第一章:故事开始 —— 为什么需要 WebSocket?
场景一:餐厅老板的烦恼
想象你是一家餐厅的老板。客人来了,点了一道菜,吃完就走了。但有一天,来了一群客人,他们想坐在那里聊天,时不时点个小菜、加杯饮料。
如果按传统方式:
- 客人每次要点东西,都要站起来跑到柜台点单
- 服务员每次都要重新确认:“你是谁?你要什么?”
- 客人等得不耐烦,服务员也累得要死
这就是 HTTP 请求-响应模式 的问题:每次通信都要重新建立连接,效率太低。
场景二:实时聊天的需求
再想象你开发一个在线聊天室:
- 用户 A 发消息给用户 B
- 传统方式:B 的浏览器每隔 3 秒问服务器一次:“有新消息吗?”
- 如果没有消息,就浪费了一次请求;如果有消息,最多要等 3 秒才能收到
这就是 轮询(Polling) 的问题:浪费资源、延迟高。
场景三:WebSocket 的解决方案
WebSocket 就像是:
- 客人来了,服务员带他到一个包间坐下
- 服务员一直站在门口等着
- 客人想点菜,挥挥手就行,不用跑到柜台
- 厨师做好菜,服务员直接送进去,不用客人催
一次建立连接,之后双方随时可以通信 —— 这就是 WebSocket 的核心思想。
第二章:餐厅类比 —— HTTP vs WebSocket
HTTP:每次吃饭都要重新进店
客人: "老板,我要一份炒饭!" → 进店、点单
老板: "好的,炒饭来了!" → 上菜、客人吃完、走人
客人: "老板,我还要一份炒面!" → 又要进店、点单
老板: "好的,炒面来了!" → 上菜、客人吃完、走人
…
每次请求-响应都要经历:建立连接 → 发送请求 → 等待响应 → 关闭连接。
缺点:
- 每次都要重新"进店"(建立连接),浪费时间
- 客人不能"随时点菜",必须主动发起请求
- 老板不能主动"送菜",只能等客人点
WebSocket:包下一个包间,服务员随时待命
客人: "老板,我要包个包间!" → 握手建立连接
老板: "好的,二楼包间请!" → 连接建立成功
客人: "服务员,来瓶啤酒!" → 随时发消息
老板: "好的,马上送到!" → 随时响应
老板: "今天特价菜:小龙虾!" → 服务器主动推送
客人: "来一份!" → 客人响应
…
一次握手建立连接后,连接保持打开,双方随时可以互发消息。
优点:
- 一次"进店",可以一直聊天
- 客人可以随时点菜(客户端主动发送)
- 老板可以主动推荐菜(服务器主动推送)
- 包间开门关门的开销大大减少(减少连接建立/关闭开销)
对比表格
| 连接方式 | 每次请求新建连接 | 一次握手,持久连接 |
| 通信方向 | 客户端发起,服务器响应(单向) | 全双工,双方随时互发 |
| 数据格式 | 完整 HTTP 头部(几百字节) | 轻量帧格式(2-10 字节) |
| 适用场景 | 普通网页浏览、表单提交 | 实时聊天、行情推送、协同编辑 |
| 开销 | 每次请求都有头部开销 | 只有第一次握手有开销 |
第三章:握手过程 —— 如何包下一个包间?
📊 简化版时序图(餐厅故事版)
客人(客户端) 老板(服务器)
│ │
│ "老板,我想包个包间!" │
│─── HTTP 请求 ──────────────→│ (带 "我要升级协议" 的暗号)
│ │
│ "好的,二楼包间请!" │
│←── 101 同意 ───────────────│ (状态码 101 = 协议切换成功)
│ │
│◄====== 包间门一直开着 =======►│
│ │
│◄── 随便点菜聊天 ──────────►│
│ │
📊 完整时序图(技术细节版)
服务器 (后端)
网络 (TCP/IP)
客户端 (浏览器/App)
服务器 (后端)
网络 (TCP/IP)
客户端 (浏览器/App)
#mermaid-svg-701i0SkL6EVZQUPR{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-701i0SkL6EVZQUPR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-701i0SkL6EVZQUPR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-701i0SkL6EVZQUPR .error-icon{fill:#552222;}#mermaid-svg-701i0SkL6EVZQUPR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-701i0SkL6EVZQUPR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-701i0SkL6EVZQUPR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-701i0SkL6EVZQUPR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-701i0SkL6EVZQUPR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-701i0SkL6EVZQUPR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-701i0SkL6EVZQUPR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-701i0SkL6EVZQUPR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-701i0SkL6EVZQUPR .marker.cross{stroke:#333333;}#mermaid-svg-701i0SkL6EVZQUPR svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-701i0SkL6EVZQUPR p{margin:0;}#mermaid-svg-701i0SkL6EVZQUPR .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-701i0SkL6EVZQUPR text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-701i0SkL6EVZQUPR .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-701i0SkL6EVZQUPR .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-701i0SkL6EVZQUPR .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-701i0SkL6EVZQUPR .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-701i0SkL6EVZQUPR #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-701i0SkL6EVZQUPR .sequenceNumber{fill:white;}#mermaid-svg-701i0SkL6EVZQUPR #sequencenumber{fill:#333;}#mermaid-svg-701i0SkL6EVZQUPR #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-701i0SkL6EVZQUPR .messageText{fill:#333;stroke:none;}#mermaid-svg-701i0SkL6EVZQUPR .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-701i0SkL6EVZQUPR .labelText,#mermaid-svg-701i0SkL6EVZQUPR .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-701i0SkL6EVZQUPR .loopText,#mermaid-svg-701i0SkL6EVZQUPR .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-701i0SkL6EVZQUPR .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-701i0SkL6EVZQUPR .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-701i0SkL6EVZQUPR .noteText,#mermaid-svg-701i0SkL6EVZQUPR .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-701i0SkL6EVZQUPR .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-701i0SkL6EVZQUPR .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-701i0SkL6EVZQUPR .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-701i0SkL6EVZQUPR .actorPopupMenu{position:absolute;}#mermaid-svg-701i0SkL6EVZQUPR .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-701i0SkL6EVZQUPR .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-701i0SkL6EVZQUPR .actor-man circle,#mermaid-svg-701i0SkL6EVZQUPR line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-701i0SkL6EVZQUPR :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
1. TCP 三次握手 (建立管道)
2. HTTP 协议升级 (握手)
协议切换完成,HTTP 下线,WebSocket 接管
3. 全双工数据帧通信 (业务交互)
par
[客户端发送]
[服务器发送]
4. 心跳保活 (可选)
5. 关闭连接
TCP 四次挥手,连接释放
SYN (我要连接)
SYN
SYN+ACK (允许)
SYN+ACK
ACK
ACK (TCP连接建立成功)
HTTP GET 请求 (Upgrade: websocket, Key: xxx)
HTTP 101 响应 (Switching Protocols, Accept: yyy)
数据帧 (掩码处理, 如: "Hello")
数据帧 (无掩码, 如: "World")
Ping 帧
Pong 帧
Close 帧 (状态码 1000)
Close 帧 (确认关闭)
时序图解读
第一步:TCP 三次握手 —— 拉电话线
TCP 层先建立一条可靠的连接通道,就像在客户端和服务器之间拉一根电话线。三次握手确保双方都能正常收发数据。
第二步:HTTP 协议升级 —— 敲门打招呼
客户端用 HTTP 发送"升级请求",服务器同意后返回 101 状态码。这一步完成后,HTTP 协议就下线了,WebSocket 正式接管连接。
第三步:全双工数据帧通信 —— 愉快聊天
WebSocket 用轻量的帧格式收发数据。客户端发送的数据需要做掩码处理(防止缓存攻击),服务器发送的数据不需要。
第四步:心跳保活 —— 保持电话线畅通
客户端定期发送 Ping 帧,服务器回复 Pong 帧,告诉对方"我还在",防止长时间不说话导致连接被防火墙断开。关于心跳的详细实现,我们会在第十章"生产踩坑"中讨论。
第五步:关闭连接 —— 挂断电话
任何一方发送 Close 帧,对方确认后,TCP 进行四次挥手,连接正式释放。
HTTP 握手细节
上面的时序图展示了完整的通信流程,这里我们聚焦于第二步——HTTP 协议升级的具体内容。
请求头:客人敲门打招呼
GET /ws/chat HTTP/1.1
Host: example.com
Upgrade: websocket ← 暗号:我要升级到 WebSocket 协议
Connection: Upgrade ← 确认:我真的要升级
Sec-WebSocket-Key: xxx ← 验证码:证明我是合法客人
Sec-WebSocket-Version: 13 ← 版本号:我会这个版本的协议
响应头:老板开门放行
HTTP/1.1 101 Switching Protocols ← 101 = 协议切换成功
Upgrade: websocket ← 确认:已切换到 WebSocket
Connection: Upgrade ← 确认:连接已升级
Sec-WebSocket-Accept: yyy ← 验证码回执:证明我是真老板
服务器把客人给的验证码(Sec-WebSocket-Key)与固定魔法字符串拼接,做 SHA-1 哈希再 Base64 编码,生成 Sec-WebSocket-Accept。客人验证通过后,就知道这是真老板,不是冒牌货。
第四章:协议栈 —— 它们在网络中的位置
📊 网络协议栈图
┌─────────────────────────────────┐ 应用层(我们写代码的地方)
│ HTTP │ WebSocket │
│ (点餐) │ (包间聊天) │
├─────────────────────────────────┤
│ TCP │ 传输层(可靠连接的保障)
│ (电话线) │
├─────────────────────────────────┤
│ IP │ 网络层(数据传输的路径)
│ (快递路线) │
└─────────────────────────────────┘
三者关系
TCP = 电话线
TCP 是传输层协议,负责建立可靠的连接。就像你家和餐厅之间拉了一根电话线,保证通话不会中断、不会错乱。HTTP 和 WebSocket 都跑在 TCP 之上。
HTTP = 打电话点餐
HTTP 是应用层协议,采用请求-响应模式。就像你打电话到餐厅点餐,说完就挂电话,下次要点还要重新拨。
WebSocket = 包下包间聊天
WebSocket 通过 HTTP 完成"握手"(相当于确认包间),之后不再走 HTTP 格式,改用更轻量的帧格式直接在 TCP 上通信。就像你到了包间,和服务员面对面聊天,不用每次都打电话。
第五章:FastAPI 实战 —— 搭建你的第一家餐厅
5.1 最简单的餐厅:回声餐厅
客人说什么,服务员就重复什么:
from fastapi import FastAPI, WebSocket
app = FastAPI()
@app.websocket("/ws")
async def echo_restaurant(websocket: WebSocket):
await websocket.accept() # 开门让客人进来
while True:
order = await websocket.receive_text() # 听客人点菜
await websocket.send_text(f"好的,您点了:{order}") # 重复一遍
运行方式:
uvicorn app.main:app –reload
测试方式(浏览器控制台):
const ws = new WebSocket('ws://localhost:8000/ws');
ws.onmessage = (event) => console.log('收到:', event.data);
ws.send('一份炒饭'); // 会收到:"好的,您点了:一份炒饭"
5.2 进阶:多人聊天室
多个客人可以在同一个大厅聊天,一个人说话,所有人都能听到:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
# 大厅里的所有客人
active_connections: list[WebSocket] = []
@app.websocket("/ws/chat")
async def chat_hall(websocket: WebSocket):
await websocket.accept()
active_connections.append(websocket) # 客人入座
print(f"新客人入座,当前人数:{len(active_connections)}")
try:
while True:
message = await websocket.receive_text()
# 广播给所有客人
for conn in active_connections:
await conn.send_text(message)
except WebSocketDisconnect:
active_connections.remove(websocket) # 客人离开
print(f"客人离开,当前人数:{len(active_connections)}")
5.3 生产级:VIP 包间(按用户管理)
实际项目中,我们需要按用户 ID 管理连接,实现定向推送。以下来自"暖屿"项目的实际实现:
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
logger = logging.getLogger(__name__)
router = APIRouter()
# VIP包间管理:{user_id: websocket}
_vip_rooms: dict[str, WebSocket] = {}
async def send_to_vip(user_id: str, data: dict) –> bool:
"""给指定VIP客人推送消息"""
room = _vip_rooms.get(user_id)
if room is None:
return False # 客人不在包间
try:
await room.send_text(json.dumps(data, ensure_ascii=False))
return True
except Exception as e:
logger.warning(f"推送失败,客人可能已离开: {e}")
_vip_rooms.pop(user_id, None)
return False
@router.websocket("/ws/chat/{user_id}")
async def vip_room(websocket: WebSocket, user_id: str):
"""VIP包间入口"""
await websocket.accept()
_vip_rooms[user_id] = websocket
logger.info(f"VIP客人 {user_id} 进入包间")
try:
while True:
raw = await websocket.receive_text()
data = json.loads(raw)
if data.get("type") == "message":
receiver_id = data.get("receiver_id")
content = data.get("content")
# 给接收者推送
await send_to_vip(receiver_id, {
"type": "message",
"content": content,
"sender_id": user_id,
})
# 给发送者回执
await websocket.send_text(json.dumps({"status": "sent"}))
except WebSocketDisconnect:
logger.info(f"VIP客人 {user_id} 离开包间")
finally:
_vip_rooms.pop(user_id, None)
生产级要点:
第六章:安全鉴权 —— 只接待VIP客人
问题:包间不能随便进
HTTP 接口通过 Authorization: Bearer <token> 头部鉴权,但浏览器的 WebSocket API 不支持自定义请求头。这意味着我们需要换一种方式。
解决方案:门口查身份证
客人进包间前,必须在 URL 中带上 Token(身份证):
ws://example.com/ws/chat/123?token=abc123
📊 鉴权流程图
客人拿着身份证(Token)来敲门
│
▼
服务员查身份证
┌─────────────┐
│ 验证Token │
│ 是不是VIP? │
│ 有没有过期? │
└─────────────┘
│ │
通过 拒绝
│ │
▼ ▼
开门进包间 请回吧!
│ │
▼ ▼
正常聊天 连接被拒绝
代码实现
from fastapi import WebSocket, Query, WebSocketException, status, Depends
from jose import jwt, JWTError
JWT_SECRET_KEY = "your-secret-key"
async def check_vip_card(
websocket: WebSocket,
token: str = Query(...), # 从 URL 参数提取 Token
) –> str:
"""查身份证 —— 在开门之前验证"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
if user_id is None:
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
return str(user_id)
except JWTError:
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
@router.websocket("/ws/chat/{user_id}")
async def secure_vip_room(
websocket: WebSocket,
user_id: str,
current_user: str = Depends(check_vip_card), # 先查身份证
):
# 能执行到这里,说明已认证通过
if user_id != current_user:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await websocket.accept() # 验证通过,开门!
# … 正常聊天逻辑
关键细节:
第七章:分布式广播 —— 多分店如何协同?
前面第五章我们实现了单机版的聊天室,所有客人都在同一家分店。但随着业务发展,一家分店不够用了,需要开多家分店(部署多个 FastAPI 实例)。
问题:客人可能在不同分店
如果你的餐厅开了多家分店(多个 FastAPI 实例),问题来了:
客人A ──→ 分店1(知道A在这)
客人B ──→ 分店2(知道B在这)
客人A 给客人B 发消息 → 分店1 找不到B → 消息送不到!
解决方案:对讲机(Redis Pub/Sub)
每个分店配一台对讲机,所有分店订阅同一个频道。任何分店收到消息,都通过对讲机广播,其他分店收到后转发给各自的客人。
📊 分布式架构图
客人A ──→ 分店1 ──→ 对讲机(Redis) 发布消息
│
▼ 广播给所有分店
客人B ←── 分店2 ←──┘
客人C ←── 分店1 ←──┘
代码实现
import asyncio
import json
import redis.asyncio as aioredis
from fastapi import FastAPI, WebSocket
app = FastAPI()
# 对讲机频道名
CHANNEL = "restaurant_broadcast"
# 本地客人(每个分店独立管理)
_local_guests: dict[str, WebSocket] = {}
# 对讲机(发布用)
_publisher: aioredis.Redis | None = None
@app.on_event("startup")
async def startup():
global _publisher
_publisher = aioredis.from_url("redis://localhost:6379")
# 启动对讲机监听(后台任务)
asyncio.create_task(listen_to_radio())
async def listen_to_radio():
"""监听对讲机,收到消息后转发给本地客人"""
subscriber = aioredis.from_url("redis://localhost:6379")
pubsub = subscriber.pubsub()
await pubsub.subscribe(CHANNEL)
async for message in pubsub.listen():
if message["type"] != "message":
continue
data = json.loads(message["data"])
target_user = data.get("target_user")
# 转发给本地客人
if target_user in _local_guests:
await _local_guests[target_user].send_text(message["data"])
async def broadcast_to_guest(user_id: str, data: dict):
"""给客人发消息(跨分店)"""
# 先试试本地分店
if user_id in _local_guests:
await _local_guests[user_id].send_text(json.dumps(data))
return
# 本地没有,通过对讲机广播
if _publisher:
payload = json.dumps({"target_user": user_id, **data})
await _publisher.publish(CHANNEL, payload)
架构要点:
第八章:技术选型 —— 为什么用 Redis 不用 RabbitMQ?
这是一个很经典的技术选型问题。选择 Redis Pub/Sub 主要有以下几个原因:
延迟差异:对讲机 vs 快递系统
| 延迟 | < 1ms(内存级推送) | 10-50ms(路由转发) |
| 路径 | 发布 → 内存广播 → 订阅者 | 发布 → 交换机 → 队列 → 消费者 |
| 中间环节 | 无 | 路由、队列存储、ACK 确认 |
WebSocket 广播对延迟非常敏感,用户发消息希望对方"秒收"。Redis Pub/Sub 是纯内存操作,发布后立即广播给所有订阅者。而 RabbitMQ 需要经过交换机路由、队列存储、消费者拉取等步骤,延迟高出一个数量级。
模型差异:广播 vs 队列
Redis Pub/Sub 是 fire-and-forget(发完就忘) 的广播模型:
- 消息发布后立即广播给所有订阅者
- 没在线的订阅者收不到(消息不持久化)
- 天然适合 WebSocket 实时广播场景
RabbitMQ 是 队列模型:
- 消息先存入队列,再由消费者拉取
- 支持消息持久化、ACK 确认、死信队列
- 更适合点对点可靠投递(如任务队列)
对于 WebSocket 广播来说,离线消息根本不需要通过消息队列处理——用户上线后会从数据库拉取未读消息(正如项目中 _handle_send_message 的逻辑:推送失败就等待上线后查看)。
是否过度设计?
WebSocket 广播的需求:
- ✅ 低延迟实时推送
- ✅ 支持水平扩展
- ❌ 不需要消息持久化(离线消息走 DB)
- ❌ 不需要 ACK 确认(客户端重连后从 DB 补)
- ❌ 不需要死信队列
用 RabbitMQ 就像"杀鸡用牛刀"——它的优势(持久化、可靠性)在这个场景下完全用不上,反而增加了复杂度和延迟。
什么时候该换?
当业务发展到一定规模,需要消息持久化、消息回溯或消费者组时,应该考虑 Kafka 或 Redis Streams(不是 RabbitMQ)。关于架构演进的详细讨论,我们会在第十二章中展开。
工具选型总结
| Redis Pub/Sub | 对讲机 | 实时广播,低延迟 |
| RabbitMQ | 快递系统 | 可靠投递,任务队列 |
| Kafka | 邮政系统 | 大规模数据流,日志采集 |
第九章:在线状态 —— 用 Redis 存储在线状态
当需要跨实例查询用户在线状态时(如显示好友在线列表),可以用 Redis 存储在线状态。
📊 数据结构设计
| Hash | online:{user_id} | 存储用户在线详情(实例 ID、连接 ID、最后心跳时间) |
| Set | online_user_ids | 存储所有在线用户 ID 的集合 |
核心逻辑
用户上线:
- 将用户详情写入 Hash,并设置 60 秒过期时间
- 将用户 ID 添加到在线用户集合
用户下线:
- 删除 Hash 中的用户详情
- 从在线用户集合中移除用户 ID
心跳保活:
- 每隔 30 秒更新 Hash 中的心跳时间,并重置过期时间
- 如果用户断开连接且没有心跳,Key 会自动过期,实现自动离线
查询在线状态:
- 查询 Hash 是否存在 → 判断用户是否在线
- 查询在线用户集合 → 获取所有在线用户列表
架构优势
| 跨实例共享 | 所有实例共享同一个 Redis,在线状态查询准确 |
| 自动离线 | Key 过期自动标记为离线,无需手动清理僵尸状态 |
| 实例容错 | 某实例宕机后,该实例的用户状态会在 60 秒后自动过期 |
| 水平扩展 | 支持无限水平扩展,加实例不影响在线状态查询 |
使用场景
- 需要显示"好友在线列表"
- 需要跨实例查询用户在线状态
- 需要实例宕机后自动清理状态
第十章:生产踩坑 —— 餐厅经营中的那些坑
坑 1:门口保安(Nginx)不让进
现象: 客人明明拿着正确的身份证,却被挡在门外。
原因: Nginx 默认不转发 WebSocket 需要的特殊请求头。
解决: 告诉保安放行 WebSocket:
location /ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; ← 必须转发升级请求
proxy_set_header Connection "upgrade"; ← 必须转发连接升级
proxy_read_timeout 86400s; ← 包间门不能随便关
}
坑 2:包间门被风吹关了(心跳超时)
现象: 客人正在聊天,突然包间门被关上了,必须重新进来。
原因: 云服务商的防火墙有空闲超时机制(通常 60-90 秒),长时间不说话就自动关门。
解决: 每隔 30 秒喊一声"我还在"(心跳):
import asyncio
@app.websocket("/ws/chat/{user_id}")
async def heartbeating_room(websocket: WebSocket, user_id: str):
await websocket.accept()
async def heartbeat():
while True:
await asyncio.sleep(30)
await websocket.send_text(json.dumps({"type": "ping"})) # 我还在!
heartbeat_task = asyncio.create_task(heartbeat())
try:
while True:
message = await websocket.receive_text()
data = json.loads(message)
if data.get("type") == "pong":
continue # 客人回应了,继续
# … 处理业务消息
finally:
heartbeat_task.cancel()
坑 3:网络不好,客人走丢了(断线重连)
现象: 网络突然断了,客人被踢出包间,消息也丢了。
解决: 客户端实现自动重连,像手机信号不好时自动搜索网络一样:
class RestaurantClient {
constructor(url) {
this.url = url;
this.reconnectCount = 0;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log("进入包间成功!");
this.reconnectCount = 0;
};
this.ws.onclose = () => {
console.log("包间门被关了,正在重新连接…");
this.scheduleReconnect();
};
}
scheduleReconnect() {
// 指数退避:1秒、2秒、4秒、8秒…最多等30秒
const delay = Math.min(1000 * 2 ** this.reconnectCount, 30000);
this.reconnectCount++;
setTimeout(() => this.connect(), delay);
}
}
坑 4:客人太多,包间不够(连接数限制)
现象: 超过一定人数后,新客人进不来。
原因: 操作系统默认限制每个进程最多打开 1024 个文件(每个连接占用一个文件描述符)。
解决: 扩大接待能力:
# 临时扩大限制
ulimit -n 65535
# Uvicorn 多开几个服务员(workers)
uvicorn app.main:app –workers 4 –limit-max-requests 1000
第十一章:性能调优 —— 让餐厅接待更多客人
压测数据参考
| 服务器配置 | 4 核 CPU / 8 GB 内存 |
| 服务员数量 | 4 个(workers) |
| 同时接待客人 | 5000 人 |
| 消息延迟 | < 50ms |
| CPU 使用率 | ~60% |
调优建议
服务员层面(Uvicorn):
uvicorn app.main:app \\
–workers 4 \\ # 服务员数量 = CPU 核心数
–loop uvloop \\ # 更快的事件循环
–limit-concurrency 10000 \\ # 最多接待 10000 人
–backlog 2048 # 门外排队人数
操作系统层面:
# 增加排队队列长度
sysctl -w net.core.somaxconn=65535
# 加快包间回收(TIME_WAIT)
sysctl -w net.ipv4.tcp_tw_reuse=1
第十二章:总结与最佳实践
核心要点回顾
最佳实践清单
- ✅ 生产环境用 wss:// —— 包间门要上锁(加密)
- ✅ 鉴权前置 —— 在开门之前查身份证
- ✅ 心跳保活 —— 每隔 30 秒说一声"我还在"
- ✅ 自动重连 —— 客人走丢了要自动找回来
- ✅ 监控告警 —— 随时知道包间里有多少客人
- ✅ 优雅关闭 —— 客人离开时要清理包间




