处理实时推送这件事,我总结过一句很主观但实战经验支撑的话:连接多的时候,先别急着开线程。前两年做一个模拟行情推送项目的客户端,最初用线程池去维护几百条WebSocket长连接,结果线程上下文切换、缓冲读写、异常退出兜底逻辑混在一起,CPU和内存都很难看。后来把底层全部切到Python异步高性能websockets库,搭配asyncio的调度,同样的连接数,整体负载和代码可读性都提升了一大截。这篇文章想复盘一下最常用的入门路径,核心围绕三件事:怎么用websockets库接上服务端、怎么把断线重连写成可靠的状态机、怎么用asyncio的定时任务做心跳和数据巡检。适合会写同步requests、但还没系统接触过asyncio的开发者,也适合已经跑通demo却在生产环境频繁断连的运维同学参考。
1. 为什么异步WebSocket比线程池更适合长连接场景
1.1 一条长连接背后的I/O模型差异
先说一个很多人忽略的事实:WebSocket连接本身几乎不做计算,它的主要工作是“等待消息到达”。一个客户端进程维护1000条连接时,同一时刻真正有数据可读的可能只有几条,其余都在等内核通知。多线程方案里,每条连接至少要占一个线程,线程在内核态和用户态之间反复切换,一次切换的代价大概是几微秒到几十微秒,连接一多就变成纯开销。
异步方案完全不同。事件循环手里攥着一张文件描述符表,它用epoll或kqueue这些多路复用机制统一等待,哪个socket可读,就去执行对应的协程。连接从“每个线程负责一条”变成“一个循环管理上千条”,内存占用从每线程几MB降到每协程几KB。这就是为什么我习惯说:判断一个场景适不适合异步,先看你是在等数据还是在算数据,WebSocket几乎天然属于前者。
1.2 适合异步WebSocket的典型场景
我实际接触下来,最适合这套写法的场景有几类:
- 行情推送、订单状态通知这类低延迟高频率消息,消息短小,不涉及复杂计算
- 聊天室、客服会话、协同编辑这类双向通信,需要长期维持连接
- 需要同时对大量外部节点做WebSocket采集的中间层服务,比如聚合多个行情源的网关
反过来,如果你要在每条消息里做视频编解码、大规模机器学习推理、乱序比对等CPU密集操作,那就不该只靠asyncio。正确做法是让事件循环只负责收包,把重计算扔到concurrent.futures线程池或独立进程里。这样分工,事件循环依然很轻,重活也不会阻塞收发。
1.3 异步方案的代价:排查思路要切换
当然异步不是银弹,写到后面你会发现调试思路必须改。多线程出问题时,可以看线程堆栈,大概猜到是哪条连接阻塞了。异步代码里,所有协程都在一个线程里轮流跑,一个协程里出现死循环或者同步阻塞调用,整个事件循环都会被拖死,表现是“所有连接同时卡住”。
这一点对WebSocket客户端特别致命:如果你收到一条消息后调用了一个密不透风的同步函数,运行几秒钟,心跳就发不出去了,服务端会判定你失联然后主动断开。所以入门异步WebSocket的第一课不是写连接,是养成“不在事件循环里放阻塞代码”的肌肉记忆。
2. 先把最小可运行示例跑起来
2.1 选哪个库:websockets还是aiohttp
正规可用的选择不少,但最常被问的是websockets和aiohttp。下表是我比较主观的总结:
| 对比项 | websockets库 | aiohttp |
|---|---|---|
| 依赖重量 | 轻,只负责WebSocket协议 | 重,带完整HTTP客户端和服务器 |
| API风格 | 收发都叫send/recv,对称好记 | send_str/receive,语义更分散 |
| 服务端能力 | 支持,适合做本地测试 | 支持,适合和HTTP接口共用 |
| 典型选型理由 | 客户端为主、协议专注 | 项目里已经有aiohttp全家桶 |
如果你只是想写一个连接外部服务的客户端,我建议直接用websockets。它能少引入一堆依赖,代码也更容易理解。如果你已经在维护一个aiohttp应用,想在同一个进程里顺带做WebSocket客户端,那直接用aiohttp也可以,没必要再塞一个库进去。
两种库在异步模型上是一致的,理解其中一种的reconnect逻辑,换到另一种只是改几个API名。我自己日常主力是websockets,下面代码都基于它。
2.2 本地起一个回显服务端做测试
先别急着连别人的服务器,在本地起一个回显服务端,至少能帮你确认环境没问题。下面的服务端接收任何文本消息,原样返回:
import asyncio from websockets.server import serve async def echo_handler(websocket): async for message in websocket: print(f"server received: {message}") await websocket.send(f"echo: {message}") async def main(): async with serve(echo_handler, "127.0.0.1", 8765): print("server started on ws://127.0.0.1:8765") await asyncio.Future() # 一直运行,直到进程被终止 asyncio.run(main())这段代码里的async for message in websocket是接收循环的常用写法。它会不断从连接里取消息,连接正常关闭、异常断开或协议错误时退出循环。asyncio.Future()不加参数等于永远不会完成,用来让服务端常驻是最简单的方式。
客户端对应这样:
import asyncio import websockets async def main(): async with websockets.connect("ws://127.0.0.1:8765") as ws: await ws.send("hello async websocket") pong = await ws.recv() print(pong) asyncio.run(main())这个示例虽然短,但已经把最基本的语义讲透了:
async with websockets.connect(...)负责建立连接,并在退出时自动关闭连接await ws.send(...)是发送,不是即时完成的网络IO,所以必须等await ws.recv()会一直阻塞到有消息到来,这是异步的等待,事件循环不会卡住
我自己的习惯是先跑通这个回显,再改真实协议。因为大部分连接问题其实是自己的参数没对齐,比如路径拼错、鉴权字段缺失、协议版本不匹配。先在本地回显环境里把收发打通,再去查业务字段,能节省很多定位时间。
2.3 新手的常见误区:Sync和Async混着用
最容易翻车的是在异步环境里用了同步库,或者在同步函数里直接调协程。有一个简单原则:一旦代码进入asyncio.run()管辖的地盘,所有耗时IO都应该用async库,普通requests.get()、time.sleep()、socket.connect()都不应该在事件循环里出现。
如果遇到必须调用的同步SDK,你可以把它包到asyncio.to_thread()里:
import asyncio def call_legacy_sync_api(): ... async def handler(): result = await asyncio.to_thread(call_legacy_sync_api)但这不是万能药,最好还是从源头找它的异步版本。WebSocket连接一旦建立,后续收发都在一个事件循环里,任何一个阻塞调用都可能在瞬间让所有连接断掉,这一点用生产环境的流量打个照面就会记住。
3. 断线重连的正确姿势:从朴素循环到状态机
3.1 最朴素的while循环为什么不够
一个刚接触异步的开发者,很容易写出这样的重连逻辑:
while True: try: async with websockets.connect(uri) as ws: async for msg in ws: ... except Exception: continue这确实能重连,但暴露三个问题。第一,断线后立刻重连,没有间隔,如果服务端只是短暂重启,你的客户端会以一个极高频率疯狂打连接请求,把自己的CPU和对方的负载都打满。第二,没有区分异常类型,包括主动关闭、协议错误、超时,全都一样处理。第三,没有退出机制,进程想优雅关闭时,这个循环会一直卡在重试里。
这类代码在本地测试时完全正常,因为本地失败概率低,一旦拿到公网环境,网络抖动、中间设备超时、服务端升级,各种状况轮番上演,朴素循环就漏出马脚。
3.2 指数退避与随机抖动
正确思路是给每次重试增加一个等待时间,并且这个时间要随连续失败次数指数增长。第一次失败等1秒,第二次等2秒,第三次4秒,最多加到30秒或60秒,避免无限瞬时重试。在退避间隔上再加一点随机抖动(jitter),防止多客户端同时断线后,在同一个时间点发起重连,把服务端再压垮一次。
客户端代码可以这样组织:
import asyncio import logging import random import websockets from websockets.exceptions import ConnectionClosed logger = logging.getLogger("ws-reconnect") class ResilientWebSocketClient: def __init__(self, uri): self.uri = uri self.stop_event = asyncio.Event() async def run(self): backoff = 1.0 while not self.stop_event.is_set(): try: await self.handle_connection() backoff = 1.0 except asyncio.CancelledError: raise except Exception as exc: if self.stop_event.is_set(): break delay = min(backoff * 2, 30) + random.uniform(0, 1) logger.warning("connection error: %s, reconnect after %.1fs", exc, delay) await asyncio.sleep(delay) backoff = delay async def handle_connection(self): async with websockets.connect(self.uri) as ws: logger.info("connected") async for message in ws: await self.on_message(message) async def on_message(self, message): # 实际业务处理,子类重写或注入回调 logger.info("received: %s", message) async def stop(self): self.stop_event.set()实现细节值得说明:
- 成功处理过一次正常连接后,把退避重置回1秒,这样临时网络抖动不会让你长期处于低重试频率
backoff = min(backoff * 2, 30) + random.uniform(0, 1),上限30秒,抖动加到1秒以内,足够打散重试蜂拥except asyncio.CancelledError单独捕获并重新抛出,不要和其他Exception混在一起,否则调用cancel()时循环不会退出- 引入
stop_event,让外部可以优雅停止重连环
3.3 区分主动关闭与异常断开
服务端可能在收完数据后主动发起1000(正常关闭),也可能是中间网络设备把连接踢掉,导致客户端收到1006(非正常关闭)。websockets库在连接断开时会抛出ConnectionClosed异常,包含code和reason。
在on_message之外,你还可以监听ws.close_code或通过ConnectionClosed.code判断。通常策略是:
1000正常关闭:按业务决定是否重连1001服务端即将重启:可能应该等一会儿再重连1006非正常关闭:必然要重连,而且多半要退避1008协议错误:通常是鉴权或数据格式问题,立刻重连只会刷屏
合理的做法是,对于协议级错误(1008之类),先记录日志,甚至可以进入较长时间退避或者通知人工检查,而不是无限期快速重试。生产环境中我还会把连续失败次数暴露成监控指标,一旦某类错误超过阈值就报警。
3.4 优雅退出:别让重连循环拖着进程不放
很多服务进程需要接收信号后优雅退出,比如SIGTERM时关闭连接、清空缓冲、退出。前面的stop_event就是干这个用的。在外部调用client.stop()后,正在运行的重连循环会在下一次循环判断中退出;如果你正在handle_connection里处理消息,则可能还要等它处理完。
更严格的退出需要响应asyncio.CancelledError,并且主动关闭底层连接,避免连接挂着不释放。大致写法:
async def stop(self): self.stop_event.set()然后在入口主函数里:
async def main(): client = ResilientWebSocketClient("ws://127.0.0.1:8765") task = asyncio.create_task(client.run()) # 等某个停止信号,比如等键盘中断 try: await task except asyncio.CancelledError: await client.stop()这样至少能保证进程退出时不会有一部分连接还活着,导致端口释放不及时,下一轮启动失败。
4. 定时任务与心跳机制的正确协作方式
4.1 服务端为什么要把你踢下线
如果你的客户端只是连上后默默等待,不发任何数据,很多服务端会把它当成“半开连接”清理掉。所谓半开连接,就是TCP层面看起来还在,但实际对端已经感知不到有效通信。服务端为了节省资源,通常会在规定时间内没有收到任何帧就主动断开。
websockets库内置了心跳参数:ping_interval和ping_timeout。设置ping_interval=20,客户端每20秒发送一个Ping帧;如果在ping_timeout规定的秒数内没有收到Pong,它就把这条连接标记为不健康,并抛出连接异常。这个内置机制已经是第一道防线,大多数场景直接配置即可。
4.2 如果业务需要更细粒度的定时任务
内置ping解决了连接存活,但有的时候你还需要定时做业务动作,比如每隔30秒上报一次状态,或者定时从服务端拉取一次离线数据。这种需求就要自己写定时任务。
在asyncio里,定时任务通常是asyncio.create_task创建的一个无限循环协程。心跳协程可以这样写:
import asyncio import json import time import websockets async def heartbeat_loop(websocket, interval=30): while True: try: await websocket.send(json.dumps({ "type": "ping", "ts": int(time.time()) })) await asyncio.sleep(interval) except websockets.exceptions.ConnectionClosed: break这里的核心不是发送本身,而是“失败就退出循环”。因为一旦连接断开,这个协程继续循环没有意义,只会不断抛出异常。它退出了,外层负责协调的任务就会感知到,从而触发重连逻辑。
4.3 让接收循环和定时任务协同工作
一个连接如果要同时做两件事,最自然的做法是创建两个任务,一个是接收循环,一个是心跳循环,然后用gather或wait协调。简单示例:
async def handle_connection(self): async with websockets.connect(self.uri) as ws: recv_task = asyncio.create_task(self.recv_loop(ws)) beat_task = asyncio.create_task(self.heartbeat_loop(ws, 30)) done, pending = await asyncio.wait( [recv_task, beat_task], return_when=asyncio.FIRST_EXCEPTION, ) for task in pending: task.cancel() for task in pending: try: await task except Exception: pass这段代码的关键是:无论接收循环先死,还是心跳循环先死,都用FIRST_EXCEPTION触发整体收尾。pending里的另一个任务要取消,否则它会继续占用事件循环,导致重连循环以为自己还在正常连接中。
我第一次写这个逻辑时踩了个坑:只用了asyncio.gather(recv_task, beat_task),然后下面的代码永远不执行,因为gather会等所有任务完成。接收任务和心跳任务都是无限循环,如果不主动取消,这个协程永远不会退出,重连逻辑自然也就没有机会执行。
4.4 通用周期任务的漂移问题
写定时任务还有个容易忽略的点:每次执行完任务后再sleep(interval),如果任务本身耗时不稳定,实际执行周期会不断漂移。比如任务有时需要8秒,有时需要2秒,你设定30秒周期,真实的间隔就会变成32秒或38秒不等。
更严谨的做法是按目标执行时刻计算下一次等待时间:
async def periodic(coro_factory, interval): loop = asyncio.get_running_loop() while True: start = loop.time() await coro_factory() elapsed = loop.time() - start await asyncio.sleep(max(0, interval - elapsed))同时要注意,如果任务异常退出,而你想让它继续轮询,就需要在coro_factory()外层接住异常。否则一个异常就会让整个周期任务终结,心跳也就停了,这种bug很难在本地复现,因为本地出错概率低。
4.5 定时任务必须随连接生命周期重建
一条连接断开后,之前基于这条连接创建的定时任务是无效的。如果定时任务继续使用旧的websocket对象发送消息,会不断抛出异常,甚至可能干扰重连逻辑。
我推荐的做法是:定时任务只存在handle_connection()内部,连接断开,任务随之终结,下一次重连成功后再重新创建。别把定时任务提到run()外层,那样它还得操心连接是否还存在,代码复杂度会上升一个等级。
5. 性能与稳定性:把连接扛到生产环境
5.1 背压问题:消费速度跟不上消息速度
服务端消息密集时,客户端面对的最大威胁是背压(backpressure)。websockets库的接收端有一个内部队列,客户端通过async for message in ws或ws.recv()消费。如果消费速度太慢,队列堆积,内存持续飙升,最终要么连接被中断,要么进程OOM。
应对思路有两个。第一,消费逻辑要快,JSON解析、数据库写入都尽量异步化或线程化,不要让接收循环承担重活。第二,如果服务端支持,优先订阅你真正关心的数据,比如只订阅某几个交易对、某几个事件类型,而不是一股脑全量订阅。第三,可以监控接收队列长度和ws.recv()一次调用的耗时;发现耗时放大,说明消息处理已经跟不上。
5.2 max_size和消息大小限制
websockets库默认单条消息大小上限约1MB,这是为了防止一个恶意或异常的大消息把内存打爆。如果你的业务消息体可能超过这个阈值,比如二进制行情快照,就需要显式调大:
websockets.connect(uri, max_size=16 * 1024 * 1024) # 16MB调大max_size也意味着内存上限变高,遇到超大规模消息时要注意防护。更适合的方案是检查消息内容、确认来源可信,再决定是否调大。小消息场景不要为了省事盲目调大,给异常数据留的开口越大,风险越高。
5.3 send并发调用时的行为
在一个连接上,如果多个任务同时调用send,websockets库内部会做发送锁,基本不会出现帧交错。但你仍然要小心逻辑层的顺序:如果业务上严格要求消息A先发、消息B后发,而A和B分别在不同的异步任务里发送,那么需要自己用队列或锁保证顺序。
我见过一个案例:两个协程同时往服务器推数据,一个推价格,一个推订单,价格消息偶尔排到订单消息后面,导致客户端逻辑错乱。后来改成统一的发送队列,所有要发送的消息按产生时间排序逐条发送,问题消失。WebSocket本身只是TCP通道,不会帮你做业务排序。
5.4 连接数上去之后的资源维度
单进程异步可以支撑的连接数远超线程方案,但也不是没有上限。每个WebSocket连接至少消耗一个socket文件描述符,开启TLS时额外消耗内存和CPU。达到几千条连接时,你可能要调整系统文件描述符上限,比如ulimit或容器层的limits配置。
另外,如果连接使用wss://,TLS握手在并发连接高峰时会比较吃CPU。面对这类场景,可以给客户端连接加上open_timeout参数,避免某个服务端不响应握手,连接一直挂在那里占资源。握手超时和正常通信超时是两个维度,都要设置。
5.5 事件循环必须有最后一道“看门狗”
重连机制和心跳能解决大部分问题,但要警惕“看起来还连着,实际已经死掉”的情况。TCP层面连接断开,可能由于网络设备静默丢弃,客户端短时间内感知不到。
ping_interval和ping_timeout就是内置看门狗,但如果你关闭了它,比如设成ping_interval=None,那就必须自己发应用层心跳。在我的实践里,更喜欢的方案是给接收循环维护一个last_message_at时间戳,定时巡检任务会检查这个时间戳,如果超过阈值就主动断开并触发重连。这比单纯依赖底层ping更贴近业务:服务端如果持续没有业务消息,即使ping返回成功,也可能业务逻辑已经卡住了。
6. 调试与监控:让重连不再是一门玄学
6.1 日志字段设计
写重连逻辑时,我不建议只在异常时打日志,而是把状态转换都打出来。一个稳定的客户端日志应该包含:
- 连接ID或连接对象标识,方便串联日志
- 当前状态:connecting、connected、disconnected、reconnecting
- 断线时的close code和reason
- 当前退避时长、连续失败次数
- 最近一次收到消息的时间戳
日志格式可以参考结构化输出。例如断线时记录这一条:
[client] disconnected code=1006 reason="connection reset by peer" retries=3 next_retry=4.2s这样的日志排查问题非常省力,能直接从日志看出断线是哪个阶段、什么原因、退避策略是否生效。
6.2 本地用故障注入模拟服务端断开
找一个测试服务端,故意让它断开连接,验证你的重连和心跳是否正常,这个方法比等生产环境出问题靠谱得多。写一个会随机断开的服务端:
import asyncio import random from websockets.server import serve async def flaky_server(websocket): count = 0 async for message in websocket: count += 1 if count == 5: # 模拟服务端异常退出 await websocket.close(code=1011, reason="simulated failure") return await websocket.send(f"ok:{message}") async def main(): async with serve(flaky_server, "127.0.0.1", 8765): await asyncio.Future() asyncio.run(main())然后启动你的客户端,观察它是否在收到5条消息后触发重连,退避是否正常,心跳定时任务是否随着连接重建而重建。这类故障注入demo能模拟掉真实环境里八成以上的断连情况。
6.3 可观测性指标
生产环境的客户端最好暴露几个关键指标,我常用的是:
- 当前连接状态(0=初始,1=已连接,2=重连中)
- 连续重连次数
- 最近一次连接建立耗时
- 上一次收到消息的时间戳差
- 堆积未处理消息数
不需要引入重型监控系统,用一个内部计数器对象就能完成。指标的意义不是为了炫技,而是让你在服务端状态还没恶化前,先看到客户端侧的异常。
写在实际运行之后的几点个人体会
退避参数不是越大越好,也不是越小越好,要根据服务端重启时间、网络链路最长恢复时间综合判断。我之前一度把上限设到300秒,结果一次服务端短暂重启,客户端要等5分钟才重新连上,业务被迫断档。后来改成30秒上限加随机抖动,既不会打崩服务端,恢复也足够快。
心跳和业务逻辑要分开看。内置ping_interval=20, ping_timeout=10是我比较常用的起步值,但如果你的业务消息本身就比较频繁,心跳可以更保守一点,甚至依赖业务消息作为活跃信号。防止中间设备掐连接的原则是:别让链路长时间静默。
如果你在真实项目里同时处理几十个连接、每个连接还带独立的重连和定时任务,建议把公共逻辑抽成一个通用类,连接句柄、退避状态、日志上下文都做成可配置项。这篇示例里的ResilientWebSocketClient就是最小模板,按业务加个消息解析、加个发送队列,基本能覆盖大多数生产场景。