欢迎光临
我们一直在努力

深入浅出 WebSocket:从原理到 FastAPI 生产级实践

第一章:故事开始 —— 为什么需要 WebSocket?

场景一:餐厅老板的烦恼

想象你是一家餐厅的老板。客人来了,点了一道菜,吃完就走了。但有一天,来了一群客人,他们想坐在那里聊天,时不时点个小菜、加杯饮料。

如果按传统方式:

  • 客人每次要点东西,都要站起来跑到柜台点单
  • 服务员每次都要重新确认:“你是谁?你要什么?”
  • 客人等得不耐烦,服务员也累得要死

这就是 HTTP 请求-响应模式 的问题:每次通信都要重新建立连接,效率太低。

场景二:实时聊天的需求

再想象你开发一个在线聊天室:

  • 用户 A 发消息给用户 B
  • 传统方式:B 的浏览器每隔 3 秒问服务器一次:“有新消息吗?”
  • 如果没有消息,就浪费了一次请求;如果有消息,最多要等 3 秒才能收到

这就是 轮询(Polling) 的问题:浪费资源、延迟高。

场景三:WebSocket 的解决方案

WebSocket 就像是:

  • 客人来了,服务员带他到一个包间坐下
  • 服务员一直站在门口等着
  • 客人想点菜,挥挥手就行,不用跑到柜台
  • 厨师做好菜,服务员直接送进去,不用客人催

一次建立连接,之后双方随时可以通信 —— 这就是 WebSocket 的核心思想。


第二章:餐厅类比 —— HTTP vs WebSocket

HTTP:每次吃饭都要重新进店

客人: "老板,我要一份炒饭!" → 进店、点单
老板: "好的,炒饭来了!" → 上菜、客人吃完、走人
客人: "老板,我还要一份炒面!" → 又要进店、点单
老板: "好的,炒面来了!" → 上菜、客人吃完、走人

每次请求-响应都要经历:建立连接 → 发送请求 → 等待响应 → 关闭连接。

缺点:

  • 每次都要重新"进店"(建立连接),浪费时间
  • 客人不能"随时点菜",必须主动发起请求
  • 老板不能主动"送菜",只能等客人点

WebSocket:包下一个包间,服务员随时待命

客人: "老板,我要包个包间!" → 握手建立连接
老板: "好的,二楼包间请!" → 连接建立成功
客人: "服务员,来瓶啤酒!" → 随时发消息
老板: "好的,马上送到!" → 随时响应
老板: "今天特价菜:小龙虾!" → 服务器主动推送
客人: "来一份!" → 客人响应

一次握手建立连接后,连接保持打开,双方随时可以互发消息。

优点:

  • 一次"进店",可以一直聊天
  • 客人可以随时点菜(客户端主动发送)
  • 老板可以主动推荐菜(服务器主动推送)
  • 包间开门关门的开销大大减少(减少连接建立/关闭开销)

对比表格

维度HTTPWebSocket
连接方式 每次请求新建连接 一次握手,持久连接
通信方向 客户端发起,服务器响应(单向) 全双工,双方随时互发
数据格式 完整 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)

生产级要点:

  • dict 按用户 ID 管理 —— 每个客人有独立包间,支持定向推送
  • finally 清理 —— 无论正常离开还是异常,都确保包间被释放
  • JSON 格式约定 —— 通过 type 字段区分消息类型
  • 错误处理 —— 推送失败时清理僵尸连接

  • 第六章:安全鉴权 —— 只接待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() # 验证通过,开门!
    # … 正常聊天逻辑

    关键细节:

  • 鉴权前置 —— 在 accept() 之前验证,没证的人根本进不来
  • WebSocketException —— 专门用来拒绝非法连接,返回特定错误码
  • 双重校验 —— 既要验证 Token,也要校验路径参数中的 user_id 是否匹配

  • 第七章:分布式广播 —— 多分店如何协同?

    前面第五章我们实现了单机版的聊天室,所有客人都在同一家分店。但随着业务发展,一家分店不够用了,需要开多家分店(部署多个 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 快递系统

    维度Redis Pub/SubRabbitMQ
    延迟 < 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 存储在线状态。

    📊 数据结构设计

    数据结构Key用途
    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


    第十二章:总结与最佳实践

    核心要点回顾

  • WebSocket = 包间 —— 一次握手建立连接,之后随时聊天(第三章)
  • HTTP = 进店点餐 —— 每次都要重新进店,效率低(第二章)
  • 鉴权前置 —— 没证的人进不了包间(第六章)
  • 分布式用 Redis —— 多分店靠对讲机协同(第七章、第八章)
  • 在线状态存储 —— 用 Redis 跨实例查询(第九章)
  • 心跳保活 —— 防止包间门被风吹关(第十章)
  • 最佳实践清单

    • ✅ 生产环境用 wss:// —— 包间门要上锁(加密)
    • ✅ 鉴权前置 —— 在开门之前查身份证
    • ✅ 心跳保活 —— 每隔 30 秒说一声"我还在"
    • ✅ 自动重连 —— 客人走丢了要自动找回来
    • ✅ 监控告警 —— 随时知道包间里有多少客人
    • ✅ 优雅关闭 —— 客人离开时要清理包间
    赞(0)
    未经允许不得转载:171主机测评 » 深入浅出 WebSocket:从原理到 FastAPI 生产级实践
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址