数据库一致性设计:从事务、幂等到最终一致性

复杂业务里的数据一致性不只靠数据库事务,还要结合业务唯一键、状态机、消息队列、补偿任务和对账机制。

数据库一致性设计:从事务、幂等到最终一致性

数据库一致性是后端系统里最容易被低估的问题。单机数据库事务能解决一部分问题,但真实业务往往跨服务、跨数据库、跨消息队列,甚至跨第三方支付平台。此时“一个事务包住所有操作”通常做不到。

一致性设计的核心不是追求所有地方都强一致,而是识别哪些环节必须立即正确,哪些环节可以通过最终一致、补偿和对账来收敛。

Rendering diagram...

本地事务能解决什么

如果所有数据都在同一个数据库里,本地事务是最清晰的选择。比如用户转账,扣减 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

状态机的好处是可以拒绝非法跳转。比如已退款订单不能再发货,已取消订单不能再支付成功。

最终一致不是“不一致”

最终一致的意思是系统短时间内可以有延迟,但最终必须收敛到正确状态。它不是允许数据永远错下去。

因此最终一致一定要配套:

  • 可重试任务。
  • 死信队列。
  • 失败告警。
  • 定期对账。
  • 手工修复入口。

比如支付系统可以每天和第三方支付平台对账。如果本地显示未支付,但支付平台显示成功,就要触发补偿,把订单修正为已支付。

小结

一致性设计要分层看:

  • 单库内优先用本地事务。
  • 跨服务优先缩小事务边界。
  • 状态变化通过事件传播。
  • 消息消费必须幂等。
  • 关键业务必须有对账。

强一致很贵,最终一致也不简单。真正可靠的系统,是清楚知道哪里必须同步正确,哪里可以异步收敛,并且有能力发现和修复偏差。

参考链接