WebSocket 实时通信与 缓存一致性 应用
起因:订单状态改了,页面没跟上
我这边有个小后台,订单状态变了之后,希望用户页面能立刻从“待支付”变成“已支付”。最开始的做法很直接:数据库更新完,清一下 Redis,再通过 WebSocket 给所有相关客户端推一条 refresh。结果测了一轮,发现这玩意儿是真的难搞,有时候状态明明推了,页面还是旧的。看了几眼代码,主要有三个坑:
- 客户端只收到“刷新”,但还是去读本地缓存,本地缓存没失效;
- 服务端有多个实例,每个实例有进程内缓存,广播只打到网关,没打到业务实例;
- 消息会乱序。一个“已支付”事件到了,“未支付”的旧事件又慢半拍到了,页面又被覆盖回去了。
下面是后来实测比较稳的一种做法。不一定适合所有项目,但至少能先把“实时”和“缓存”这两件事拆开,别指望一个 refresh 消息打天下。
先给缓存一个版本号
后来我把思路从“通知刷新”改成了“通知版本”。
比如订单缓存键:
order:detail:{orderId}
再加一个版本键:
order:cache:v:{orderId}
每次更新订单状态的时候,不只要写数据库,还要把版本号 incr 一下。推送到 WebSocket 里的消息,也要带上这个版本号和动作。
大致像这样:
const version = await redis.incr(`order:cache:v:${orderId}`);
await redis.setex(
`order:detail:${orderId}`,
60,
JSON.stringify({
id: orderId,
status: "paid",
version,
updatedAt: Date.now(),
})
);
await pub.publish(
"order.events",
JSON.stringify({
type: "order",
id: orderId,
version,
action: "patch",
payload: { status: "paid" },
})
);
客户端收到以后,先比较版本号。如果本地已经有更高版本,这条消息直接丢掉。这样旧消息慢到了,也不会把新状态覆盖掉。
客户端不能只认 refresh
我一开始在客户端收到消息就做:
fetch("/api/orders/" + e.id)
这看着很合理,实际上很容易踩到 HTTP 缓存、浏览器缓存、框架层缓存,还有自己写的 store 缓存。后来我就把逻辑改得稍微啰嗦一点:
const cache = new Map();
function applyEvent(e) {
const local = cache.get(e.id);
if (local && e.version <= local.version) {
return;
}
if (e.action === "invalidate") {
cache.delete(e.id);
return;
}
if (e.action === "patch") {
cache.set(e.id, {
...(local || {}),
...e.payload,
version: e.version,
});
return;
}
if (e.action === "replace") {
cache.set(e.id, { ...e.payload, version: e.version });
}
}
这样处理有几个好处。列表页如果只是需要局部字段更新,patch 就够了,不用每次都打接口。如果事件里字段不全,再按版本号去拉一次完整数据。关键点是:页面不要无条件信任 WebSocket 消息,也不要无条件信任缓存。
多个服务实例怎么办
如果你的 WebSocket 网关和业务服务不是同一个进程,光用内存里的连接池广播不够。我这边用了 Redis pub/sub。业务服务只管发事件,网关订阅事件,再转给对应客户端。
// 业务服务
await redis.publish("order.events", JSON.stringify(event));
// WebSocket 网关
redis.subscribe("order.events");
redis.on("message", (channel, message) => {
const event = JSON.parse(message);
for (const client of clientsByEntity.get(event.id) || []) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
这里有个细节:网关里最好维护一个 clientsByEntity,也就是每个订单 id 对应哪些连接。不要每次收到订单事件就广播给所有在线用户,不然用户一多,网络风暴就来了。小项目无所谓,用户稍微多一点就会很难看。
重连以后不能装作没发生过
WebSocket 断线是很正常的事。我最早只做了自动重连,重连上以后重新拉一次列表。结果发现列表是新的,但详情页、弹窗、局部组件还是旧的。后来就加了一个比较土但有效的策略:每个连接记录一个 lastSeq。
服务端发消息时带一个自增序号:
{
seq: 1087,
type: "order",
id: "order_123",
version: 12,
payload: { status: "paid" }
}
客户端重连以后,带着这个 seq 问服务端:
GET /api/events?channel=order&after=1087
服务端如果本地缓存或者事件流里还能补发,就补发。补不了,就返回一个提示,让前端直接全量刷新一次。别把重连当成没事发生,很多缓存不一致都是重连这段时间攒出来的。
如果要更稳一点,可以用 Redis Stream,比 pub/sub 多一个回放能力:
await redis.xadd("order.events", "*", ...);
然后客户端用:
await redis.xread("BLOCK", 1000, "STREAMS", "order.events", lastSeq);
不过我这边项目规模不大,先 pub/sub 加版本号顶着,后面真有回放需求再换也没那么麻烦。
接口缓存也要带上版本
还有一个坑挺阴险的:WebSocket 通知是新的,但 HTTP 接口还在返回旧缓存。尤其如果你们用了 CDN、浏览器缓存、服务端内存缓存,那版本一致性就更重要。
我的做法很简单,接口返回头里带一个版本号:
X-Cache-Version: 12
前端拿到这个版本后,如果之后收到一个 version > 12 的事件,就把本地这份接口缓存标记为 stale。下次打开详情时,不用傻乎乎等 30 秒过期,直接重新请求。
如果不想动太多 HTTP 层,也可以在请求详情时带版本:
GET /api/orders/order_123?expectedVersion=11
服务端发现 expectedVersion 小于当前版本,就直接返回最新数据,而不是走本地旧缓存。这个稍微费点劲,但比后面天天查“为什么只有部分人看到旧状态”要省心得多。
最后别过度设计
我一开始还想得很复杂,什么事件溯源、双向同步、乐观锁、冲突合并都往里面塞。搞了两天发现,大部分业务只需要三件事:版本号、动作类型、重连补偿。
一个比较能落地的消息结构大概就是这样:
{
"seq": 1087,
"type": "order",
"id": "order_123",
"version": 12,
"action": "patch",
"payload": {
"status": "paid"
},
"ts": 1781234567890
}
服务端更新数据库 -> 更新缓存版本 -> 发事件。客户端订阅实体 -> 比较版本 -> 局部更新或拉全量。多实例用 Redis 转发事件。重连带 lastSeq 补偿。基本能把 WebSocket 实时通信和缓存一致性串起来。
这方案也不是银弹,高并发下还是要小心 incr、setex、publish 不是事务。如果有条件,更新完数据库后先写版本,再写详情缓存,最后 publish。实在要求严格,可以搞一个 outbox 表,定时扫出去发事件。但别一上来就全套中台,先把自己页面的旧数据问题修掉再说。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。