方案评审上,我们为一个超时场景吵了一小时
5 月初做支付回调的改造,评审会上卡在一个问题上:调用支付网关的查询接口超时了,这笔订单该标记为成功、失败,还是"处理中"?
一派说标记为失败,让用户重新支付;另一派说标记成成功,反正钱已经扣了;还有人说先挂着,等定时任务去查。三种意见都有道理,谁也说服不了谁。最后架构师说了一句:"这不是对错问题,是你们要在 C 和 A 之间选哪个。"
那句话我没完全听懂,会后把 CAP 的资料重新看了一遍,才明白那场争论的本质。
先说清楚那笔订单的困境
场景是这样的:
- 用户在我们的收银台点了支付,请求转发到微信支付
- 用户在微信侧完成了支付
- 微信回调我们的
/pay/callback接口 - 我们处理回调时,要给微信返回应答,同时更新订单状态
麻烦出在两种情况下:
- 我们更新订单成功,但返回给微信的应答丢了。微信会重发回调,我们要能幂等。
- 我们收到回调,但处理过程中调微信的"订单查询接口"超时了。此时我们不知道这笔钱到底扣没扣。
第二种情况就是会上争论的焦点。三种处理方式对应三种后果:
| 做法 | 后果 |
|---|---|
| 标记失败 | 钱扣了货没发,用户投诉。最严重的资损类型。 |
| 标记成功 | 钱没扣货发了,公司亏钱。 |
| 标记处理中 | 用户看到"支付处理中",不知道结果,体验差但不会出错。 |
CAP 到底在说什么
CAP 三个字母的含义,我按自己的理解重述一遍,去掉那些书本上的表述:
- C(Consistency,一致性):所有节点在同一时刻看到的数据是一样的。注意这里说的是线性一致性,不是数据库事务 ACID 里的那个 C。意思是"写进去之后,后续任何读都能立刻看到"。
- A(Availability,可用性):每个请求都能在有限时间内收到一个非错误的响应。超时、报错都算不可用。注意它不保证响应里的数据是最新的。
- P(Partition tolerance,分区容错):系统被网络分区切成几个孤岛时,还能继续对外提供服务。
一个我以前理解错的点
我以前记的是"CAP 三选二,最多满足两个"。这个说法流传很广,但它把重点带偏了。
正确的理解是:P 不是一个可以放弃的选项。只要系统是分布式的、跑在网络上,网络分区就一定会发生(交换机故障、光纤被挖断、机房之间丢包),你没法选择"不要 P"。所以 CAP 的真正在问的是:
当网络分区发生的时候,你是选择继续提供服务(但可能返回旧数据,即 AP),还是选择停止服务直到数据同步完成(保证一致,即 CP)?
反过来说,在没有分区的正常情况下,C 和 A 是可以同时满足的。我们平时写的绝大多数业务代码都处在这个状态,这也是为什么 CAP 在日常开发中感觉"离自己很远"。它只在出故障的时候才显形。
还有个补充:CAP 里没有考虑延迟(Latency)。实际系统里"慢"和"断"很难区分,调用一个接口 5 秒没返回,你不知道是对端挂了还是只是慢。所以 PACELC 这个扩展理论补充了另一半:没有分区时,系统在延迟(L)和一致性(C)之间做取舍。同步刷盘、读写分离、缓存,本质上都属于这一半的取舍。
业界现成的例子
把理论落到具体的中间件上,会好理解得多:
| 系统 | 倾向 | 分区时的表现 |
|---|---|---|
| ZooKeeper | CP | leader 选举期间整个集群拒绝服务,可能持续几十秒。Dubbo 用 ZK 做注册中心时,ZK 挂掉期间服务调用不受影响(本地有缓存),但新服务注册不上。 |
| Eureka | AP | 节点之间心跳断了也继续提供服务,客户端拿到的是可能过期的实例列表。Spring Cloud 选它就是这个理由:注册中心短暂不一致的代价,远小于整个注册中心不可用。 |
| MySQL 半同步复制 | 偏 CP | 主库要等至少一个从库确认才返回,从库宕机时主库写操作会阻塞或者退化成异步。 |
| Redis 主从(异步复制) | AP | 主节点故障时未同步的数据直接丢失,前面那篇 RDB/AOF 里记的事故就是例子。 |
| Cassandra、DNS | AP | 永远响应,数据最终收敛。 |
我们系统里 CP 和 AP 是并存的
这是我准备这次评审最大的收获:CAP 不是给整个系统贴一个标签,而是按业务场景逐个接口做选择。同一个应用里,不同的接口可以有完全不同的取舍。
我把我们订单系统里的场景列了一遍:
| 场景 | 选择 | 理由 |
|---|---|---|
| 库存扣减 | CP | 超卖一次要赔钱,宁可短暂不可售。用 UPDATE stock SET qty=qty-1 WHERE sku_id=? AND qty>=1 加数据库行锁保证。 |
| 优惠券领取 | CP | 超发的券是真金白银。同上,靠数据库唯一索引加行锁。 |
| 商品详情页 | AP | 价格晚 500ms 更新没人会发现,但页面打不开就流失用户了。用 Redis 缓存,允许短暂不一致。 |
| 用户昵称、头像展示 | AP | 改完昵称自己看到是旧的,刷新一下就好,完全可接受。 |
| 订单列表 | AP | 刚下的单没显示出来,用户会刷新,不会投诉。 |
| 支付状态 | CP | 这个绝对不能含糊,涉及的都是钱。 |
分类的依据其实就一句话:不一致会造成资损或法律风险的场景选 CP,只影响体验的选 AP。
BASE:把"放弃强一致"这件事说清楚
选了 AP 之后总得有个说法,不然就变成了"我们不管数据对不对"。BASE 就是这套说法:
- Basically Available(基本可用):允许响应时间变长(平时 50ms,故障时 2 秒),允许功能降级(关掉推荐、关掉评论),但核心功能不能停。
- Soft state(软状态):允许系统存在中间状态,比如"支付处理中"、"出票中",并且这个状态不需要实时同步给所有节点。
- Eventually consistent(最终一致):经过一段时间后,数据会收敛到一致。重点是"一段时间"要可度量、可监控,不能是"也许有一天会对"。
落到实现上,我们用的三种手段:
-- 1. 本地消息表:业务操作和消息在同一个本地事务里写进去
BEGIN;
UPDATE t_stock SET qty = qty - 1 WHERE sku_id = 1001 AND qty >= 1;
INSERT INTO t_message (biz_id, topic, body, status) VALUES ('ORD123', 'stock_deducted', '...', 0);
COMMIT;
-- 2. 定时任务扫 t_message,投递到 RocketMQ,成功则改 status=1
-- 3. 消费端幂等,靠唯一键 ord_no 去重
INSERT IGNORE INTO t_message_consume (msg_id) VALUES ('MSG456');
还有最土但最有效的兜底:对账。每天凌晨 3 点跑全量对账,比对订单表和支付流水表,不一致的记录自动生成工单。我们上线半年,靠对账捞出来 3 笔异步消息丢失导致的差异,都是百元以内的单子。
那个支付回调最后怎么定的
回到开头的争论。最后的方案是"三段式":
- 回调来了先落库,状态写成
PAYING(处理中),立刻给微信返回成功应答(避免它一直重发)。这一步只依赖本地数据库,可用性最高。 - 异步主动查询:
PAYING状态的订单进入延迟队列,1 秒、5 秒、30 秒、2 分钟各查一次支付网关。查到明确结果就更新状态。 - 兜底对账:超过 10 分钟还是
PAYING的,进入人工对账队列,同时给用户展示"银行处理中,请稍后查看"。
这个设计里,用户能看到的状态是"软状态"(BASE 的 S),最终结果靠主动查询保证收敛(E),而"绝不猜测状态"这条保证了不会出现资损(C)。代价是用户可能要多等几秒,这就是为了一致性付出的可用性。
上线一个月的数据:回调超时率 0.31%(主要是微信侧抖动),其中 82% 在第一次重试(1 秒后)就查到结果,17% 在 30 秒内,剩下 1% 走对账。之前这类订单每天要人工处理 20 多单,现在是 0。
先到这
《CAP 理论与分布式系统中的取舍》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。