距離上次排查 epoll 與 CLOSE_WAIT 連接配接 的問題,已經過去了将近一年。最近在看 《UNIX 網絡程式設計》,看到 “TCP 狀态轉換圖” 中提到 CLOSE_WAIT 狀态時,突然又想起來上次還有一個 遺留問題,于是決定再次嘗試分析一下。
一、問題現象
上次的遺留問題,歸納起來就是:(由于 Redis 的 server 端主動關閉逾時連接配接)在 client 端産生的 CLOSE_WAIT 連接配接,一直無法被 redis-py 連接配接池複用,進而無法被正常 close。
二、分析 redis-py 連接配接池機制
以目前最新的 redis-py 2.10.6 為例,從連接配接池擷取連接配接 的源碼:
1
2
3
4
5
6
7
8
9def get_connection(self, command_name, *keys, **options):
"Get a connection from the pool"
self._checkpid()
try:
connection = self._available_connections.pop()
except IndexError:
connection = self.make_connection()
self._in_use_connections.add(connection)
return connection
1
2
3
4
5
6
7def release(self, connection):
"Releases the connection back to the pool"
self._checkpid()
if connection.pid != self.pid:
return
self._in_use_connections.remove(connection)
self._available_connections.append(connection)
可以看出,redis-py 使用 _available_connections 來維護 “空閑可用的連接配接清單”,擷取連接配接時 pop 出清單末尾的連接配接,釋放連接配接時 append 連接配接到清單末尾。是以 “空閑可用的連接配接清單” 其實是個 後進先出的棧。
很顯然,基于這種 “後進先出的棧” 的資料結構,redis-py 連接配接池對連接配接的擷取和釋放都發生在 “棧頂”。至此,原因就很明顯了:如果某段時間内由于突發流量産生了大量連接配接,一旦流量趨于平穩(減少)後,位于 “棧底” 的部分連接配接就會一直無法被複用,于是這些連接配接被 Redis 的 server 端逾時關閉後,就會一直處于 CLOSE_WAIT 狀态。
三、解決方案
為了讓 redis-py 連接配接池能夠更均衡地複用各個連接配接,很容易想到的一個方案是:将資料結構從 “後進先出的棧” 改成 “先進先出的隊列”。
通過修改 get_connection 的實作可以很容易做到這一點:
1
2# connection = self._available_connections.pop()
connection = self._available_connections.pop(0) # 擷取連接配接時,從隊列首部 pop 出來
關于這個方案,其實在 GitHub 上也有一個 pull request:Connection management improvements,然而還是沒有得到響應 :-( 不得不手動尴尬一下…
四、複現和驗證
為了簡化場景,便于問題的複現和方案的驗證,這裡有一段輔助代碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30# example.py
import select
import redis
def main():
import os; print('pid: %s' % os.getpid())
r = redis.StrictRedis(host='localhost', port=6379, db=0)
pool = r.connection_pool
epoll = select.epoll()
for conn in (pool.get_connection(''), pool.get_connection('')):
conn.connect()
epoll.register(conn._sock, select.POLLIN)
pool.release(conn)
command_args = ('SET', 'foo', 'bar')
while True:
conn = pool.get_connection('')
conn.send_command(*command_args)
epoll.poll()
r.parse_response(conn, command_args[0])
pool.release(conn)
if __name__ == '__main__':
main()
操作步驟提示:
設定 Redis 的 server 端的 timeout 參數(比如 10 秒)
運作代碼(python example.py)
一段時間後,觀察程序的 CPU 占用率(top)
觀察程序是否有 CLOSE_WAIT 連接配接(lsof -p PID)