数据库一致性设计:从事务、幂等到最终一致性
复杂业务里的数据一致性不只靠数据库事务,还要结合业务唯一键、状态机、消息队列、补偿任务和对账机制。
数据库一致性设计:从事务、幂等到最终一致性
数据库一致性是后端系统里最容易被低估的问题。单机数据库事务能解决一部分问题,但真实业务往往跨服务、跨数据库、跨消息队列,甚至跨第三方支付平台。此时“一个事务包住所有操作”通常做不到。
一致性设计的核心不是追求所有地方都强一致,而是识别哪些环节必须立即正确,哪些环节可以通过最终一致、补偿和对账来收敛。
本地事务能解决什么
如果所有数据都在同一个数据库里,本地事务是最清晰的选择。比如用户转账,扣减 A 账户、增加 B 账户、写流水,这三步必须一起提交。
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;
INSERT INTO account_flows(from_account, to_account, amount)
VALUES (1, 2, 100);
COMMIT;
本地事务的边界非常重要。不要在事务里调用远程服务,也不要在事务里等待用户操作。事务越长,锁持有越久,冲突越多。
分布式事务为什么难
订单服务、库存服务、优惠券服务、支付服务通常有各自的数据库。一个下单请求可能要同时写多处数据。
理论上可以使用 2PC、TCC、Saga 等方案。2PC 追求强一致,但会引入协调者、阻塞和复杂的异常恢复。TCC 把业务拆成 Try、Confirm、Cancel,对业务侵入较强。Saga 通过一组本地事务和补偿动作实现最终一致,更适合长流程。
真实工程里,很多系统会避免大范围分布式事务,而是用本地事务 + 可靠消息 + 幂等消费 + 补偿对账来实现最终一致。
可靠消息与 Outbox 模式
一个常见问题是:数据库写成功了,但消息发送失败。比如订单状态已改为已支付,但支付成功事件没发出去,报表和履约系统都不知道。
Outbox 模式的思路是,在同一个本地事务里写业务表和消息表,然后由后台任务或 CDC 把消息表投递到 Kafka。
BEGIN;
UPDATE orders
SET status = 'PAID'
WHERE id = 10001 AND status = 'CREATED';
INSERT INTO outbox_events(event_id, event_type, payload, status)
VALUES (
'evt_10001_paid',
'ORDER_PAID',
'{"order_id":10001}',
'NEW'
);
COMMIT;
这样至少可以保证“业务状态变化”和“待发送消息”一起落库。后续即使发送失败,也能重试。
幂等是最终一致的底座
消息系统通常会出现重复投递。网络超时、消费者重启、生产者重试,都可能让同一事件被处理多次。
所以消费者必须幂等。常见做法包括:
- 使用业务唯一键。
- 使用事件 ID 去重表。
- 使用状态机限制非法状态跳转。
- 使用数据库唯一约束兜底。
CREATE TABLE processed_events (
event_id VARCHAR(128) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);
BEGIN;
INSERT INTO processed_events(event_id, processed_at)
VALUES ('evt_10001_paid', now());
UPDATE orders
SET status = 'PAID'
WHERE id = 10001 AND status = 'CREATED';
COMMIT;
如果 event_id 已经存在,说明事件处理过了,消费者可以直接跳过。
状态机比布尔字段可靠
复杂业务不要只用几个布尔字段表示状态。订单、支付、退款、履约都应该有明确状态机。
CREATED -> PAYING -> PAID -> SHIPPED -> FINISHED
CREATED -> CANCELLED
PAID -> REFUNDING -> REFUNDED
状态机的好处是可以拒绝非法跳转。比如已退款订单不能再发货,已取消订单不能再支付成功。
最终一致不是“不一致”
最终一致的意思是系统短时间内可以有延迟,但最终必须收敛到正确状态。它不是允许数据永远错下去。
因此最终一致一定要配套:
- 可重试任务。
- 死信队列。
- 失败告警。
- 定期对账。
- 手工修复入口。
比如支付系统可以每天和第三方支付平台对账。如果本地显示未支付,但支付平台显示成功,就要触发补偿,把订单修正为已支付。
小结
一致性设计要分层看:
- 单库内优先用本地事务。
- 跨服务优先缩小事务边界。
- 状态变化通过事件传播。
- 消息消费必须幂等。
- 关键业务必须有对账。
强一致很贵,最终一致也不简单。真正可靠的系统,是清楚知道哪里必须同步正确,哪里可以异步收敛,并且有能力发现和修复偏差。