ES 大宽表在订单查询中的设计与优化
当订单量从每天几万涨到每天几百万的时候,最直观的感受不是接口慢了,而是查询变得越来越复杂。运营要按渠道查,财务要按时间范围查,客服要按用户 ID 反查订单,还有各种组合条件的筛选。这些查询如果都打到关系数据库上,索引设计会变得极其复杂,而且很多查询场景在建表时根本预想不到。
我们当时的方案是在现有存储之上加一层 ElasticSearch,用大宽表的方式把订单相关的所有信息组装到一起,专门服务查询场景。
为什么选大宽表
订单中心的数据天然就是分散的:订单基本信息在一张表,用户信息在另一张表,商品信息、支付信息、退款信息各自独立存储。这在交易场景下是合理的——写入路径需要高内聚、强一致。但查询场景的需求恰好相反:需要把所有相关信息拉到一起做筛选和排序。
大宽表的思路就是:把这些分散的信息在写入 ES 之前就组装好,一张索引包含订单相关的所有字段。查询时不需要 JOIN,不需要跨索引聚合,一个请求就能拿到所有需要的数据。
当然代价也很明显:数据冗余、写入时需要组装、字段变更时需要同步更新索引结构。但对于读多写少的查询场景,这个 trade-off 是值得的。
写入链路设计
大宽表的数据来自多个业务服务,不能也不应该侵入正常的订单写入流程。我们的做法是通过事件驱动解耦:
- 各业务服务在关键操作完成后发送 MQ 消息(订单创建、支付成功、退款完成等)
- 消费端订阅这些消息,异步组装完整数据
- 组装完成后批量写入 ES
这里有个关键决策:消息只携带事件类型和主键 ID,不携带完整数据。消费端收到消息后,主动去各服务查询最新数据再组装。这样做的好处是解耦——事件发送方不需要关心数据结构的变化,也不需要维护一个"给 ES 用"的特殊数据格式。
乱序问题
事件驱动架构下,乱序是必然会出现的问题。同一个订单可能在短时间内触发多个事件(创建、支付、发货),如果这些事件被不同消费者乱序处理,最终写入 ES 的数据可能不是最新的。
我们遇到过一个典型场景:订单先触发了"退款完成"事件,紧接着又触发了"支付成功"事件(对账回调)。如果"支付成功"的消费先完成,ES 里的数据会被覆盖为"已支付"状态,丢失了退款信息。
解决方案是在消息和 ES 写入之间加一层中间表:
- 消息到达后,先写入 SQLServer 的一张事件记录表,包含订单 ID、事件类型、事件时间
- 定时任务从中间表读取数据,按订单 ID 聚合,取每个订单的最新状态
- 最新状态组装完成后写入 ES
这个方案牺牲了一些实时性(定时任务的间隔决定了延迟),但彻底解决了乱序问题。在我们的场景下,查询 ES 的用户能接受几秒到十几秒的延迟,这个 trade-off 是合理的。
索引拆分
随着数据量增长,单个 ES 索引会遇到写入积压的问题。ES 的写入性能和索引大小、分片数量、单条文档大小都有关系,当索引文档数超过一定规模后,写入延迟会明显增加。
我们的做法是按时间拆分索引:每个月一个独立索引,查询时通过别名(alias)聚合多个索引。这样有几个好处:
- 热数据(最近几个月)的索引体积小,写入性能好
- 历史数据可以转移到性能较低的节点,降低存储成本
- 索引结构变更时,只需要在新索引上生效,不影响历史数据
拆分后,写入积压问题基本解决了。但查询侧需要注意:跨多个索引的聚合查询会变慢,需要在业务层做好分页和范围限制。
补偿机制
异步架构下,消息丢失是不可避免的——虽然概率很低,但在百万级日订单的规模下,每天丢几条消息是正常的。如果没有任何补偿机制,这些丢失的数据会导致 ES 和源数据库不一致。
我们的做法是双管齐下:
- 消息级别的补偿:每条消息消费成功后,更新一条状态记录。定时任务扫描超时未消费的消息,重新投递。
- 全量级别的补偿:每天凌晨跑一次全量比对任务,从源数据库查出最近一天的订单数据,和 ES 里的数据做 diff,找出不一致的记录并修复。
第一种处理实时问题,第二种兜底。上线后跑了大半年,大部分不一致都是通过第一种机制自动修复的,第二种偶尔能抓到一些边界情况。
后置数据使用
大宽表除了服务查询,还有一个意外的收获:它天然就是一个数据归集点。很多后置业务(统计报表、数据分析、风控模型)需要的订单相关数据,在大宽表里都能找到。
我们在大宽表之上又建了一层数据归集表,专门给业务侧做统计用。这样做的好处是业务方不需要自己去各服务拼数据,直接从归集表取就行,减少了重复建设和数据口径不一致的问题。
总结
ES 大宽表不是什么新技术,但在订单查询这种场景下确实好用。关键的设计决策在于:通过事件驱动解耦写入链路,通过中间表解决乱序问题,通过索引拆分解决写入积压,通过双层补偿保证数据一致性。每个决策都有对应的 trade-off,但在我们的场景下,这些 trade-off 是值得的。