每次都查最新数据,为什么 ES 里还是旧订单
订单事件只发一个 ID,消费者拿到 ID 后,再从业务库查最新订单、组装宽表、写入 ES。这个设计看上去避开了旧消息的问题:消息再旧,回查的数据总该是新的。
但“读取时最新”和“写入时最新”之间,还隔着一次组装和一次网络请求。
把同一订单的两次更新排在一起,就能看到这个空当:
A:读到订单版本 11,状态 PAID
A:查询关联数据,尚未写 ES
B:读到订单版本 12,状态 REFUNDED
B:组装完成,写入 ES
A:组装完成,把版本 11 的快照写入 ES
如果两次都用普通的 Index API 覆盖同一个 _id,最后留下的是 A 的旧快照。A 没有读到错误的数据,只是慢了一步。
把版本检查放到写入端
对于这种整份文档的覆盖,源系统如果能提供与快照对应的递增版本,可以用 ES 的 external versioning。下面两次请求表示上面的先后顺序,localhost:9200 指一个本地 ES 实例:
PUT /orders/_doc/o-001?version=12&version_type=external
Host: localhost:9200
Content-Type: application/json
{"status":"REFUNDED","source_version":12}
然后让旧版本到达:
PUT /orders/_doc/o-001?version=11&version_type=external
Host: localhost:9200
Content-Type: application/json
{"status":"PAID","source_version":11}
第二次写入会被拒绝,返回 409 version_conflict_engine_exception。严格的 external 要求提交版本大于已有版本;相等也会冲突。因此版本 12 的重复投递不会再写一次。
这里起作用的是 URL 上的 version 和 version_type。只在 JSON 里存一个 source_version 字段,没有启用外部版本检查,ES 仍会正常覆盖文档。
消费端也不能看到 409 就把旧请求放回重试队列。对于这里的同一文档、同一外部版本协议,冲突说明这次写入没有比已存在的版本更新;继续重放版本 11 不会让它突然变成新数据。鉴权失败、mapping 错误和 bulk 中其他失败项则需要分别处理。
接口细节可以看 Index API 的 versioning 说明。这个例子用的是完整文档索引,不能把相同参数直接搬到所有更新接口上。
最难的部分其实是拿到那个 12
如果只是单表投影,读取一行记录和它的版本比较直接。订单宽表通常还包含退款、支付、商品等数据,问题就没这么干净了。
假如退款记录已经变了,订单主表的版本却没变,宽表内容变化后仍拿着同一个版本号。严格 external 会拒绝后到的相同版本,这次反而可能挡住了应该生效的退款字段。
所以“用订单表版本”需要先证明:所有影响这份宽表的变化都会推进这个版本,组装结果也确实与该版本对应。否则需要由聚合或投影过程维护一致的快照和版本。多次查完各服务后再取当前时间,没法补上这个保证;旧数据也可以领到一个更新的时间戳。
ES 自己的 _seq_no 和 _primary_term 能做另一种乐观并发控制,但它们描述的是 ES 内部的文档修改,不会替业务系统判断两个源快照谁更新。
中间表没有替 ES 做这次比较
按订单 ID 合并任务,确实能减少回查次数。可只要任务还可能被两个实例同时执行,就仍能排出开头那段时序。任务表已经标记完成,也不能撤回另一个执行者正在发送的旧请求。
没有统一源版本时,可以重新考虑投影方式,比如按订单键有序处理变更,或者让同一订单经过一个明确的写入者。但故障恢复、任务重领和读副本延迟仍需要处理,不能只写一句“同订单串行”就算结束。
对我来说,先把“谁能提交这份快照”讲清楚,比先决定中间表放 SQL Server 还是 Redis 更有用。不然轮询间隔调得再短,也只是在更频繁地争抢最后一次写入。