Posted on ::

一、前言

在后端开发领域,数据一致性永远是核心议题。之前我们或许讨论过冗余数据存储中的一致性,也研究过分布式共识算法。但回归到业务开发的最前线,我们面临最频繁的挑战,往往源于事务处理。

无论是处理单体架构下的复杂业务,还是微服务架构下的跨服务调用,其核心诉求是一致的:保证状态转化的安全。也就是我们常说的,要么全部成功,要么全部失败。

本文将从单机数据库的底层机制出发,推演到分布式环境下的一致性困境与破局方案。

二、单机事务的基石

数据库事务的本质是一组操作的集合。这组操作是不可分割的,它们负责将数据库从一个一致性状态,转换到另一个一致性状态。

对于Java开发者而言,我们要理解的不仅仅是Spring中的@Transactional注解,更需要理解底层数据库(以MySQL InnoDB为例)是如何支撑起ACID特性的。因为单机事务的实现原理,往往是分布式事务设计思想的源头。

2.1 隔离性与并发控制

隔离性 Isolation 是为了解决并发执行时,不同事务之间的干扰问题。其本质是在并发场景下,通过控制数据的访问范围,实现不同级别的对外一致性。

2.1.1 常见的并发异常

  • 脏读:读取到了其他事务未提交的数据。如果前一个事务回滚,当前事务持有的数据就是脏数据。
  • 不可重复读:同一事务内,两次读取同一行数据,结果不一致。这是因为中间有其他事务提交了修改。
  • 幻读:同一事务内,同样的查询条件,第二次查询时发现结果集中多了或者少了一些行。

2.1.2 MySQL的解决方案:MVCC与锁

MySQL InnoDB引擎通过多版本并发控制 MVCC 和锁机制配合解决了上述问题。

  • 读屏障 MVCC:

    MVCC本质是一种快照读。InnoDB通过Undo Log构建数据的历史版本链,并通过Read View 读视图来判断当前事务能看到哪个版本的数据。

    • 读已提交 RC级别下,每次查询都会生成新的Read View,所以能看到最新的已提交数据。
    • 可重复读 RR级别下,只在第一次查询生成Read View,后续查询沿用,从而保证了可重复读。
  • 写屏障 锁机制:

    对于写操作,必须串行化。

    • 行锁 Record Lock:锁住具体的数据行。
    • 间隙锁 Gap Lock:锁住数据之间的间隙,防止其他事务插入新数据。这是解决幻读的关键手段。
    • 临键锁 Next-Key Lock:行锁+间隙锁,锁定左开右闭区间。

2.2 原子性与回滚

原子性 Atomicity 要求事务要么全做,要么全不做。

在单机场景下,Undo Log 是实现原子性的关键。

每当我们要修改一条数据时,InnoDB不仅会修改内存中的数据,还会生成一条对应的反向操作日志记录在Undo Log中。

  • 如果执行Insert,Undo Log就记录Delete。
  • 如果执行Update,Undo Log就记录Update回旧值的SQL。

一旦事务需要回滚,或者系统崩溃重启,数据库就可以利用Undo Log将数据恢复到事务开始前的状态。

2.3 持久性与性能平衡

持久性 Durability 保证事务一旦提交,修改就是永久的。

这里存在一个经典的矛盾:写磁盘太慢,写内存不可靠。

InnoDB采用了 WAL 预写日志 技术来平衡。

  1. 修改数据时,先在内存 Buffer Pool 中修改。
  2. 同时将修改操作顺序写入 Redo Log 并落盘。
  3. 只要Redo Log写入成功,事务就视为提交成功。
  4. 内存中的脏页会由后台线程慢慢刷回磁盘数据文件。

即使断电,利用Redo Log也能重放操作,恢复数据。


三、分布式环境的混沌与挑战

当我们从单机走向分布式,面对的最大的敌人不再是磁盘IO,而是不可靠的网络不可靠的时钟

3.1 拜占庭式的网络

在分布式系统中,节点通信依赖网络。但网络是不可靠的,一次RPC请求的结果存在三种状态:

  1. 成功。
  2. 失败。
  3. 未知(超时)

当发生超时时,发送方无法确定请求是根本没发出去,还是请求处理了但响应丢了。这就迫使我们必须引入确认应答模式重试机制

  sequenceDiagram
    participant A as 服务调用方
    participant B as 服务提供方
    A->>B: 发送请求
    alt 网络正常
        B-->>A: 返回成功
    else 请求丢失
        A->>A: 超时未收到响应
    else 响应丢失
        B->>B: 处理业务
        B--x A: 响应在网络中丢失
        A->>A: 超时未收到响应
    end
    Note right of A: 只要没收到明确回复,
A就无法确定B的状态

3.2 测不准的时钟

每台服务器都有自己的本地时钟。NTP同步虽然能校准时间,但依然存在误差。在分布式数据库中,如果单纯依赖物理时钟来判断由于两个事务的先后顺序,可能会出现后发生的操作时间戳反而更小的情况。

因此,分布式系统常采用逻辑时钟(如Lamport时钟)或中心化的TSO 全局授时服务来保证全局顺序一致性。


四、分布式事务的原子性解决方案

在分布式场景下,我们需要跨多个节点实现要么全做,要么全不做。业界演化出了两大类解决思路:强一致性的回滚模式最终一致性的重试模式

4.1 强一致性路径:二阶段提交 2PC

2PC Two-Phase Commit 是分布式事务最基础的协议。它引入了一个协调者来管理所有参与者。

  • 阶段一 准备 Prepare:协调者询问所有参与者,你们准备好提交了吗?参与者执行本地事务,写Redo/Undo Log,但不提交,并锁住资源。
  • 阶段二 提交 Commit:如果所有参与者都回复Yes,协调者发送Commit命令;只要有一个回复No或超时,协调者发送Rollback命令。

4.1.1 落地实现:Seata AT模式

Seata是阿里开源的分布式事务框架,其AT模式是目前Java生态中最流行的2PC改良版。它对业务代码几乎无侵入。

核心机制:

Seata在本地事务提交前,会先解析SQL,查询更新前的数据镜像 Before Image,执行更新后,再查询更新后的数据镜像 After Image。这两个镜像会作为回滚日志 Undo Log 存入数据库。

若需要回滚,Seata会利用Before Image还原数据。

若需要提交,Seata只需异步删除Undo Log即可。

注意:AT模式虽然方便,但需要获取全局锁来防止脏写,这在高并发场景下会有性能损耗。

4.2 业务层面的2PC:TCC模式

2PC和Seata AT模式是数据库层面的强一致,会长时间持有数据库锁。TCC Try-Confirm-Cancel 则是将锁的粒度上移到业务层面。

  • Try:资源检查和预留。例如冻结资金、预扣库存。
  • Confirm:真正的业务执行。使用Try阶段预留的资源。
  • Cancel:预留资源的释放。

代码示意

public interface InventoryService {
    // Try阶段: 预扣库存
    @TwoPhaseBusinessAction(name = "deductInventory", commitMethod = "confirmDeduct", rollbackMethod = "cancelDeduct")
    boolean prepareDeduct(BusinessActionContext context, @BusinessActionContextParameter(paramName = "productId") String productId, @BusinessActionContextParameter(paramName = "count") int count);

    // Confirm阶段: 真正扣减
    boolean confirmDeduct(BusinessActionContext context);

    // Cancel阶段: 释放预留
    boolean cancelDeduct(BusinessActionContext context);
}

TCC 性能更好,因为不依赖数据库的长事务,但对业务侵入极大,每个接口都要写三套逻辑。

4.3 最终一致性路径:消息队列

在很多高并发业务场景(如电商下单后发券、发通知),我们并不要求实时一致,只要求最终一致。这时,尽最大努力交付是更好的选择。

其核心逻辑是:重试。只要我不断重试,消息总能送达,操作总能执行成功。

但是,如何保证 本地事务提交 和 消息发送 这两个动作的原子性?这里有两种经典方案。

4.3.1 本地消息表

这是最稳健的方案,不依赖特定MQ的功能。

流程

  1. 在业务库中创建一个message表。
  2. 业务操作和插入message表在同一个本地事务中完成。
  3. 事务提交后,业务数据和消息记录同时存在。
  4. 通过一个定时任务或者异步线程,扫描message表,将未发送的消息投递到MQ。
  5. MQ消费成功后,回调删除或更新message表的状态。

SQL结构示例

CREATE TABLE `local_message` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `msg_topic` varchar(64) NOT NULL,
  `msg_content` text,
  `status` tinyint DEFAULT '0' COMMENT '0:未发送, 1:已发送, 2:失败',
  `retry_count` int DEFAULT '0',
  `next_retry_time` datetime,
  PRIMARY KEY (`id`)
);

4.3.2 事务消息 RocketMQ

RocketMQ 提供了原生的事务消息支持,本质上是把 2PC 的思想应用到了消息发送上。

流程

  1. 发送半消息:Producer发一条Half Message给MQ,MQ收到后持久化但不投递给Consumer。
  2. 执行本地事务:Producer执行本地业务逻辑。
  3. 提交或回滚
    • 本地事务成功,发送Commit,MQ将消息改为可投递。
    • 本地事务失败,发送Rollback,MQ删除该消息。
  4. 回查兜底:如果MQ长时间没收到Commit/Rollback,会反向查询Producer,确认本地事务的状态。
  sequenceDiagram
    participant App as 应用服务
    participant MQ as RocketMQ
    participant DB as 数据库

    App->>MQ: 1. 发送半消息(Half Msg)
    MQ-->>App: OK
    App->>DB: 2. 执行本地事务
    alt 事务成功
        App->>MQ: 3. Commit
        MQ->>Consumer: 投递消息
    else 事务失败
        App->>MQ: 3. Rollback
        MQ->>MQ: 删除消息
    else 网络超时/进程崩溃
        MQ->>App: 4. 回查事务状态
        App->>DB: 检查数据
        App->>MQ: 补发Commit/Rollback
    end

五、分布式环境下的隔离性与持久性

5.1 分布式隔离性

在分布式环境下,隔离性通常演变成读写分离下的延迟问题。

例如,主库更新了库存,从库还没同步,用户读取从库发现库存还有,下单却失败。

解决思路

  1. 强制读主:对于一致性要求极高的关键数据(如金额、库存),强制走主库查询。
  2. 全局一致性快照:如Google Spanner或OceanBase,利用TrueTime或全局时间戳服务,保证在任何节点都能读到一致的快照版本。

5.2 分布式持久性

单机的持久性靠Redo Log,分布式的持久性靠多副本复制

为了防止单节点故障导致数据丢失,数据通常会复制多份(如3副本)。这里涉及一致性算法(Paxos、Raft)。

  • 强一致复制:写操作必须同步到所有副本才算成功。安全但慢。
  • 半同步复制:写操作同步到多数派(Quorum)即可。

六、总结与选型建议

分布式事务是一剂猛药,能治病,也有副作用(性能下降、复杂度飙升)。作为资深开发,我们在做架构选型时,应遵循以下原则:

  1. 能不用就不用。

    如果业务能通过领域划分,聚合在同一个数据库内,利用本地事务解决,那是最好的。不要为了分布式而分布式。

  2. 区分业务场景的容忍度

    • 强一致性场景(如金融转账):必须保证实时一致。考虑 Seata AT 模式(开发成本低)或 TCC 模式(性能要求高)。
    • 最终一致性场景(如支付成功后发货、积分变更):推荐使用 RocketMQ事务消息本地消息表。这是互联网大厂最常用的解耦方案。
  3. 设计可回滚与可补偿的业务。

    无论选择哪种方案,业务代码的健壮性至关重要。你需要思考:如果重试一直失败怎么办?是否需要人工介入的死信队列?接口是否做好了幂等性设计?

技术服务于业务。理解ACID的底层原理,掌握2PC、MQ等工具的适用边界,才能在复杂的分布式迷局中,构建出既稳健又高效的系统。

Table of Contents