
Tornado这名字搞后端的老哥应该都不陌生。它不是我见过最花哨的Web框架但绝对是“异步”这一概念在Python圈子里最好的启蒙老师之一。早在大部分框架还在靠多线程硬扛并发的时候Tornado就已经用单进程加事件循环的方式把成千上万的并发连接玩得明明白白。这篇文章我想从实际使用的角度聊聊怎么用Tornado从零搭一套能抗住高并发实时请求的服务以及这几年我在它身上踩过的坑和总结下来的心得。现在网上聊高并发十个帖子九个在说Go剩下的在说Java Netty。Python老被说慢但慢不代表不能做高并发。如果你对异步编程有一定基础想在Python技术栈里搞一个轻量、高效、能处理长连接和实时消息推送的服务那Tornado依然是很能打的选手。即便你之前只接触过Flask或Django这类同步框架只要理解了Tornado的异步模型也能在架构思路上打开一扇新的大门。这篇文章不打算讲太多华丽的架构图而是实实在在的代码、配置、压测数据和踩坑记录。整个项目从为什么选Tornado开始然后讲透同步和异步的天壤之别再一步步搭出服务骨架最后专门把我在生产环境里经常碰到的问题拎出来逐个分析这样后续真的用到它的时候会比翻官方文档省力不少。1. 为什么是Tornado百万级并发背后的核心思路聊并发之前先搞清楚一件事并发和并行是两码事做后端服务我们要处理的核心矛盾其实是“海量连接”和“有限线程资源”之间的矛盾。1.1 传统多线程模型的瓶颈传统的Web服务比如早期经典的Flask配合gunicorn多worker部署每一路请求进来服务端基本都要占用一个线程。线程虽然比进程轻量但依然有调度成本空闲时占用的内存也不小。粗略算一下一个线程默认栈空间大约8MB虽然真实物理占用不会一开始就拉满但要撑到几万个并发连接的时候上下文切换带来的CPU开销绝对会让人头大。而百万级并发这个说法这里的“并发”通常指的是瞬时连接数也就是TCP连接层的并发承载能力不是字面意义上的每秒处理一百万次完整的业务请求。真要每秒一百万QPS任何单机框架都扛不住那必须是分布式集群加各种缓存策略共同努力的结果。理解这一点很重要不然很容易被数字带偏。1.2 事件循环一个人干一万个人的活Tornado的核心是IOLoop事件循环。它的原理其实是比较朴素的一个进程里只有一个线程在跑一个循环这个循环持续不断地检查哪些socket有事件发生有数据就处理数据没数据就继续空转等操作系统帮忙唤醒。打个比方传统多线程就是开一千个窗口的银行每个窗口配一个柜员。而事件循环是一个超级柜员客户来了他登记一下姓名电话就叫客户先去旁边等着等材料真正准备好了再叫号办理。这个“登记一下”的动作就是注册一个回调事件而“去旁边等着”就是异步IO的挂起操作。这位超级柜员虽然表面看着懒散一直在循环里东张西望但他一个人就把一千个窗口的活给接了因为大部分时间他并不真正在处理数据只是在等待。Linux上的epoll帮了Tornado很大的忙这是成熟的内核级事件通知机制数量巨大的文件描述符都能交给内核去监听状态变化内核只在有事件就绪时才通知应用层。这也是Tornado能撑住高连接数的底气来源之一。1.3 Tornado和常用Python框架的定位差异选框架之前一定要先想清楚使用场景。Flask和Django属于同步阻塞型开发体验极好生态丰富但面对海量长连接时线程模型会成为天花板。FastAPI虽然支持异步但底层高性能部分依赖uvicorn。Tornado的优势在于自带的IOLoop和HTTP协议栈都是原生异步实现内置WebSocket支持不需要额外引入ASGI服务器单独部署做一个实时推送服务它可以一条龙直接搞定。Tornado可以理解成“框架和服务器一体”这正是它做实时服务的核心价值。很多人拿Tornado和Flask比性能其实不太公平双方解决的问题是不同的。Flask适合业务系统Tornado适合对长连接和低延迟有要求的网关层或推送层。2. 同步与异步Tornado开发者的第一道思维门槛很多人刚开始写Tornado写出来的代码却“很不Tornado”。最常见的问题就是写了一个async def但里面调用的全是同步阻塞操作结果异步等于没异步连接一来照样卡死。2.1 同步阻塞的代价当一个async函数里出现了类似time.sleep(2)这样的代码整个事件循环都会停住。因为事件循环就是那一个线程它被睡眠操作期间别的socket事件再就绪也没人去处理。表现就是这个连接卡住的同时其他所有连接全部跟着遭殃。代码里不能用同步阻塞数据库驱动也得选异步版本这是Tornado开发中最根本的一条规则。比如连接数据库老老实实用asyncpg、aiomysql或者SQLAlchemy的异步模式别图省事直接往里面塞psycopg2的同步连接后果就是灾难性的。2.2 理解await的挂起与恢复Python在3.5之后引入了原生协程语法Tornado很快就跟进适配了。现在我们写的async def函数体里遇到await关键字时会把这个协程的未来Future交给事件循环事件循环记录下“这个协程在等某个事件完成”然后立刻去处理别的就绪事件。那个未来一旦完成事件循环会再回来把协程往后推进一步继续执行后面的代码。这个过程没有多线程切换所以也就没有竞态条件和加锁的烦恼当然如果你把同样的变量在多个回调里共享且不加保护另当别论。整个服务的内存开销几乎恒定因为线程数固定为一个等待中的连接不占额外栈空间。2.3 gen.coroutine时代留下的宝贵遗产现在网上搜Tornado的旧代码还能看到大量的gen.coroutine和yield写法那是Python原声协程落地之前Tornado自己搞的协程协议。如果维护老项目会见到类似代码但新项目一定要用async/await语义更清晰也更好和其他异步库协作。需要说明的是Tornado的异步生态虽然没有像Node.js的npm那么庞大但核心场景完全够用了。2.4 同步库转向异步操作的常见套路面对一些冷门同步库比如某个特殊的监控SDK没法立刻找到异步版本时可以用IOLoop.run_in_executor把它扔到线程池里去做然后把结果返回给协程至少能保证主事件循环不被阻塞。这个思路属于“拆东墙补西墙”的权宜之计但确实管用。from tornado.concurrent import run_on_executor from concurrent.futures import ThreadPoolExecutor class SomeHandler(RequestHandler): executor ThreadPoolExecutor(4) async def get(self): result await self.sync_heavy_work() self.write({status: result}) run_on_executor def sync_heavy_work(self): # 这里跑的是同步代码会在独立线程执行 return do_something_slow()注意这里的线程池不能开太大否则线程切换成本会把异步优势吃掉。核心原则还是那句话能用纯异步解决的别用线程池。3. 从零搭建一个可承受高并发的实时服务骨架这个项目如果是实战可能需要一个具体业务背景。举个我能快速说清楚的例子做一个简单的实时在线状态通知服务客户端通过WebSocket连接上来服务端维护在线列表并把上下线事件实时广播给所有订阅者。这个场景特别适合用Tornado因为连接是长连接如果选同步框架每个客户端都要占用一个线程几万个客户端就要几万个线程直接不可行。Tornado的WebSocket支持是内置的写起来要比用Flask加第三方库顺手太多。3.1 搭建基础项目结构先列一个标准的项目结构realtime_service/ ├── app.py ├── handlers │ └── ws_handler.py ├── models │ └── connection_manager.py ├── config.py └── requirements.txt这是非常简单的单体结构。如果一个服务里要承载多种业务协议可以考虑把路由和handler拆得更细。但当前这个阶段保持轻量比什么都重要。3.2 核心连接管理器WebSocket场景里连接管理器是所有连接的核心。它负责记录每一个建立起来的WebSocket连接并在需要广播时遍历所有连接推送消息。# models/connection_manager.py import time from typing import Dict from tornado.websocket import WebSocketHandler class ConnectionManager: def __init__(self): self._connections: Dict[str, WebSocketHandler] {} self._last_seen: Dict[str, float] {} async def connect(self, client_id: str, handler: WebSocketHandler): self._connections[client_id] handler self._last_seen[client_id] time.time() await self.broadcast({type: online, client_id: client_id, online_count: len(self._connections)}) async def disconnect(self, client_id: str): if client_id in self._connections: self._connections.pop(client_id, None) self._last_seen.pop(client_id, None) await self.broadcast({type: offline, client_id: client_id, online_count: len(self._connections)}) async def broadcast(self, message: dict): data message for client_id, handler in list(self._connections.items()): try: await handler.write_message(data) except Exception: # 连接可能已经断了延时清理 self._connections.pop(client_id, None)这个管理器就是一个内存字典的封装。这里的核心问题是当连接数上万时广播一次消息就得遍历上万次字典。考虑到单条消息体通常不大这个O(n)的成本还是能接受的。如果连接数真的到了几十万级别就得考虑换成分组广播甚至消息队列来做扇出而不是单进程直接遍历。3.3 WebSocket处理器WebSocket handler本身是Tornado里最典型的异步场景。open、on_message、on_close三个核心方法分别对应连接建立、收到消息、连接关闭三个生命周期事件。# handlers/ws_handler.py import uuid from tornado.websocket import WebSocketHandler from models.connection_manager import ConnectionManager class RealtimeWebSocketHandler(WebSocketHandler): manager ConnectionManager() def check_origin(self, origin): # 生产中要把跨域验证写好不能无脑全部放行 return True async def open(self, *args, **kwargs): self.client_id self.get_query_argument(client_id, defaultstr(uuid.uuid4())) await self.manager.connect(self.client_id, self) async def on_message(self, message): # 消息进来先解析、再分发。这里要避免在handler里做重活 try: data message # 业务逻辑入口比如处理心跳、转发业务消息等 if data ping: await self.write_message({type: pong}) else: await self.manager.broadcast({from: self.client_id, data: data}) except Exception as e: self.application.logger.error(fhandle message error: {e}, exc_infoTrue) await self.write_message({type: error, detail: str(e)}) def on_close(self): # 注意这里不能用 awaitmanager.disconnect 是异步方法 # 可以借助 IOLoop 把清理协程调度出去执行 from tornado.ioloop import IOLoop IOLoop.current().add_callback(self.manager.disconnect, self.client_id)这段代码里体现了一个细节点on_close是同步回调里面不能直接await。这是个新手容易踩的大坑。用add_callback把清理动作扔给事件循环队列倒是很干净的做法。3.4 启动入口Tornado的启动非常简单核心就是实例化App监听到某个端口然后进入IOLoop。我习惯把启动参数做成可通过环境变量调整便于后面在不同环境下运行。# app.py import os import logging from tornado.web import Application, RequestHandler from tornado.ioloop import IOLoop from tornado.options import options, define from handlers.ws_handler import RealtimeWebSocketHandler define(port, default9000, helplistening port, typeint) class HealthHandler(RequestHandler): async def get(self): self.write({status: ok, connections: len(RealtimeWebSocketHandler.manager._connections)}) def make_app(): return Application( [ (r/ws, RealtimeWebSocketHandler), (r/healthz, HealthHandler), ], debugos.getenv(DEBUG, 0) 1, ) if __name__ __main__: logging.basicConfig(levellogging.INFO) app make_app() app.listen(options.port, address0.0.0.0) logging.info(fServer started at port {options.port}) IOLoop.current().start()这个启动方式里app.listen会注册监听socket到IOLoop而start之后整个进程就死守在这个循环里了。单进程Tornado跑满一个CPU核心没问题。至于多核利用后面会专门说。3.5 路由与业务解耦的一些思考如果后续业务变重建议把路由配置独立成一个模块把各个handler通过工厂函数注册进去。更进阶一点可以引入tornado.routing.Rule来做更复杂的路径匹配。但核心原则是handler只是薄薄的一层壳负责解析参数、调用业务模块、返回结果业务逻辑别直接堆在handler里否则后期维护成本会直线上升。4. 百万级连接背后的“穷”策略单进程能力边界与集群扩展说实话“百万级连接单机搞定”是可以通过调内核参数来达成的但真正生产环境里没人真敢把所有鸡蛋放在一个进程里。更重要的是单进程Tornado能承载的连接数和能达到的吞吐量之间并不完全是正比关系。4.1 单进程的承载极限官方曾提到Tornado单进程极限情况下可支撑上万甚至数万连接。我在实际压测中用一台4核8G的云主机纯内存操作不接外部依赖单进程Tornado可以稳定保持几万长连接在线CPU占用在80%上下浮动。如果要达到百万级别连接那一定不是单实例而是多点接入。百万连接这个数字真正想表达的是整体系统架构的容量上限是一整套前后端协同、网关层管理、服务层纵向伸缩的综合结果。用Tornado做单点它是合格的连接层组件用Tornado做全系统它扛不住也不该由它扛。4.2 多进程部署的正确姿势Python的GIL导致单进程无法充分利用多核CPUTornado也一样。生产环境要充分发挥多核能力正确的选择是supervisor配合多个进程各自监听不同端口。前端放一层负载均衡比如Nginx或者HAProxy把连接按策略分发到不同端口上。但有个问题WebSocket长连接一旦建立负载均衡必须支持会话保持基于IP哈希转发可以做到把同一个客户端的连接始终固定到同一个后端进程。之所以要会话保持是因为如果长连接在传输过程中被负载均衡搞到另一台机器上连接会直接断开客户端就必须发起重连这在实时推送场景里是致命的体验问题。4.3 内核参数调优打开连接数天花板要支撑大规模长连接系统层面的文件描述符上限必须改不然一个进程莫名其妙就报Too many open files。这里放一组我常用的内核参数调整适用于Linux生产环境# 用户级文件句柄限制 ulimit -n 1048576 # 全局文件句柄限制 sudo sysctl -w fs.file-max2097152 sudo sysctl -w fs.nr_open2097152 # TCP连接复用和快速回收 sudo sysctl -w net.ipv4.tcp_tw_reuse1 sudo sysctl -w net.ipv4.ip_local_port_range1024 65535tcp_tw_reuse解决的是TIME_WAIT过多导致的端口耗尽问题ip_local_port_range扩充了本地端口可用范围。如果客户端大量短连接反复建立断开这组配置基本是必需的。4.4 不要忽视内存占用连接也是要花钱的单个WebSocket连接在Tornado里除了socket本身的缓冲区还要维护协议状态和业务对象比如ConnectionManager里的字典条目。几十万连接即使什么都不干光内存就有好几个GB的量级。因此生产环境里布设内存监控是很有必要的连接数的突增往往先于CPU飙升就成了扩容的第一信号。5. 压测实战与性能调优记录搭建完成之后压测永远是最好的试金石。我自己一般用websocket-bench做WebSocket压测工具也可以用locust去模拟更真实的连接行为。这里的关键是不光要看能建立多少连接还要看连接建立后消息转发的时延表现。5.1 压测场景设计模拟一万个客户端并发连接每个客户端建立连接后每5秒发一条心跳服务端收到心跳后隔0.5秒模拟正常业务处理推送一条广播消息统计P99时延。5.2 压测数据参考在我的测试环境4核8G云主机里单进程处理一万个长连接时广播延迟在10毫秒以下。但把连接数拉到五万同样的广播逻辑P99时延明显上升到50毫秒左右。原因很简单广播是O(n)的遍历连接越多单次广播的耗时自然越长。这也验证了前面分组广播的必要性。要优化广播时延第一个方向是分组把用户划分为多个房间或者多个通道广播时只遍历目标组内的连接而不是对所有连接做全局遍历。第二个方向是分片一个分组对应一个进程让连接分布到多个进程上减小单进程的压力。5.3 内存与GC调优Python的GC在大量短生命周期对象创建销毁时会产生明显抖动。实时服务里如果每秒要处理上万个消息且每个消息都创建一堆字典对象老年代GC一旦触发全进程都会停顿一小会。这个停顿在长连接场景里就很明显客户端那边能感知到收到的推送突然卡了零点几秒。我常用的做法尽量复用消息结构不要频繁创建临时大字典用--gc-threshold调低垃圾回收阈值让GC更频繁更早执行避免一次大扫除在代码层面避免在热点路径上无谓地构造中间对象5.4 用官方诊断工具辅助Tornado自带一个轻量级的/debug性能诊断页开启debug模式后可以查看当前事件循环的堆积情况。生产环境开启debug会有性能损耗但排查问题时会非常直观。低峰期临时开一下配合日志看吻合度很多时候问题原因一眼就能定位。6. 实战中高频踩坑清单别在同一个地方摔倒两次这部分内容是从一个个线上事故里捞出来的。每一条都有人花了不少时间才解开谜题这里直接整理成清单按表象、原因和解决方式三条线记录。6.1 事件循环被同步代码堵死这是Tornado异步开发中的第一大杀手。表象是某个接口偶尔卡住紧接着所有连接集体掉线CPU却原地起飞。原因就是某个请求里偷偷执行了同步调用的Redis或者数据库操作。排查方式很简单开启debug日志看看那些日志长期没有输出而请求又堆积在哪个handler。解决方式也很直接代码审查、压测覆盖、把同步库全部换掉。6.2 广播风暴导致连接雪崩广播本身是异步的但是如果广播的消息量突然暴增比如某个接口被错误地循环触发每个连接都会收到海量消息这会造成消息队列在socket层堆积。一旦客户端消费不过来TCP缓冲区被填满服务端写操作就会进入等待然后事件循环的处理时延就会无限上升。最后几个瞬间内存飙升大量连接被reset。这个问题的核心是缺少背压控制需要在广播前做流量控制和消息丢弃策略宁可丢消息不可拖垮整个服务。6.3 WebSocket的保活心跳不能省长连接最怕的不是断怕的是断了没人知道。用户手机切了网络、电脑合上了盖子TCP连接可能已经死了但服务端还傻傻地认为连接活着。等广播消息来时写操作失败才后知后觉地去清理。这种情况如果累积多了服务端会维护一堆僵尸连接白白浪费内存和文件描述符。解决方式是业务心跳客户端每30秒发一个心跳包服务端连续两个心跳间隔没收到数据就主动关闭连接。做实时通信服务的这几乎是标配需求。6.4 调用write_message前务必处理掉异常write_message在连接已经关闭的情况下调用会抛WebSocketClosedError。这个异常如果没被捕获会一路向上传递到事件循环直接把整个进程打挂。正确做法是要么在调用前判断连接状态要么在broadcast方法里把所有write_message用try包裹做到连接失效时静默清理。6.5 on_close里面的异步陷阱on_close本身是同步回调在WebSocket协议栈处理完关闭事件后立刻执行。如果你在on_close里直接调用异步方法又不做处理协程会中断错误日志还不明显导致看起来连接释放了实际后端状态没清理干净。更麻烦的是这种情况下连接可能已经被Tornado从底层移除了但你管理的在线列表里还留着记录时间一长内存和逻辑双双失控诡异问题不断。6.6 生产部署不能裸奔Tornado自带的HTTP服务器可以用于开发环境但如果直接暴露到公网面对恶意流量和慢连接攻击它的抗压能力会大打折扣。生产环境前面必须加一层Nginx做反向代理和SSL终结Nginx对TCP连接的管理和静态内容处理要比Tornado自身强得多。也别忘了配置client_max_body_size等参数防止大包把WebSocket通道挤爆。Tornado这套东西真正验证的是“合适的工具做合适的事”。如果有人说Python做不了高并发服务我会直接用上面的整套方案和数据反驳Python做不了的是CPU密集型服务但在IO密集型实时长连接场景下异步模型照样能优雅地管理海量连接。关键在于开发者能不能管住自己的手让所有操作尽量走异步路径远离阻塞调用。我自己在实际使用中最深的体会是Tornado真正磨人的地方从来不在框架本身而在背后的一整套操作系统细节、网络协议理解和Python异步模型融会贯通的综合能力。建议第一次上生产的同学先在测试环境把压测数据跑透再小流量灰度宁可保守一点也别让线上替他交学费。如果后续有机会把那套ConnectionManager替换成Redis Pub/Sub或者引入消息队列做成真正的多机分布式在线推送服务又是一片完全不同的进阶领域。