ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Flask-SocketIO 实时通信指南:从 WebSocket 原理到生产部署

Flask-SocketIO 实时通信指南:从 WebSocket 原理到生产部署 说实话刚开始做实时推送功能的时候我对 Flask-SocketIO 是有点不屑的。因为 Flask 本身就是同步框架处理 WebSocket 这种长连接场景怎么看都有点勉强。但后面被业务按在地上摩擦了几次——产品经理要做在线人数统计、客服要实时聊天、运维监控要推送告警——我才发现工具不在于新在于顺不顺手。Flask-SocketIO 这个库表面上是给 Flask 加了个 WebSocket 能力实际上它把整个实时通信的复杂度都封装好了直接用就行。这篇文章我就从自己的实际项目出发聊聊在 Flask 项目里集成 Socket.IO 的完整链路从握手原理、事件分发、房间广播到 Nginx 代理配置、常见断连报错排查再到和 Spring Boot WebSocket、Node.js Socket.IO 的技术选型对比。适合正在做 Python Web 后端、又被实时推送需求逼到头秃的开发者也包括想搞懂 WebSocket 到底怎么落地的初中级玩家。文章里的配置和代码都是我在生产环境实测过的照着抄基本能跑。1. 为什么我最终选了 Flask-SocketIO 而不是原生 WebSocket1.1 Flask 原生实现长连接的几个尴尬Flask 跑在 WSGI 协议上而 WSGI 的设计模型是一次请求、一次响应处理完就断开。你要在 Flask 里做长连接第一个想到的是搞一个后台线程不断往前端写数据。但这种方式有几个硬伤第一Flask 的Response对象不支持流式写你要用yield配合stream_with_context硬撑一旦客户端断开服务器很难及时感知第二连接多了以后线程数量直接爆炸用线程池也要命第三最麻烦的是你得自己维护一个连接池自己实现心跳自己处理断线重连这些代码写出来就是一团乱麻。有人会说那我直接用websockets这个库不就行了确实可以但websockets库是一个独立的 ASGI/异步框架和 Flask 的request、session、before_request这些机制完全是两套体系。你需要在两套体系之间做用户认证、做上下文传递光是桥接代码就能写几百行。还有老版本浏览器不支持 WebSocket 协议你又得自己写降级方案这又是一个坑。1.2 Flask-SocketIO 帮你封装了什么Flask-SocketIO 做的事就是把这些脏活累活全部揽下来。它实现了完整的 Socket.IO 协议核心亮点有三个协议自动降级优先走 WebSocket如果浏览器不支持或者网络环境不支持自动降级到 HTTP 长轮询前端代码完全不用改。事件驱动模型你不需要解析消息类型而是注册不同的事件处理函数像on(connect)、on(message)、on(disconnect)这是它最顺手的地方。房间Room与广播Broadcast给连接打个标签然后往标签对应的所有连接推数据。做群聊、做全局通知、做定向推送都是现成的。更重要的是Flask-SocketIO 是在 Flask 的应用上下文之上运行的你依然可以用 Flask 的session、request来拿用户状态也可以直接调用数据库 ORM。这意味着你不需要重新学习一套框架只需要理解几个新概念就能把实时能力嵌进现有的 Flask 项目里。1.3 用对讲机的逻辑理解一次完整的通信过程如果你没接触过 Socket.IO我用一个对讲机的场景来解释。Flask-SocketIO 启动后就相当于给服务器装了一台总台。每个浏览器连上来就像领了一台对讲机connect事件就是对讲机开机。之后所有消息都有两个标签事件名和数据。客户端调emit(chat, {msg: 有人吗})相当于对着对讲机说了一句带标签的话。服务端用socketio.on(chat)监听这个标签然后决定是回复当前对讲机、回复整个频道还是转发给某几个人。这个模型和传统的 HTTP 请求 - 响应模式完全不同。HTTP 是你要什么我给什么Socket.IO 是服务端想什么时候说话就什么时候说话。理解了这一点后面所有代码逻辑都能串起来。2. 快速搭一个能跑的实时通信 Demo2.1 环境准备和安装我假设你已经有 Python 3.8 环境Flask 项目也已经建好了。安装很直接pip install flask-socketio注意flask-socketio有几个依赖需要额外确认。在 Python 3.10 的环境下新版本默认使用纯 Python 的simple-websocket作为 WebSocket 实现日常开发不用管。但如果你想跑高性能生产环境后面会提到要安装eventlet或者gevent这里先不做。Flask-SocketIO 的版本兼容性需要注意一点最新版5.x要求 Flask 2.x 以上如果你还在用 Flask 1.x建议先把 Flask 升级到 2.x 或者 3.x不然会有 API 不兼容的报错。我自己就在一次老项目里踩过这个坑——Flask 1.1.4 配上 Flask-SocketIO 5.3启动直接报TypeError。2.2 后端代码一个最简服务端新建一个app.py写一个最简单的实时回显服务from flask import Flask, render_template from flask_socketio import SocketIO, emit app Flask(__name__) app.config[SECRET_KEY] your-secret-key socketio SocketIO(app) app.route(/) def index(): return render_template(index.html) socketio.on(connect) def handle_connect(): print(客户端已连接) emit(server_event, {data: 欢迎连接}) socketio.on(client_event) def handle_client_event(data): print(收到客户端消息, data) # 原样返回给发送方 emit(server_event, {data: 服务端收到 str(data.get(data))}) # 广播给所有连接 # emit(server_event, {data: 广播消息}, broadcastTrue) socketio.on(disconnect) def handle_disconnect(): print(客户端已断开) if __name__ __main__: socketio.run(app, host0.0.0.0, port5000, debugTrue)这里有几个点要留意不是用app.run()而是用socketio.run(app)。只有这样才能启动 Socket.IO 的服务器默认是 Werkzeug 的线程模式开发环境够用。事件处理函数的名称就是事件名你可以随意定义client_event、server_event但connect和disconnect是保留事件名。emit默认只把消息发回给当前连接的客户端。如果要发给所有人必须显式加broadcastTrue这里我注释了。2.3 前端浏览器客户端在templates/index.html里引入 Socket.IO 客户端 JS!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleFlask-SocketIO Demo/title /head body h1Flask-SocketIO 实时通信测试/h1 input idmsg typetext placeholder输入消息 button onclicksendMsg()发送/button ul idlog/ul script srchttps://cdn.socket.io/4.7.5/socket.io.min.js/script script const socket io(); socket.on(connect, function() { appendLog(连接成功session id socket.id); }); socket.on(server_event, function(data) { appendLog(服务端返回 data.data); }); socket.on(disconnect, function() { appendLog(连接断开); }); function sendMsg() { const msg document.getElementById(msg).value; socket.emit(client_event, {data: msg}); appendLog(我发送 msg); } function appendLog(text) { const li document.createElement(li); li.textContent text; document.getElementById(log).appendChild(li); } /script /body /html前端io()默认连接当前域名和端口会自动携带 Socket.IO 握手需要的参数。socket.emit(client_event, data)对应后端的socketio.on(client_event)。返回事件server_event由socket.on(server_event, ...)接收。事件名字符串只要前后端一致就行我习惯统一命名风格前端事件用client_xxx后端广播用server_xxx这样看着不会乱。2.4 跑起来看效果启动服务打开浏览器访问http://localhost:5000。你会看到控制台打印客户端已连接 收到客户端消息 {data: 你好}页面收到返回消息后自动追加到列表里。实测下来这里的连接速度非常快从握手到connect回调触发基本在几十毫秒级别。如果连接失败优先检查是不是端口被占用或者浏览器版本太老不支持 WebSocket。我调试期间遇到过一次 404是因为socket.io.min.js的 CDN 地址没配对写错版本号导致找不到文件这类问题前端控制台一眼就能看出来。3. 从 Demo 到真实业务连接生命周期、房间与广播3.1 连接生命周期里最容易忽略的细节connect和disconnect看起来简单但有几个生产环境必须知道的细节。第一connect处理函数可以返回False来拒绝连接。这在做登录校验时特别有用socketio.on(connect) def handle_connect(auth): token auth.get(token) if auth else None if not valid_token(token): return False # 拒绝连接 print(连接成功用户已验证)客户端在连接时可以传auth参数io({auth: {token: xxx}})。我第一次用的时候完全没注意到这个参数校验逻辑写在连接后的第一个事件里既不安全又绕。第二每一个连接都有一个唯一的request.sid。它不是传统意义上的 session ID而是 Socket.IO 内部维护的连接标识。你可以通过request.sid给指定连接发消息from flask import request from flask_socketio import emit, send socketio.on(private_msg) def handle_private_msg(data): target_sid data.get(sid) # 只发给指定 sid emit(server_event, {data: 私聊消息}, totarget_sid)第三disconnect事件触发的原因可能是客户端主动关闭、网络抖动、服务器超时也可能是服务端调用了socketio.disconnect(request.sid)主动踢人。日志里要做好区分不然排查问题的时候会一头雾水。我一般会在disconnect里记录request.sid和断开时间方便回溯。3.2 房间机制群聊和广播的底层逻辑房间Room是 Socket.IO 里一个很棒的设计。你可以把一批连接放进同一个房间然后对房间广播。Flask-SocketIO 的join_room和leave_room用起来非常简单from flask_socketio import join_room, leave_room socketio.on(join_room) def handle_join_room(data): room data.get(room) join_room(room) emit(server_event, {data: f你已加入房间 {room}}, toroom) # 告诉房间其他人 emit(server_event, {data: 新用户加入}, roomroom, include_selfFalse) socketio.on(leave_room) def handle_leave_room(data): room data.get(room) leave_room(room) emit(server_event, {data: f你已离开房间 {room}})这里有两个参数容易搞混to和room。在新版本里两个基本等价都表示往哪个房间发送。区别在于include_self默认是True也就是发送者也包含在接收列表里。如果你做的是新用户加入这类提示应该设置include_selfFalse否则自己会多收到一条新用户加入的提示。房间是 Socket.IO 服务端维护的内存数据结构不需要建表。但要注意房间与连接的关系是建立在单进程内存里的如果部署了多进程就必须加 Redis 消息队列来做跨进程同步。这个问题我放到第 4 章详细说。3.3 后台任务实现定时推送start_background_task 的用法实际的实时推送场景里服务端往往需要在特定时刻主动推数据。比如监控系统每 5 秒采集一次服务器 CPU 使用率并推送到前端大屏。这时候不能把占用的操作放在connect函数里因为connect是阻塞的你一旦在里面while True整个连接就卡死了。正确做法是使用socketio.start_background_task启动一个独立的后台协程或线程import time import random from flask_socketio import SocketIO from flask import Flask app Flask(__name__) socketio SocketIO(app) def push_cpu_usage(): with app.app_context(): while True: cpu random.randint(1, 100) socketio.emit(cpu_usage, {value: cpu}) time.sleep(5) socketio.on(connect) def handle_connect(): print(客户端连接) if __name__ __main__: socketio.start_background_task(push_cpu_usage) socketio.run(app, host0.0.0.0, port5000)这里有几个注意点后台任务里如果用到数据库操作或 Flask 的上下文必须用with app.app_context()包起来否则会报RuntimeError: Working outside of application context。socketio.emit(cpu_usage, {...})不带to参数时表示广播给所有连接。全局广播要慎用连接数一多就会造成广播风暴。这个后台线程是挂在主进程里的。如果用了多 worker每个 worker 都会启动一个后台任务结果就是前端收到的数据频率翻倍。生产环境建议单独起一个推送服务或者用消息队列做分发不要指望随便开个线程就完事。3.4 用户认证与连接映射真实项目中前端连上来时会带一个 token后台要能根据 token 确定这个用户是谁然后建立user_id - sid的映射。我在项目里常用的做法是from flask import request from flask_socketio import SocketIO socketio SocketIO() # 内存映射表生产环境建议用 Redis user_sid_map {} socketio.on(connect) def handle_connect(auth): token auth.get(token) if auth else None user_id parse_token(token) if not user_id: return False user_sid_map[user_id] request.sid print(f用户 {user_id} 上线sid {request.sid})这样当业务系统里产生一条新消息时可以直接通过user_sid_map找到用户的sid然后用socketio.emit(new_msg, data, tosid)推给他。这个映射表的维护要做好断开时的清理socketio.on(disconnect) def handle_disconnect(): for uid, sid in list(user_sid_map.items()): if sid request.sid: del user_sid_map[uid] break这套逻辑在单机场景下完全够用。如果用户多开设备同一个用户会有多个 siduser_sid_map应该改成user_id - set(sid)的结构这里不展开原理是一样的。4. Nginx 反向代理配置生产环境的三个致命坑本地开发跑得好好的一上服务器就各种连不上。这几乎是每个 Flask-SocketIO 新手都会遇到的问题。三个坑我全踩过一个一个说。4.1 握手 400 和 301 重定向WebSocket 建连时浏览器发的是带Upgrade: websocket头的 HTTP 请求。如果 Nginx 没有正确转发这些头握手就会失败。常见报错是WebSocket connection to ws://yourdomain/socket.io/ failed: Error during WebSocket handshake: Unexpected response code: 400。Nginx 配置里必须加上这几行proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade;如果Connection头没有正确设置为upgradeNginx 会把 WebSocket 请求当成普通 HTTP 请求转发到 FlaskFlask 收到后不认识这个请求返回 400。另外如果 Flask 服务监听在127.0.0.1:5000Nginx 的proxy_pass也要对应写对比如proxy_pass http://127.0.0.1:5000;。还有一个容易忽略的是proxy_set_header Host $host;。如果你代理转发了内网地址而后端程序里面用了request.host来做某些逻辑Host 头不对会导致服务端生成错误的 URL。4.2 连接 60 秒就断开默认情况下Nginx 的proxy_read_timeout是 60 秒。这意味着如果 60 秒内没有任何响应或数据从后端传来Nginx 就会主动断开连接。WebSocket 连接建立后如果业务上长时间没有消息推送前端就会收到disconnect。这是我线上环境踩过的最坑的一次导致用户在线状态一直不稳定。解决办法有两个方向。一是把超时时间调大proxy_read_timeout 3600s; proxy_send_timeout 3600s;二是实现 Socket.IO 的心跳机制。Flask-SocketIO 默认有ping_interval和ping_timeout前后端会自动 ping-pong保证连接不空闲。默认配置下Socket.IO 客户端会每 25 秒发一次 ping服务端在 20 秒内没收到就认为连接断开。所以理论上即使 Nginx 的 60 秒默认值也不会触发断连因为数据包一直在走。但如果你的 Nginx 只配了 WebSocket 代理而没调超时时间同时前端又手动关掉了 Socket.IO 的心跳那就很容易在 60 秒边界被断开。我在实际项目中两个都做了Nginx 超时设成3600s同时保留 Socket.IO 默认心跳。4.3 多 worker 进程导致连接错乱本地用socketio.run(app)默认是单进程没问题。但生产环境中不可能只跑一个进程一般会用 Gunicorn gevent 或 uWSGI 跑多个 worker。这时候问题来了客户端 A 连上了 worker 1。客户端 B 连上了 worker 2。当业务要给 A 推送消息时如果请求被负载均衡分发到了 worker 2worker 2 根本不知道 A 的存在消息就丢了。解决方法是引入消息队列。Flask-SocketIO 支持通过message_queue参数把跨进程消息统一转发socketio SocketIO(app, message_queueredis://127.0.0.1:6379/0, async_modegevent)当某个 worker 要发广播时消息会先发到 Redis 频道其他 worker 订阅这个频道并推给各自持有的连接。这样每个 worker 只管自己连接的这部分客户端不会出现消息黑洞。配置了message_queue之后所有emit、join_room、leave_room操作都会被消息队列接管房间逻辑也能跨进程生效。注意message_queue依赖 redis 服务生产环境要保证 Redis 可用。另外async_mode要和运行服务器的方式对应。用 Gunicorn 跑 gevent worker 时async_modegevent用 eventlet 则设成eventlet。混用的结果就是各种诡异的超时和断连。4.4 一套可用的 Nginx 完整配置upstream flask_socketio_backend { server 127.0.0.1:5000; keepalive 32; } server { listen 80; server_name yourdomain.com; # 反向代理 Socket.IO location /socket.io/ { proxy_pass http://flask_socketio_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } # 其他 HTTP 请求正常代理 location / { proxy_pass http://flask_socketio_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }如果把proxy_set_header Connection upgrade无条件加上会遇到一个问题普通 HTTP 请求的Connection头也变成了upgrade虽然不是致命错误但不规范。更好的写法是采用map方案不过对于中小项目直接写死问题不大我自己的生产环境就是这么配的。5. 部署后的常见报错与排查思路5.1 stream disconnected before completion 这类报错的根因热搜词里有一条stream disconnected before completion: websocket closed by server before res这个典型的报错会在请求发起后、WebSocket 握手完成前就断开连接。出现这个错误时我建议按下面的链路排查看 Nginx 错误日志和访问日志。如果/socket.io/返回 400说明代理的 Upgrade 头没设置对。看 Flask-SocketIO 日志。开启 debug 模式后每次连接和断开都会有记录判断是不是服务端主动关闭的。抓包确认。前端浏览器 F12 打开 Network 面板找到id为ws的连接看 WebSocket 帧数据。如果握手显示101 Switching Protocols说明连接正常建立如果直接finish或显示Unexpected response code再层层往上查。确认反向代理和目标服务没有中间层丢连接。有些云厂商的负载均衡SLB默认不支持 WebSocket需要在云控制台开启 WebSocket 支持。真正原因可能是 Nginx 的proxy_read_timeout太短、服务端ping_timeout太短、或者后端进程因为 OOM 被内核杀了导致连接全断。我遇到过一次是服务器内存不足Gunicorn worker 被系统杀掉所有 WebSocket 连接瞬间断开前端表现为频繁断开重连。查监控才发现是内存问题并不是代码问题。5.2 连接频繁断开、无限重连Socket.IO 客户端在断开后默认会指数退避重连这个设计挺好的但有一个副作用如果服务端因为某个事件一直拒绝连接客户端会不断重试造成流量放大。我在一个项目里遇到过这类问题后端connect事件里调用数据库查询用户信息数据库连接池满导致连接超时connect函数抛出异常。Flask-SocketIO 捕获异常后把这个连接标记为失败客户端收到失败后 1 秒重连重连又失败如此循环。排查后把数据库查询改成了异步并且加了缓存问题才解决。所以一个值得记住的经验是不要在connect里做重活比如复杂的 SQL 查询、IO 等待。连接事件应该只做最基本的校验和初始化其他逻辑放后台任务里慢慢做。原因很简单connect阻塞的时间越久重连风暴造成的系统负载越高。5.3 跨域问题Flask 项目如果页面和 API 在不同域名浏览器会拦截跨域 WebSocket 请求。在 Flask-SocketIO 里配置跨域有几种方式# 允许所有来源 socketio SocketIO(app, cors_allowed_origins*) # 只允许指定的来源 socketio SocketIO(app, cors_allowed_origins[https://example.com, https://admin.example.com])生产环境不建议用*。因为 WebSocket 连接一旦建立就可能被任意网站发起恶意连接如果你的服务端没有做 token 校验等于把实时推送通道暴露给了全世界。配合第 3.4 节的连接认证基本能挡住绝大多数恶意连接。5.4 如何验证 WebSocket 是否真的升级成功有时候看起来连上了但实际是降级到 HTTP 长轮询。检查方法很简单在浏览器 F12 的 Network 面板找到ws类型的请求如果存在说明走的是 WebSocket如果只有polling请求说明降级了。降级的可能原因是网络环境不允许 WebSocket也可能是服务端配置把 WebSocket 关掉了。也可以直接用命令行验证curl -i \ -H Connection: Upgrade \ -H Upgrade: websocket \ -H Sec-WebSocket-Version: 13 \ -H Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ \ --http1.1 \ http://yourdomain.com/socket.io/?EIO4transportpolling正常情况下会返回101 Switching Protocols。如果返回 400 或其他说明代理链路有问题。6. 和同类方案的横向对比帮你做技术选型作为一个用了多种方案做实时通信的人我经常被问到底选哪个。我直接用表格说明然后再解释为什么在某些场景下我仍然推荐 Flask-SocketIO。方案协议语言/框架学习成本扩展性典型场景Flask-SocketIOSocket.IOWebSocket 长轮询降级Python/Flask低中依赖 Redis 做跨进程中小型 Web 项目实时推送、简单聊天原生websockets库RFC 6455 WebSocketPython/异步高高高性能网关、自定义协议、流式数据传输Django ChannelsWebSocket HTTP2Python/Django中高已有 Django 项目的实时能力扩展Spring Boot WebSocketRFC 6455 WebSocket STOMPJava/Spring中高Java 系微服务、企业级系统Node.js Socket.IOSocket.IOJavaScript/Node.js低高大规模实时交互、在线游戏、协作白板从技术选型的角度你应该先问自己三个问题第一你的技术栈里 Python 占了多大比重如果整个后端都是 Flask 写的为了一个聊天功能再引入一个 Node.js 服务维护成本骤增。Flask-SocketIO 的优势就在于能无缝嵌进现有 Flask 项目用一套框架解决所有问题。第二你的并发量级是多少单机几千并发以内的话Flask-SocketIO 配合 gevent 模式完全能扛住。我测过在 4C8G 的云服务器上eventlet 模式下 2000 个并发连接CPU 占用还在可控范围。但如果要撑 10 万在线Flask 本身不太合适用 Node.js 或 Go 会更有优势。第三你的业务里除了聊天还有没有其他实时需求如果只是服务端定时推送状态到前端大屏Flask-SocketIO 的广播功能是最省事的如果是复杂的在线协作需要很细粒度的状态同步和高吞吐建议选专门的实时后端服务。7. 最后分享一些我在实际项目里的使用心得Flask-SocketIO 用了一年多从最初只做一个客服气泡提示到后来做了在线监控大屏、工单实时流转、用户行为告警整体体验是它不像原生 WebSocket 那样需要你处理底层协议细节也不需要像 Node.js 那样为了一个实时功能单独起服务。对于 Python 后端团队来说它的学习曲线最平缓出问题也最容易排查因为你所有的异常栈都还在 Flask 体系内。最后提一个小技巧如果连接数一多你会发现默认的 Werkzeug 开发服务器根本撑不住。生产环境我建议直接用 Gunicorn 跑命令是gunicorn -k gevent -w 1 app:app。注意-w 1的意思是只跑一个 worker因为多个 worker 需要配合 Redis 消息队列否则会出现前面说的连接错乱。等你把 Redis 配好、确认多 worker 没问题再调大-w数量否则你会踩到很多莫名其妙的雷。Flask-SocketIO 的文档其实不算特别完善社区讨论也比较分散但核心 API 很稳定。你只需要把connect、emit、join_room、start_background_task这几个概念吃透就能覆盖 90% 以上的实时业务需求。剩下的 10%靠日志和耐心慢慢排查总能解决。
返回列表