货拉拉待办中心重构的标题里有两个关键词:低延迟和一致性。最近我在做同类订单履约平台的任务系统时,正好把这两个目标放在一起推演了一遍,发现它们不是“先快后稳”的两阶段,而是必须同时设计的两个约束。待办中心不像支付、库存那么容易被关注,但它一旦变慢或者出现脏数据,司机端、客服端、用户端都会同时感知到,属于典型的“平时不起眼,故障时炸一片”的系统。
这篇重点拆解的是:2秒的延迟到底从哪里来,切换成事件驱动后一致性为什么反而更难做,以及100ms这个目标要如何落到消费、缓存、推送和对账链路上。适合正在做业务中台、待办任务、消息驱动类系统的后端同学参考,尤其是那些准备把同步接口改造成异步事件流的团队,应该能少踩几个坑。
1. 重新拆一拆:2秒的延迟到底花在哪里
1.1 待办中心不是一张“任务表”,它是履约状态的集散地
任何订单履约类系统里,待办中心都承担着同一个职责:把散落在各个业务域的状态变化,转成具体执行者能看到的下一步动作。司机端要看“我现在该去哪个位置取货”,客服工作台要看“哪些订单需要人工介入”,用户端则要看到“订单异常之后我下一步能做什么”。这些都不是主流程里的核心查询,但它们的集合决定了整个平台的协作效率。
所以待办中心本质上不是一张任务表,而是一个履约状态的集散地。货拉拉那篇分享把主题定成“低延迟与一致性保障”,其实是在说:当所有端都依赖待办数据来驱动下一步操作时,等待模型和数据可靠性必须一起升级,而不是把查询SQL调快就完事。
旧架构里最常见的实现,是把所有待办固化成MySQL表,上游业务通过接口直接插入一行记录。这样做最简单,但弊端也很直接:调用链路长,依赖多,待办服务一旦出现慢SQL,上游下单逻辑都会被拖垮。如果为了跨服务强一致去引入分布式事务,成本极高且不必要。优化方向应该是改变请求模型,而不是在原有模型上修修补补。
1.2 轮询等待是2秒最主要的放大镜
如果客户端每2秒轮询一次待办列表,服务端处理得再快,用户感知到的延迟也在0到2秒之间。假设延迟分布比较均匀,平均值就会接近1秒,这正好能解释“2秒”这个体验数据里很大一部分来源。
第二个被放大的环节是读请求造成的数据库压力。2秒一次轮询意味着,当在线司机端数量到10万量级时,每秒会产生数万次列表查询,而这些请求大部分是空轮询,或者返回的集合几乎没有变化。待办中心的低延迟优化,首先要处理的就是这种空轮询造成的瞬时DB QPS,而不是把单条SQL从50ms优化到5ms。真正有价值的优化是:让没有新事件的查询不要再落到数据库。
第三点,旧的创建逻辑大概率是同步写入。上游订单状态变成已支付之后,HTTP调用待办服务,一旦待办服务线程池阻塞,超时后客户端重试,就有可能在待办表里插入两条一模一样的数据。从这个角度看,“2秒”只是表象,表象背后是等待模型不合理、幂等边界缺失。
1.3 完整延迟链路要切到这几个环节看
从领域事件发生到司机端真正看到待办,需要经过下面几个环节:业务侧本地事务提交、事件产生、消息平台传输、待办服务消费、持久化与缓存更新、端侧通知或轮询触发。
举个例子,一次支付成功后,业务库的事务提交时间可能只有5ms;但如果待办中心是靠另一个定时任务去扫订单表来感知支付状态的,那从支付成功到待办产生,最坏就要等一个扫表周期,延迟会立刻涨到数百甚至上千毫秒。这里有一个很容易被低估的结论:大多数情况下,优化数据库里的单条查询不如去掉“轮询订单表”这个动作。
我把这类延迟做成了一张拆分表,实际项目里可以按这个思路继续细化:
| 环节 | 典型延迟 | 优化方式 |
|---|---|---|
| 业务提交后到事件发出 | 0~50ms | 同一事务写事件表或事务消息 |
| 消息平台传输 | 5~20ms | 分区合理、消费端不阻塞 |
| 待办服务入库 | 10~80ms | 批量写、幂等表 |
| 缓存更新与通知 | 5~30ms | Redis更新加推送信号 |
| 客户端感知 | 0~2秒 | 长连接推送加兜底轮询 |
如果要求每一个环节都压到10ms以内,那是过度设计。真正要做的是把最大的等待块去掉,而不是花两周时间把MySQL调优到2ms。
1.4 100ms这个目标应该怎么理解
100ms不能理解成“让客户端每100ms拉一次接口”。如果客服台有5万个用户,每秒每人拉10次,就是50万QPS,待办服务肯定扛不住。
更合理的理解是:端侧通过长连接收到待办变更信号后,再触发一次增量数据拉取,整个从事件发生到端上可展示的时间控制在100ms以内。这条链路上,主要耗时天然分布在消息传输、入库、缓存更新和通知推送中,所以需要在设计阶段就给每一段预留预算,同时保留1秒级的兜底轮询。有了这层理解,做技术选型时才不会为了“快”而无脑加大轮询频率,最后把数据库打挂。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 驱动模型从“接口调用”切到“事件订阅”,这一步不能省
2.1 为什么“同步创建待办”是反模式
订单业务在本地事务里提交业务结果后,再去同步调用待办服务创建任务,会让待办服务的结果反过来影响主流程。为了不让主流程失败,很多团队会给创建待办加异步补偿,结果代码里到处是重试和补齐逻辑,时间久了几乎没人敢动这块代码。
真正合适的模型是:上游只描述“发生了什么”,比如订单已支付、司机已取货、订单已送达,由待办中心这个订阅方决定“要不要生成待办、生成什么状态的待办”。把业务动作建模成事件之后,上游不需要知道待办中心的表结构,也不需要保证待办创建成功,它只需要保证事件本身不丢。这样待办中心就成了一个真正自治的服务,它的内部一致性由自己负责。
这一步是整个方案的地基。如果还在用同步接口做核心驱动,后面讨论缓存、批量和100ms都会走偏,因为串行调用天然会让延迟取决于最慢的那个依赖。
2.2 事件落库方式:本地消息表和事务消息都可用
只用MQ发送消息,会遇到一个经典问题:事务提交前发消息,消息发出但事务回滚;事务提交后发消息,进程刚好崩溃,事件就丢了。比较通用的解法有两种。
本地消息表,是在业务数据库里建一张事件表,把业务主表和事件表放在同一个本地事务里写入。后台再有一个转发组件扫描事件表,投递到消息队列,收到确认后把事件状态改成已投递。这个方案理解成本低,可排查性好,缺点是需要额外维护扫描任务,并且要保证扫描任务本身多副本执行时不会重复投递。重复投递不是致命问题,因为消费端可以幂等,但如果处理不当,消息量会被成倍放大。
分布式事务消息,是让消息队列先发送半消息,再执行业务本地事务,最后根据事务状态决定提交或回滚消息。这一步看起来比本地消息表更优雅,但依赖消息队列自身能力,线上排查时很多问题会集中在MQ一侧。如果团队对消息队列的运维能力不是特别强,我建议先做本地消息表,业务稳定后再考虑切换。
两种方案的共同原则是:事件消息里的eventId、bizId、bizType都要保留,消费端必须按这些字段做幂等,因为消息平台只保证“至少一次”,不保证“精确一次”。
2.3 需要订阅的是“业务动作”,而不是数据库Binlog
有些团队会为了省事,直接监听订单表的Binlog来生成待办,把订单行的变更数据当成事件源。这个做法在简单场景下能跑通,但订单表数据变化不等于领域动作发生。比如运营后台订正订单数据时,也会产生行变更,就可能触发一条根本不该生成的待办取消事件。
更好的做法是由订单域在业务方法中,主动发出OrderPaidEvent、OrderConfirmedEvent、DeliveryCompletedEvent这类具备业务语义的领域事件。事件里只放orderId、bizId、eventTime、eventId,以及待办流转需要的关键状态,不要把整行数据库记录都扔到消息里。这样订单表结构后续修改时,可以不影响待办中心的消费逻辑。
Binlog仍然有价值,它适合做缓存最终一致、搜索索引同步这类场景,但不适合直接驱动一个带状态机的待办系统。
2.4 事件驱动之后,一致性压力会前移
改成事件驱动之后,同步调用变成异步消息,原来在调用现场能立即发现的问题,现在都变成了消息的可靠性问题。消息丢失、重复、乱序都会开始出现。
待办服务必须把“消费”和“业务处理”当成两件事,消费链路里先做幂等判断,看bizId对应的待办是否已经存在;再做状态机流转,判断当前状态允不允许这个事件到达;最后更新缓存和推送。
如果事件乱序却没有保护,会出现非常典型的脏数据:用户刚支付成功,产生了“待取件”待办,紧接着用户取消订单,系统要关掉这个待办。可如果取消消息先到,“已取消”状态已经写入,而创建待办的消息后到,待办服务就可能把待办又拉回“待处理”。这是低延迟重构中最容易踩中的数据问题,后面必须单独靠状态机和事件时间来解决。
3. 100ms目标下真正管用的手段:消费批量化、缓存与推送协同
3.1 先给耗时做预算,再逐段检查
100ms是一个端到端目标,不是服务端接口目标,所以要先给链路切分耗时预算。可以参考这个分配:事件发出到消息队列传输不超过15ms,消费处理不超过35ms,缓存更新与通知推送不超过30ms,客户端展示不超过20ms,整体压到100ms以内。
消费处理这里说的不是单条消息的事务时间,而是从poll到一批消息之后,批量计算并落库的时间。如果每条消息都单独做一次DB查询来判断幂等,再单独执行一次insert,再更新一次Redis,单条处理的累计延迟很容易超过80ms。
所以,100ms成立的隐藏前提是消息批量处理。消息队列每次poll通常能拿到几十到几百条消息,但也不能盲目追求“拿得越多越好”。批量太大会造成单次GC压力,建议同时按条数和字节数限制,比如一次最多处理200条或者不超过1MB。这个阈值需要通过压测来找最优点,而不是照抄别人的配置。
3.2 读路径:缓存集合,而不是缓存整个JSON
同一个司机可能同时有多个待办,比如待取件、待支付、处理中、已取消。旧系统每次拉列表时,都按司机ID查待办表,ORDER BY create_time LIMIT 20。数据量一旦增大,即使有索引,也会因为需要返回多个字段而频繁回表。
我会推荐用缓存集合的方式组织读路径:
- 在Redis中保存司机维度的ID集合,比如driver:{id}:todo保存待处理待办的ID,driver:{id}:done保存已处理待办的ID;
- 待办详情仍然存MySQL,或按待办ID为维度放到缓存里;
- 客户端请求列表时,先从Redis集合中取出ID列表,再批量查详情;如果集合不存在,再回源MySQL并把ID集合写回。
创建或取消待办时,只需要对集合执行SADD、SREM操作,而不是更新一个很大的JSON对象,并发冲突少很多。这一层缓存的一致性由消费端负责,不能只更新Redis不写MySQL,否则缓存重启后列表会短暂变空,对账也会发现大量异常。
3.3 写路径:批量幂等比单条重试性价比高
在消费端写路径上,建议单独维护一张幂等表,里面保存bizType加bizId的唯一键。每次事件到达时,先尝试插入幂等记录。插入成功,说明这个事件第一次处理,继续执行待办更新;插入冲突,说明事件已经处理过,直接丢弃重复动作,但要记录日志备查。
批量处理时,还可以在内存里先对同一bizId的消息做折叠。一个订单从“待取件创建”到“取件完成”,有可能出现在同一次poll里,那么只需要应用最终状态,不需要先把状态改成待处理,再立刻改成已完成。这样的预处理能明显减少数据库写放大。
这里有一个容易踩的坑:幂等表不能只记录“是否处理过”,还要记录事件的eventTime。如果只是看唯一键冲突就丢弃,一个迟到的旧事件Replay,可能会把已经流转到新状态的数据截断,这个放到状态机部分再仔细说。
3.4 PUSH负责唤醒,拉取负责拿数据
最终支撑100ms体验的,一定不是缩短轮询周期,而是推拉结合。服务端在待办落库和缓存更新完毕后,向客户端的连接网关发送一个轻量信号,里面可以携带userId和latestVersion。客户端收到后,通过版本号调一次增量拉取接口,这个增量接口基本只走缓存,几乎不做复杂SQL,所以延迟很低。
不建议把整个业务对象直接塞进推送消息里。不同端展示结构不一样,且版本差异很容易导致端上状态与服务端不同步。推送信号只需要负责唤醒端侧,最终的数据以端到服务端拉取为准。这样即使推送信号丢了,客户端还有1到2秒的兜底轮询,不至于让用户长时间看不到变化。
4. 一致性保障的关键不是回滚,而是幂等、状态机与对账
4.1 低延迟下不一致的来源几乎都来自异步边界
延迟被压到100ms之后,系统已经处在一个重度异步的架构里,一致性问题会集中爆发。常见来源包括:
- 事件丢失:上游事务成功,但消息没投递成功;
- 消息重复:消费端处理成功,但回执丢失,下一条重复消息又来一遍;
- 事件乱序:晚发生的事件先到,把待办状态带偏;
- 缓存与数据库不一致:DB更新成功,Redis更新失败,用户端一直读到旧状态。
面对这四类问题,强一致分布式事务只适合极小部分场景。待办这类数据,选择最终一致,并且设计一个明确收敛机制,是更务实的做法。
4.2 用事务消息或本地事件表保住“不丢”这个底线
上游要保证消息不丢,不能只依赖消息队列本身,而是要把业务提交和事件投递放在同一条可靠性链路上。前面提到的本地消息表、事务消息,都是为了解决这一点。
后面还有一个容易漏掉的细节:事件表扫描任务不能只扫“待投递”状态,还要扫描“投递中”但超过两分钟没有确认的记录。因为投递成功后ACK丢失,这条记录会一直停在“投递中”状态,如果不重新扫描,它就再也不会被处理,这就造成永久漏消息。
对账也必不可少。定时任务扫描事件表与待办表,以事件表为基准,找出“事件已发生但没有对应待办状态变化”的记录。发现缺失就重新投递,发现待办已存在但状态落后,则结合状态机做补偿。
4.3 幂等表设计不是为了防重,是为了让“至少一次”变得安全
消息队列的投递语义普遍是at least once,消费端必须在业务层面把重复变成无害操作。幂等键的选择通常是eventId,或者bizType加bizId。比如订单取消事件,幂等键可以是ORDER_CANCEL加order_12345。
实际操作中,我会把幂等判断和待办状态更新放进同一个数据库事务:先查幂等表,如果没有记录就插入幂等表并更新待办表;如果有记录,就终止处理。为了压住并发,也可以利用数据库唯一索引做insert ignore。
幂等表里建议记录eventId和eventTime。当重复事件到达时,不能只看“是否处理过”,还要再比较业务时间。如果旧事件的eventTime比当前已处理的事件更早,即使它是第一次到达,也需要被判断为过期事件,不能覆盖新状态。
4.4 状态机要把非法流转挡在门外
待办的状态集合需要收敛,一般会设计成待处理、处理中、已完成、已取消、已过期这几种。状态之间必须有明确规则:
- 已完成不能回退到待处理;
- 已取消不允许再被待处理事件激活;
- 只有待处理可以进入处理中;
- 超时会进入已过期,但过期不等于业务完成,允许用更新的业务事件把过期任务重新激活。
这些规则不能散落在各段业务的if else里,而是应该收敛为独立的状态机服务或策略类。消息消费是提出动作申请,能不能流转由状态机裁决。乱序事件到达时,状态机直接拒绝非法流转,就不会把最终状态改坏。
被拒绝的旧事件不能直接静默丢弃,要写进延迟对账表或者异常事件表,留出补偿入口,后续可以判断是否真的需要人工介入。
4.5 一致性的“正则化机制”:靠过程约束收敛最终状态
机器学习里的正则化机制,是为了避免模型被少量异常样本影响,让训练结果向约束空间收敛。分布式系统里也需要这种思路。
幂等表、状态机、事件时间比较、定时对账,本质上都是给系统加约束项。它们不会直接让系统变快,但是它们会在消息重复、乱序、丢失的情况下,保证待办状态最终能收敛到确定值,而不是发散成脏数据。
这套约束也要控制力度。幂等键设得太宽
