你给数据库加了一台读副本,查询压力降了,账单也像是算对了。直到用户付完钱却看见「订单不存在」——扩容没有删除一致性,它只是给旧数据留了一扇门。
创业第 5 个月,「秒抢」有了约 1 万用户。公司 12 人,后端 3 人;你还是那个第 3 号员工。周一上午的开票刚过峰值,你正准备去接水,客服群在 10:20 突然被同一句话刷屏:「钱扣了,订单呢?」
你把支付回调、主库提交和订单查询放到同一张大盘上。四个数字把「支付失败」这个直觉排除了:
订单写入主库已经成功,详情页却在读副本上查不到它;异常率和副本延迟在同一分钟抬头。林工把复制位点截图甩进群里,只说了一句:「主库没丢,副本还没追上。」这不是数据消失,而是用户跑到了数据前面。
再看一眼时间轴:订单 O-102014 在 10:20:14.200 提交,用户在 10:20:14.650 打开详情页,只隔了 450ms;当时副本落后 2.8s。页面把「此刻还没复制到」翻译成了「订单不存在」,客服工单于是开始滚雪球。
C 是这一步的正解:先堵住错误读。 主库已经有订单,短时把刚支付用户的读请求送回主库,能立刻恢复「读到自己刚写的数据」;同时把副本未命中从「不存在」降级为「处理中」,避免把暂时不可见宣判成永久失败。路由范围只圈住刚支付链路,主库账单可控。
A 的问题是时间,也可能选错了药。 新副本要拉取数据并追赶日志,不会在下一分钟接住客服工单;如果重放才是瓶颈,多一台同样落后的副本只是多一个旧答案。扩读容量适合稳态容量问题,不适合眼前的写后读缺口。
B 不但没帮上忙,还在加码。 每个受影响用户一秒发五次查询,会把读流量和客服焦虑一起放大;轮询最终也许能读到,但它既没有建立读路由契约,也可能在复制继续落后时制造小型重试风暴。
D 该做,但不是现在做。 大事务确实可能拖慢副本重放,暂停批任务能阻止延迟继续扩大;然而已经积压的日志仍要追平,当前用户仍在读旧副本。它应该紧跟在 C 后面,而不是代替止血。
读写分离的路径并不复杂:写请求进入主库,主库提交事务并产生复制日志;副本接收日志,再按顺序重放;读路由把查询送到副本。主库提交成功与副本重放完成是两个时刻。只要复制是异步的,中间就存在一个可以读到旧数据的窗口。
「读写分离没有消灭一致性问题,它把问题从『有没有』变成了『多久』。」写入是否成功,回答的是主库有没有;用户何时能在副本看到它,回答的是复制要多久。这两个问题混在一个 ORDER_NOT_FOUND 里,产品语义就会撒谎。
把现场的一个订单代进去:
10:20:14.650 落在 10:20:14.200 到约 10:20:17.000 之间,所以副本返回旧结果并不神秘。更危险的是倍数:平时约 35ms,现场 2.8s,窗口扩大到 2.8 ÷ 0.035 = 80 倍。若峰值每秒完成 240 笔支付,并且每位用户都立即看一次订单,最多约有 240 × 2.8 = 672 次写后读暴露在这个窗口里。这个数不是丢单数,而是需要正确路由或正确展示的风险读次数。
复制延迟通常从三处累积,定位时要分开看:
| 来源 | 发生了什么 | 该看什么 | 常见误判 |
|---|---|---|---|
| 网络 | 日志从主库到副本的传输变慢、抖动或中断 | 发送位点与接收位点差距、链路 RTT、吞吐 | 只看副本 CPU,误以为机器空闲就没有延迟 |
| 应用并行度不足 | 副本应用日志的有效并行度跟不上主库的并发写入:可并行的 worker 数量有上限,事务之间存在库/行级依赖而不能全部并行,热点行冲突还会让部分事务回退成串行重试 | 接收位点已追上,但应用位点持续落后;worker 队列深度、冲突回退次数是否上升 | 把「加读副本」当成提升单副本重放能力的办法,却没查过并行度上限和依赖冲突 |
| 大事务 | 一笔批量更新占住重放路径,后面的小订单只能排队 | 事务大小、重放耗时、延迟是否与批任务同步 | 把毛刺当网络偶发抖动,忽略固定时刻的批处理 |
因此「复制延迟」不能只挂一个模糊的秒数。至少要同时观察主库当前提交位点、副本接收位点、副本应用位点,并用时间戳或日志序号求差:接收慢,先查网络;接收快而应用慢,查重放和事务。对业务再补一层 write-to-visible 指标:从订单提交到各副本能够查到它,P50、P99 各是多少。
今天同机房里的 2.8 秒已经足以制造「丢单」。到第 15 章,复制跨过机房边界,网络距离、链路故障和恢复追赶会让这个延迟变成秒级甚至分钟级;届时,新鲜度不再只是一个接口的选择,而会升级成多活架构的核心契约。
这类事故最容易被误报成「丢单」或「缓存没刷新」。真正的诊断钩子不是某一次 404,而是错误读与写高峰、复制延迟在时间上同步出现。
记住这张卡的形状:▁▁▃▁▁█▁。如果尖刺跟写高峰一起出现,先对齐主库提交、副本接收、副本应用三个时刻,再决定要不要怀疑业务写入。先确认「晚到」,能避免把一场路由事故升级成错误的数据补写。
事故止住后,林工在白板上写下三个方案。它们都能缓解「刚写完就读不到」,但承诺并不相同:有的购买数据库新鲜度,有的购买会话连续性,有的只购买更好的用户体验。每一个都是对的,每一个都有账单。
A · 强制读主:对被圈定的接口提供最直接的写后读一致性,路由简单,排障也直观。 账单:读压力回到主库,热点查询可能挤占写入资源,主库故障域也被更多请求触碰。适合支付结果、退款状态等读错代价高、查询量可控的场景。
B · 会话粘性:只让刚发生写入的用户暂时读主,普通读仍由副本分担,容量与新鲜度较平衡。 账单:路由层要携带写入时间或位点;多设备、会话漂移和窗口估短都会漏掉旧读,估长则把更多压力送回主库。适合登录态稳定、写后读集中在数秒内的交互链路。
C · 前端乐观展示:用户立刻看到写请求返回的订单摘要,页面不必等副本追平,体验最平滑。 账单:它改善的是展示,不是数据库一致性;后续支付撤销、写入补偿或跨设备查询仍需权威状态确认。适合创建成功概率高、允许显示「确认中」并有失败回滚界面的场景。
「秒抢」的选择:以 B 做默认路由,支付成功后的 5 秒会话窗口读主;同时采用 C,先用支付回调中的订单摘要展示「已受理,确认中」。退款与票权核销这类读错代价更高的接口单独走 A。理由是约 1 万用户阶段主库还有余量,但团队不想让普通订单历史查询重新淹没写路径;三层护栏分别处理路由、新鲜度和界面语义。
回放 10:20 的正确剧本——留意动作顺序:先停止向用户输出错误结论,再压住延迟来源,最后才恢复读流量。
堵住错误读。打开订单写后读主开关,只圈住支付成功后的详情请求;副本未命中改成「订单确认中」。paid-order-not-found 从 18.4% 降到 1.2%,主库读 QPS 上升约 9%,仍在容量线内。
停止继续积压。暂停与开票重叠的结算批任务,并确认主库提交仍健康。副本应用位点开始追近,重放延迟从 2.8s 回落到 900ms。
等待追平,不重启。副本已经收到日志,瓶颈在应用端;团队让重放队列自然排空,同时逐笔核对支付单与主库订单。延迟降到 70ms,未发现真实丢单。
收窄临时开关。保留 5 秒会话粘性,普通历史查询恢复读副本;告警改为同时监控接收位点差、应用位点差和写后可见 P99。主库读 QPS 回到基线附近,客服新增工单停止。
主库提交成功率平稳;业务未命中曲线呈 ▁▁▃▁▁█▁,每根毛刺都与写高峰和副本应用延迟同步,追平后自行回落。最可能是哪种故障?
B。 「与写高峰同步的毛刺 + 主库成功 + 追平后恢复」正是 No.02 的形状。A 很诱人,因为用户看到的就是「丢单」,但真实丢失不会随着副本位点追平而自己出现;C 更常表现为连接与延迟阶跃打满,不会只让副本读到旧数据。
订单在 10:20:14.200 于主库提交,当时复制延迟为 2.8s。用户在 10:20:16.400 从副本读取。以下判断哪个最准确?
B。 读取发生在提交后 2.2s,而旧数据窗口是 2.8s,副本约到 10:20:17.000 才追到这笔写。A 把「已经等了一段时间」误当成「已经等够」;C 又把同一个复制窗口重复计算了一次,读操作不会再凭空增加一段 2.8s。
监控显示副本接收位点几乎贴着主库,但应用位点持续落后,并且毛刺总与一笔批量结算事务同时出现。第一排查方向是什么?
B。 日志已经及时到达,差距出现在「接收之后、应用之前」,又与大事务同步,证据指向重放路径。A 是最常见的惯性答案,但若网络是主因,接收位点本身就会落后;D 只会增加读负载,不能让应用位点追得更快。
团队用写响应里的订单摘要做前端乐观展示。这个方案提供的主要承诺是什么?
B。 乐观展示复用写响应,解决的是复制窗口内的界面连续性,并没有改变副本状态。A 最诱人,因为页面看起来已经正确,但视觉正确不等于数据层强一致;D 也不成立,另一台设备没有本次写响应,仍需要会话路由或读主保障。