小Cの已经记不起来的博客

WebSocket 实时通信与 缓存一致性 应用

起因:订单状态改了,页面没跟上

我这边有个小后台,订单状态变了之后,希望用户页面能立刻从“待支付”变成“已支付”。最开始的做法很直接:数据库更新完,清一下 Redis,再通过 WebSocket 给所有相关客户端推一条 refresh。结果测了一轮,发现这玩意儿是真的难搞,有时候状态明明推了,页面还是旧的。看了几眼代码,主要有三个坑:

  1. 客户端只收到“刷新”,但还是去读本地缓存,本地缓存没失效;
  2. 服务端有多个实例,每个实例有进程内缓存,广播只打到网关,没打到业务实例;
  3. 消息会乱序。一个“已支付”事件到了,“未支付”的旧事件又慢半拍到了,页面又被覆盖回去了。

下面是后来实测比较稳的一种做法。不一定适合所有项目,但至少能先把“实时”和“缓存”这两件事拆开,别指望一个 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 实时通信和缓存一致性串起来。

这方案也不是银弹,高并发下还是要小心 incrsetexpublish 不是事务。如果有条件,更新完数据库后先写版本,再写详情缓存,最后 publish。实在要求严格,可以搞一个 outbox 表,定时扫出去发事件。但别一上来就全套中台,先把自己页面的旧数据问题修掉再说。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放