基于 Hmily 实战 TCC 分布式事务之二

大纲

参考资料

账户系统 TCC 事务实战(编码)

账户系统的前置知识

Hmily 分布式事务框架

Hmily 是一个基于 Java 的金融级柔性分布式事务开源解决方案(已停止维护),主要用于微服务架构下的 TCC(Try-Confirm-Cancel)和 TAC(自动补偿)事务模式;支持通过零侵入式设计快速集成 Dubbo、SpringCloud 等框架,支持多种 RPC 框架和日志存储方式(如 MySQL、Redis、MongoDB、ZooKeeper 等),具备高可靠性、性能和可观测性,可在分布式环境中确保多个服务的一致性执行。它适合金融级场景处理复杂分布式事务。

Hmily 的官方文档

Hmily 的官方文档可以查阅 GitHub WiKi该文档已过时(最后更改时间是 2018 年),仅供参考,强烈建议直接阅读 Hmily 官方的案例代码

Hmily 的官方案例

Hmily 的官方案例代码可以从 GitHub 仓库 获取,包括 TCC、TAC 事务模式使用的 Demo。

Hmily 的版本说明

特别注意

  • Maven 中央仓库中 hmily-spring-boot-starter-springcloud 的最新版本是 2.1.1,并没有 2.1.2 版本。
  • 如果需要 2.1.2 版本,可以从 这里(不稳定,生产环境慎用) 直接下载,并通过 mvn install 命令安装到本地。
Hmily 的核心功能
  • 高可靠性:支持分布式场景下的事务异常回滚、超时异常恢复、防止事务悬挂
  • 易用性:提供零侵入性式的 Spring-BootSpring-Namespace 快速与业务系统集成
  • 高性能:去中心化设计,与业务系统完全融合,天然支持集群部署
  • 可观测性:支持 Metrics 多项指标性能监控,以及 Admin 管理后台 UI 展示
  • 多种 RPC:支持 DubboSpringCloudMotanSofa-rpcbrpctars 等知名 RPC 框架
  • 日志存储:支持 mysqloraclemongodbrediszookeeper 等日常存储方式
  • 复杂场景:支持 RPC 嵌套调用事务

使用 Hmily 的必要前提

  • 必须使用 JDK8+
  • TCC 模式必须要使用一款 RPC 框架,比如 : DubboSpringCloudMontan
Hmily 的 TCC 模式

当使用 Hmily 的 TCC 模式时,开发者需要根据自身业务需求提供 tryconfirmcancel 等三个方法,并且 confirmcancel 方法由开发者自己完成实现,Hmily 只是负责调用方法来达到事务的一致性。

Hmily 的 TAC 模式

当使用 Hmily 的 TAC 模式时,开发者必须使用关系型数据库来进行业务操作,Hmily 会自动生成 回滚 SQL;当业务出现异常的时候,Hmily 会自动执行 回滚 SQL 来达到事务的一致性。

Hmily 的工作原理

Hmily 的工作原理

  • Hmily 是一种基于 TCC、TAC 模型的分布式事务解决方案,其核心思想是将分布式事务拆分为各个服务的本地事务来处理。
  • 在事务执行过程中,每个参与者服务需要实现 Try / Confirm / Cancel 三个本地事务接口,用于完成资源预处理、提交和回滚。
  • 当全局事务开始时,事务协调器会通过 RPC 调用各参与者的 Try 接口;如果所有参与者执行成功,则继续调用 Confirm 完成事务提交;一旦出现异常,则通过 Cancel 接口进行事务回滚。
  • Hmily 通过 RPC 在各服务之间传递事务上下文并协调执行流程,从而实现对分布式事务的统一控制。由于通信基于 RPC 协议,只要各语言能够实现对应的 TCC 接口规范,理论上即可支持跨语言的分布式事务场景。
  • Hmily 采用去中心化的事务协调方式,由事务发起方充当事务协调器,通过 RPC 调用各参与者的 TryConfirmCancel 接口,并依赖事务日志和恢复机制,在没有独立中心节点的情况下完成分布式事务协调。

Hmily 事务协调器的工作流程

  • 1、全局事务创建(Root)

    • 当一个被 @Hmily 标注的方法执行时:
      • 当前服务创建一个全局事务
      • 生成全局事务 ID(XID)
      • 当前服务实例即成为事务协调者
  • 2、Try 阶段(第一阶段)

    • 事务协调者通过 RPC 调用各参与者服务的 Try 方法
    • 每个参与者:
      • 执行本地 Try 逻辑
      • 持久化事务参与记录(事务日志)
    • 所有 Try 执行成功 → 进入 Confirm
    • 任意一个 Try 执行失败 → 进入 Cancel
    • 事务协调并不是集中转发,而是通过 RPC 调用链路自然传播事务上下文
  • 3、Confirm / Cancel 阶段(第二阶段)

    • Confirm(提交)
      • 事务协调者调用各参与者服务的 Confirm 方法
      • 完成最终提交(要求幂等)
    • Cancel(回滚)
      • 事务协调者调用各参与者服务的 Cancel 方法
      • 执行补偿逻辑(需处理空回滚、防悬挂等问题)
  • 4、异常与恢复机制(无中心但可恢复)

    • 事务日志已持久化
    • 如果事务协调者宕机或网络异常:
      • 任意一个服务实例都可以通过定时恢复任务
      • 根据事务日志状态继续执行 Confirm 或 Cancel
    • 协调能力依赖事务日志 + 重试机制,而不是中心节点存活
  • 5、去中心化设计的优缺点

    • 优点:
      • 无单点故障
      • 无需额外部署事务协调器服务
      • 更符合微服务自治和水平扩展
    • 缺点:
      • 业务侵入性高(需要自行实现 TCC 的 Try / Confirm / Cancel 三个本地事务接口)
      • 需要严格处理:
        • 幂等
        • 空回滚
        • 防悬挂
        • 事务异常
      • 开发复杂度远高于 AT 模式(自动事务),AT 模式的概述:
        • 由框架自动完成事务的提交与回滚,业务代码几乎无侵入的一种分布式事务模式
          • 基于数据库本地事务
          • 通过 SQL 解析 + Undo Log 实现自动回滚
          • 业务侧无需编写 Try / Confirm / Cancel 逻辑
        • 常见框架:Seata AT 模式

总结

  • Hmily 基于 TCC 模型,通过 RPC 协调各服务执行本地的 Try、Confirm、Cancel 事务,将分布式事务问题转化为多个可控的本地事务,从而实现分布式事务一致性。
  • 在 Hmily 中,没有独立部署的中心事务协调器服务。事务发起方本身就是事务协调器,协调逻辑以内嵌组件的形式存在于各个业务服务中,事务状态通过共享的事务日志存储来维护。
Hmily 的注意事项
  • 依赖冲突问题

    • Hmily-TCC 依赖了一些通用基础库,在实际项目中可能与已有依赖产生版本冲突。
    • 通常需要通过排查 Maven 的依赖树,统一版本号,或使用 exclude 排除冲突依赖来解决。
  • 数据一致性问题

    • 如果分布式事务在各个阶段发生异常且缺乏完善的处理机制,可能会导致业务数据不一致。
    • 为避免该问题,需要在 TCC 接口的 tryconfirmcancel 阶段设计合理的异常处理和幂等控制,并根据业务场景选择回滚、重试或补偿等策略。
  • 分布式锁问题

    • 分布式事务通常涉及多个节点的协同执行,部分场景下需要借助分布式锁来保证数据操作的正确性。
    • 在 Hmily-TCC 中,可结合 ZooKeeper、Redis 等组件实现分布式锁。
    • 使用分布式锁时需重点关注锁的粒度、超时设置和释放机制,以避免死锁或性能下降。
  • 分布式事务性能问题

    • Hmily-TCC 基于 AOP 对目标方法进行代理,在一定程度上会引入性能开销。
    • 可通过合理设计 TCC 接口、减少事务粒度、结合缓存或异步处理等方式,对整体性能进行优化。
  • 分布式事务可见性问题

    • Hmily-TCC 中事务数据的可见性主要依赖底层数据库事务机制,因此需要确保所使用的数据库具备良好的 ACID 支持,并尽量保持各参与方使用一致的数据库引擎。
    • 同时,应合理选择数据库隔离级别,以避免脏读、不可重复读或幻读等事务并发问题。

账户系统的案例(SpringCloud)

特别注意

本节中的案例代码仅用于演示如何通过 SpringCloud + Hmily 实现 TCC 分布式事务,并未涵盖 TCC 在实际运行过程中可能出现的各种事务异常处理场景(如空回滚、防悬挂、第二阶段重复提交等问题),而这些事务异常问题在生产环境中是必须重点考虑并妥善处理的

代码下载

  • 本节完整的案例代码可以直接从 GitHub 下载对应章节 distributed-transaction-01

版本说明

组件版本说明
JDK11
MySQL5.7.44
Hmily2.1.1
SpringBoot2.3.7.RELEASEHmily 官方并不支持 SpringBoot 3.x
SpringCloudHoxton.SR9

Hmily 版本说明

  • 在 Maven 中央仓库中,Hmily 最新的版本是 2.1.2(2022 年 12 月发布)。特别注意,Maven 中央仓库中 hmily-spring-boot-starter-springcloud 的最新版本是 2.1.1,并没有 2.1.2 版本;如果需要 2.1.2 版本,可以从 这里(不稳定,生产环境慎用) 直接下载,并通过 mvn install 命令安装到本地。

目录结构说明

  • 在本案例中,账户系统的核心模块如下所示:
模块名称端口说明
eureka-server9091Eureka 注册中心
fencai-hmily-money-from9092扣钱服务(出账)
fencai-hmily-money-to9093加钱服务(入账)
  • 在本案例中,账户系统的整体目录结构如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
├── distributed-transaction-01.iml
├── eureka-server
│   ├── eureka-server.iml
│   ├── pom.xml
│   └── src
│   └── main
│   ├── java
│   │   └── com
│   │   └── distributed
│   │   └── transaction
│   │   └── EurekaServerApplication.java
│   └── resources
│   └── application.yml
├── fencai-hmily-money-from
│   ├── fencai-hmily-money-from.iml
│   ├── pom.xml
│   └── src
│   └── main
│   ├── java
│   │   └── com
│   │   └── distributed
│   │   └── transaction
│   │   ├── client
│   │   │   └── AccountClient.java
│   │   ├── controller
│   │   │   └── AccountController.java
│   │   ├── entity
│   │   │   └── Account.java
│   │   ├── mapper
│   │   │   └── AccountMapper.java
│   │   ├── MoneyFromApplication.java
│   │   └── service
│   │   ├── AccountService.java
│   │   └── impl
│   │   └── AccountServiceImpl.java
│   └── resources
│   ├── application.yml
│   ├── hmily.yml
│   └── mapper
│   └── AccountMapper.xml
├── fencai-hmily-money-to
│   ├── fencai-hmily-money-to.iml
│   ├── pom.xml
│   └── src
│   └── main
│   ├── java
│   │   └── com
│   │   └── distributed
│   │   └── transaction
│   │   ├── controller
│   │   │   └── AccountController.java
│   │   ├── entity
│   │   │   └── Account.java
│   │   ├── mapper
│   │   │   └── AccountMapper.java
│   │   ├── MoneyToApplication.java
│   │   └── service
│   │   ├── AccountService.java
│   │   └── impl
│   │   └── AccountServiceImpl.java
│   └── resources
│   ├── application.yml
│   ├── hmily.yml
│   └── mapper
│   └── AccountMapper.xml
└── pom.xml

TCC 模型说明

在本案例中,账户系统使用的 TCC 模型如下图所示:

提示

更多关于账户系统其他 TCC 模型的介绍可参考 这里

整体部署架构

数据库初始化

  • 创建数据库
1
2
3
4
5
-- 创建扣钱数据库
CREATE DATABASE account_from DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;

-- 创建加钱数据库
CREATE DATABASE account_to DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
  • 创建数据库表(account_from.t_account
1
2
3
4
5
6
7
8
9
10
11
12
13
-- 切换数据库
USE account_from;

-- 创建数据库表
CREATE TABLE `t_account` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '物理主键',
`name` varchar(256) NOT NULL DEFAULT '' COMMENT '用户名称',
`balance` decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '账户余额',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

-- 插入数据
INSERT INTO account_from.t_account(id, name, balance) VALUES(1, '张三', 100);
  • 创建数据库表(account_to.t_account
1
2
3
4
5
6
7
8
9
10
11
12
13
-- 切换数据库
USE account_to;

-- 创建数据库表
CREATE TABLE `t_account` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '物理主键',
`name` varchar(256) NOT NULL DEFAULT '' COMMENT '用户名称',
`balance` decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '账户余额',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

-- 插入数据
INSERT INTO account_to.t_account(id, name, balance) VALUES(2, '李四', 100);

Hmily 数据库的初始化

当 Hmily 使用 MySQL 来存储事务日志时,只要项目引入 hmily-spring-boot-starter-springcloud 依赖,并且在 hmily.yml 中添加 MySQL 相关的配置,那么 Hmily 在执行分布式事务时,默认会自动创建相关的数据库(hmily)和数据库表,比如 MySQL 初始化的 SQL 脚本源文件可以查看 GitHub 仓库。值得一提的是,不同版本的 Hmily,其数据库初始化的 SQL 脚本可能有差异。

核心代码

父 Pom 模块

提示

父 Pom 模块主要用于统一管理和定义项目中各类核心依赖的版本号,避免在各个子模块中重复声明版本,从而提升依赖管理的可维护性和一致性。

  • Maven 的 XML 配置内容(pom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<!-- SpringBoot -->
<parent>
<artifactId>spring-boot-starter-parent</artifactId>
<groupId>org.springframework.boot</groupId>
<version>2.1.1.RELEASE</version>
</parent>

<dependencyManagement>
<dependencies>
<!-- SpringCloud -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR9</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.2.0</version>
</dependency>
<!-- Hmily -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>hmily-spring-boot-starter-springcloud</artifactId>
<version>2.1.1</version>
<exclusions>
<exclusion>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
</exclusion>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
</dependencyManagement>

<modules>
<module>eureka-server</module>
<module>fencai-hmily-money-to</module>
<module>fencai-hmily-money-from</module>
</modules>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
注册中心模块
模块配置
  • Maven 的 XML 配置内容(pom.xml
1
2
3
4
5
6
7
<dependencies>
<!-- Eureka Server -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>
  • SpringBoot 的 YML 配置内容(application.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server:
port: 9090

spring:
application:
name: eureka-server

eureka:
instance:
hostname: localhost # Eureka服务端的实例名称
server:
enable-self-preservation: false # 是否开启 Eureka Server 的自我保护机制
client:
register-with-eureka: false # 是否将自己注册到 Eureka Server
fetch-registry: false # 是否从 Eureka Server 拉取注册表信息
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/ # Eureka Server 提供的注册地址
模块代码
  • SpringBoot 的主启动类
1
2
3
4
5
6
7
8
9
@EnableEurekaServer
@SpringBootApplication
public class EurekaServerApplication {

public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class);
}

}
扣钱服务模块
模块配置
  • Maven 的 XML 配置内容(pom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Eureka Client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- OpenFeign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- Hmily -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>hmily-spring-boot-starter-springcloud</artifactId>
</dependency>
</dependencies>
  • MyBatis 的 XML 映射文件内容(AccountMapper.xml
1
2
3
4
5
6
7
<mapper namespace="com.distributed.transaction.mapper.AccountMapper">

<update id="updateBalance">
update `t_account` set balance = balance + #{delta} where id = #{id} and balance + #{delta} >= 0
</update>

</mapper>
  • SpringBoot 的 YML 配置内容(application.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
server:
port: 9091

spring:
application:
name: fencai-hmily-money-from
# 数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.56.112:3307/account_from?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
username: root
password: "123456"

# MyBatis 配置
mybatis:
mapper-locations: classpath:mapper/*.xml # 指定 Mapper XML 文件位置
type-aliases-package: com.distributed.transaction.entity # 指定 Mapper 接口的包路径
configuration:
map-underscore-to-camel-case: true # 开启驼峰命名自动映射
use-generated-keys: true # 获取数据库自增主键值
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印SQL

# Eureka 配置
eureka:
client:
service-url:
defaultZone: http://localhost:9090/eureka
instance:
instance-id: ${spring.application.name}-${server.port} # 自定义服务名称
prefer-ip-address: true # 将IP注册到Eureka Server上,若不配置默认使用机器的主机名

# 日志打印
logging:
level:
root: info
org.dromara.hmily: DEBUG
  • Hmily 的 YML 配置内容(hmily.yml),可以根据实际业务需求,适当调整 Hmily 的配置参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# Hmily 服务端相关配置
hmily:
# Hmily Server 配置
server:
# 配置模式:local 表示本地配置模式
configMode: local
# 当前应用名称(Hmily Server 使用)
appName: fencai-hmily-money-from-sc

# 只有 server.configMode 为 local 时才会读取以下配置
config:
# 当前应用名称(业务侧使用)
appName: fencai-hmily-money-from-sc
# 序列化方式,推荐使用 kryo;支持 hessian、protostuff、jdk;测试表现:kryo > hessian > protostuff > jdk
serializer: kryo
# 上下文传递模式(threadLocal / transmittableThreadLocal)
contextTransmittalMode: threadLocal
# 定时任务线程池最大线程数,高并发场景下可适当调大
scheduledThreadMax: 16
# 恢复任务首次延迟时间(秒)
scheduledRecoveryDelay: 60
# 清理任务执行间隔(秒)
scheduledCleanDelay: 60
# 物理删除任务执行间隔(秒)
scheduledPhyDeletedDelay: 600
# 定时任务初始化延迟时间(秒)
scheduledInitDelay: 30
# 事务恢复延迟时间(秒),强烈建议大于 RPC 调用的超时时间,否则可能误触发补偿;比如设置为:≥ RPC 超时 + 网络抖动余量
recoverDelayTime: 60
# 清理延迟时间(秒)
cleanDelayTime: 180
# 单次处理事务的最大数量限制
limit: 200
# 最大重试次数,默认 10 次,业务服务宕机时定时任务在 retryMax 次内执行 cancel 或 confirm
retryMax: 10
# Disruptor 缓冲区大小,高并发时可调大,建议为 2 的幂次方
bufferSize: 8192
# 消费者线程数
consumerThreads: 16
# 是否启用异步事务日志存储
asyncRepository: true
# 是否自动建表(仅关系型数据库有效)
autoSql: true
# 是否启用物理删除
phyDeleted: true
# 事务数据保留天数
storeDays: 3
# 事务日志存储类型(mysql / postgresql / mongodb / redis / zookeeper / file 等)
repository: mysql

# Hmily 仓库相关配置
repository:
# 数据库相关配置
database:
# JDBC 驱动类名
driverClassName: com.mysql.cj.jdbc.Driver
# 数据库连接 URL
url: jdbc:mysql://192.168.56.112:3307/hmily?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
# 数据库用户名
username: root
# 数据库密码
password: "123456"
# 连接池最大连接数
maxActive: 20
# 连接池最小空闲连接数
minIdle: 10
# 获取连接超时时间(毫秒)
connectionTimeout: 30000
# 连接空闲超时时间(毫秒)
idleTimeout: 600000
# 连接最大生命周期(毫秒)
maxLifetime: 1800000
模块代码
  • 实体类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import java.io.Serializable;
import java.math.BigDecimal;

/**
* 账户信息
*/
public class Account implements Serializable {

/**
* ID
*/
private Long id;

/**
* 用户名称
*/
private String name;

/**
* 账户余额
*/
private BigDecimal balance;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public BigDecimal getBalance() {
return balance;
}

public void setBalance(BigDecimal balance) {
this.balance = balance;
}

}
  • Mapper 接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;

public interface AccountMapper {

/**
* 按增量更新账户余额,并发环境下可避免「丢失更新」问题
*
* @param id 账户ID
* @param delta 余额变动值(正数:增加余额;负数:减少余额)
* @return 影响行数(1:成功;0:账户不存在或更新条件不满足)
*/
int updateBalance(@Param("id") Long id, @Param("delta") BigDecimal delta);

}
  • Feign 客户端(这里必须添加 @Hmily 注解,用于标记参与 TCC 分布式事务的业务方法,声明该方法由 Hmily 框架接管并按 Try / Confirm / Cancel 事务模型执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import org.dromara.hmily.annotation.Hmily;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.math.BigDecimal;

/**
* Feign 客户端(加钱服务)
*/
@FeignClient(value = "fencai-hmily-money-to")
public interface AccountClient {

/**
* 转账操作 <p>
* 必须使用 @Hmily 注解修饰方法 <p>
* 远程调用加钱服务的 try 方法(TCC 的三大方法之一)
*
* @param id 账户ID
* @param transMoney 转账金额(正数)
* @return true 成功,false 失败
*/
@Hmily
@GetMapping("/account/{id}/{transMoney}")
boolean transfer(@PathVariable("id") Long id, @PathVariable("transMoney") BigDecimal transMoney);

}
  • Service 接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.math.BigDecimal;

public interface AccountService {

/**
* 尝试更新账户余额
*
* @param fromId 扣钱的账户ID
* @param delta 余额变动值(正数:增加余额;负数:减少余额)
* @param toId 加钱的账号ID
*/
boolean tryUpdateBalance(Long fromId, BigDecimal delta, Long toId);

/**
* 确认更新账户余额
*
* @param fromId 扣钱的账户ID
* @param delta 余额变动值(正数:增加余额;负数:减少余额)
* @param toId 加钱的账号ID
*/
boolean confirmUpdateBalance(Long fromId, BigDecimal delta, Long toId);

/**
* 取消更新账户余额
*
* @param fromId 扣钱的账户ID
* @param delta 余额变动值(正数:增加余额;负数:减少余额)
* @param toId 加钱的账号ID
*/
boolean cancelUpdateBalance(Long fromId, BigDecimal delta, Long toId);

}
  • Service 实现类(这里必须添加 @HmilyTCC 注解,同时指定 confirmMethodcancelMethod 参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import com.distributed.transaction.client.AccountClient;
import com.distributed.transaction.mapper.AccountMapper;
import com.distributed.transaction.service.AccountService;
import org.dromara.hmily.annotation.HmilyTCC;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;

@Service
public class AccountServiceImpl implements AccountService {

@Resource
private AccountMapper accountMapper;

@Resource
private AccountClient accountClient;

/**
* 添加 @HmilyTCC 注解 <p>
* 注解 @HmilyTCC 必须指定 confirmMethod 和 cancelMethod 参数
*/
@Override
@HmilyTCC(confirmMethod = "confirmUpdateBalance", cancelMethod = "cancelUpdateBalance")
public boolean tryUpdateBalance(Long fromId, BigDecimal delta, Long toId) {
System.out.println("出账 开始");
// 扣钱
accountMapper.updateBalance(fromId, delta);

// 通过 Feign 远程调用加钱服务的 try 方法(TCC 的三大方法之一)
accountClient.transfer(toId, delta.negate());

return Boolean.TRUE;
}

@Override
public boolean confirmUpdateBalance(Long fromId, BigDecimal delta, Long toId) {
System.out.println("出账 确认");
return Boolean.TRUE;
}

@Override
public boolean cancelUpdateBalance(Long fromId, BigDecimal delta, Long toId) {
System.out.println("出账 取消");
// 还钱(任意一个 try 方法执行失败时)
accountMapper.updateBalance(fromId, delta.negate());
return Boolean.TRUE;
}

}
  • Controller 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import com.distributed.transaction.service.AccountService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.math.BigDecimal;

/**
* 账户控制器
*/
@RestController
public class AccountController {

@Resource
private AccountService accountService;

/**
* 转账操作
*
* @param fromId 扣钱的账户ID
* @param transMoney 转账金额(正数)
* @param toId 加钱的账号ID
*/
@GetMapping("/account/{fromId}/{transMoney}/{toId}")
public boolean transfer(@PathVariable("fromId") Long fromId, @PathVariable("transMoney") BigDecimal transMoney, @PathVariable("toId") Long toId) {
// 调用本地的 try 方法(TCC 的三大方法之一)
return accountService.tryUpdateBalance(fromId, transMoney.negate(), toId);
}

}
  • SpringBoot 主启动类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.openfeign.EnableFeignClients;

@EnableEurekaClient
@EnableFeignClients
@MapperScan("com.distributed.transaction.mapper")
@SpringBootApplication(exclude = {MongoAutoConfiguration.class, MongoDataAutoConfiguration.class})
public class MoneyFromApplication {

public static void main(String[] args) {
SpringApplication.run(MoneyFromApplication.class);
}

}
加钱服务模块
模块配置
  • Maven 的 XML 配置内容(pom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Eureka Client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- OpenFeign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- Hmily -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>hmily-spring-boot-starter-springcloud</artifactId>
</dependency>
</dependencies>
  • MyBatis 的 XML 映射文件内容(AccountMapper.xml
1
2
3
4
5
6
7
<mapper namespace="com.distributed.transaction.mapper.AccountMapper">

<update id="updateBalance">
update `t_account` set balance = balance + #{delta} where id = #{id} and balance + #{delta} >= 0
</update>

</mapper>
  • SpringBoot 的 YML 配置内容(application.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
server:
port: 9092

spring:
application:
name: fencai-hmily-money-to
# 数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.56.112:3307/account_to?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
username: root
password: "123456"

# MyBatis 配置
mybatis:
mapper-locations: classpath:mapper/*.xml # 指定 Mapper XML 文件位置
type-aliases-package: com.distributed.transaction.entity # 指定 Mapper 接口的包路径
configuration:
map-underscore-to-camel-case: true # 开启驼峰命名自动映射
use-generated-keys: true # 获取数据库自增主键值
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印SQL

# Eureka 配置
eureka:
client:
service-url:
defaultZone: http://localhost:9090/eureka
instance:
instance-id: ${spring.application.name}-${server.port} # 自定义服务名称
prefer-ip-address: true # 将IP注册到Eureka Server上,若不配置默认使用机器的主机名

# 日志打印
logging:
level:
root: info
org.dromara.hmily: DEBUG
  • Hmily 的 YML 配置内容(hmily.yml),可以根据实际业务需求,适当调整 Hmily 的配置参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# Hmily 服务端相关配置
hmily:
# Hmily Server 配置
server:
# 配置模式:local 表示本地配置模式
configMode: local
# 当前应用名称(Hmily Server 使用)
appName: fencai-hmily-money-to-sc

# 只有 server.configMode 为 local 时才会读取以下配置
config:
# 当前应用名称(业务侧使用)
appName: fencai-hmily-money-to-sc
# 序列化方式,推荐使用 kryo;支持 hessian、protostuff、jdk;测试表现:kryo > hessian > protostuff > jdk
serializer: kryo
# 上下文传递模式(threadLocal / transmittableThreadLocal)
contextTransmittalMode: threadLocal
# 定时任务线程池最大线程数,高并发场景下可适当调大
scheduledThreadMax: 16
# 恢复任务首次延迟时间(秒)
scheduledRecoveryDelay: 60
# 清理任务执行间隔(秒)
scheduledCleanDelay: 60
# 物理删除任务执行间隔(秒)
scheduledPhyDeletedDelay: 600
# 定时任务初始化延迟时间(秒)
scheduledInitDelay: 30
# 事务恢复延迟时间(秒),强烈建议大于 RPC 调用的超时时间,否则可能误触发补偿;比如设置为:≥ RPC 超时 + 网络抖动余量
recoverDelayTime: 60
# 清理延迟时间(秒)
cleanDelayTime: 180
# 单次处理事务的最大数量限制
limit: 200
# 最大重试次数,默认 10 次,业务服务宕机时定时任务在 retryMax 次内执行 cancel 或 confirm
retryMax: 10
# Disruptor 缓冲区大小,高并发时可调大,建议为 2 的幂次方
bufferSize: 8192
# 消费者线程数
consumerThreads: 16
# 是否启用异步事务日志存储
asyncRepository: true
# 是否自动建表(仅关系型数据库有效)
autoSql: true
# 是否启用物理删除
phyDeleted: true
# 事务数据保留天数
storeDays: 3
# 事务日志存储类型(mysql / postgresql / mongodb / redis / zookeeper / file 等)
repository: mysql

# Hmily 仓库相关配置
repository:
# 数据库相关配置
database:
# JDBC 驱动类名
driverClassName: com.mysql.cj.jdbc.Driver
# 数据库连接 URL
url: jdbc:mysql://192.168.56.112:3307/hmily?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
# 数据库用户名
username: root
# 数据库密码
password: "123456"
# 连接池最大连接数
maxActive: 20
# 连接池最小空闲连接数
minIdle: 10
# 获取连接超时时间(毫秒)
connectionTimeout: 30000
# 连接空闲超时时间(毫秒)
idleTimeout: 600000
# 连接最大生命周期(毫秒)
maxLifetime: 1800000
模块代码
  • 实体类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import java.io.Serializable;
import java.math.BigDecimal;

/**
* 账户信息
*/
public class Account implements Serializable {

/**
* ID
*/
private Long id;

/**
* 用户名称
*/
private String name;

/**
* 账户余额
*/
private BigDecimal balance;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public BigDecimal getBalance() {
return balance;
}

public void setBalance(BigDecimal balance) {
this.balance = balance;
}

}
  • Mapper 接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.apache.ibatis.annotations.Param;

import java.math.BigDecimal;

public interface AccountMapper {

/**
* 按增量更新账户余额,并发环境下可避免「丢失更新」问题
*
* @param id 账户ID
* @param delta 余额变动值(正数:增加余额;负数:减少余额)
* @return 影响行数(1:成功;0:账户不存在或更新条件不满足)
*/
int updateBalance(@Param("id") Long id, @Param("delta") BigDecimal delta);

}
  • Service 接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.math.BigDecimal;

public interface AccountService {

/**
* 尝试更新账户余额
*
* @param id 账户ID
* @param delta 余额变动值(正数:增加余额;负数:减少余额)
*/
boolean tryUpdateBalance(Long id, BigDecimal delta);

/**
* 确认更新账户余额
*
* @param id 账户ID
* @param delta 余额变动值(正数:增加余额;负数:减少余额)
*/
boolean confirmUpdateBalance(Long id, BigDecimal delta);

/**
* 取消更新账户余额
*
* @param id 账户ID
* @param delta 余额变动值(正数:增加余额;负数:减少余额)
*/
boolean cancelUpdateBalance(Long id, BigDecimal delta);

}
  • Service 实现类(这里必须添加 @HmilyTCC 注解,同时指定 confirmMethodcancelMethod 参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import com.distributed.transaction.mapper.AccountMapper;
import com.distributed.transaction.service.AccountService;
import org.dromara.hmily.annotation.HmilyTCC;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;

@Service
public class AccountServiceImpl implements AccountService {

@Resource
private AccountMapper accountMapper;

/**
* 添加 @HmilyTCC 注解 <p>
* 注解 @HmilyTCC 必须指定 confirmMethod 和 cancelMethod 参数
*/
@Override
@HmilyTCC(confirmMethod = "confirmUpdateBalance", cancelMethod = "cancelUpdateBalance")
public boolean tryUpdateBalance(Long id, BigDecimal delta) {
System.out.println("入账 开始");
return Boolean.TRUE;
}

@Override
public boolean confirmUpdateBalance(Long id, BigDecimal delta) {
System.out.println("入账 确认");
accountMapper.updateBalance(id, delta);
return Boolean.TRUE;
}

@Override
public boolean cancelUpdateBalance(Long id, BigDecimal delta) {
System.out.println("入账 取消");
return Boolean.TRUE;
}

}
  • Controller 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import com.distributed.transaction.service.AccountService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.math.BigDecimal;

/**
* 账户控制器
*/
@RestController
public class AccountController {

@Resource
private AccountService accountService;

/**
* 转账操作
*
* @param id 账户ID
* @param transMoney 转账金额(正数)
* @return true 成功,false 失败
*/
@GetMapping("/account/{id}/{transMoney}")
public boolean transfer(@PathVariable("id") Long id, @PathVariable("transMoney") BigDecimal transMoney) {
// 调用本地的 try 方法(TCC 的三大方法之一)
return accountService.tryUpdateBalance(id, transMoney);
}

}
  • SpringBoot 主启动类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;

@EnableEurekaClient
@MapperScan("com.distributed.transaction.mapper")
@SpringBootApplication(exclude = {MongoAutoConfiguration.class, MongoDataAutoConfiguration.class})
public class MoneyToApplication {

public static void main(String[] args) {
SpringApplication.run(MoneyToApplication.class);
}

}

代码测试

正常调用测试
  • (1) 依次启动 eureka-serverfencai-hmily-money-fromfencai-hmily-money-to 应用
  • (2) 通过浏览器调用扣钱接口:http://127.0.0.1:9091/account/1/5/2
  • (3) 可以发现各个应用会打印以下日志信息:
    • fencai-hmily-money-from 应用
      1
      2
      出账 开始
      出账 确认
    • fencai-hmily-money-to 应用
      1
      2
      入账 开始
      入账 确认
  • (4) 最后观察数据库的数据是否有发生变化,如果数据有发生变化,则说明 Hmily 正常运行
    • 数据库表 account_from.t_account:账户余额减少了 5 元
    • 数据库表 account_to.t_account:账户余额增加了 5 元
异常调用测试
  • (1) 更改 fencai-hmily-money-from 模块中的 Try 方法,故意抛出运行时异常,验证 Cancel 方法是否会被执行(回滚操作)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /**
    * 添加 @HmilyTCC 注解 <p>
    * 注解 @HmilyTCC 必须指定 confirmMethod 和 cancelMethod 参数
    */
    @Override
    @HmilyTCC(confirmMethod = "confirmUpdateBalance", cancelMethod = "cancelUpdateBalance")
    public boolean tryUpdateBalance(Long fromId, BigDecimal delta, Long toId) {
    System.out.println("出账 开始");
    // 扣钱
    accountMapper.updateBalance(fromId, delta);

    // 通过 Feign 远程调用加钱服务的 try 方法(TCC 的三大方法之一)
    accountClient.transfer(toId, delta.negate());

    // 故意抛出运行时异常,验证 Cancel 方法是否会被执行(回滚操作)
    int num = 10 / 0;

    return Boolean.TRUE;
    }
  • (2) 依次启动 eureka-serverfencai-hmily-money-fromfencai-hmily-money-to 应用
  • (3) 通过浏览器调用扣钱接口:http://127.0.0.1:9091/account/1/5/2
  • (4) 可以发现各个应用会打印以下日志信息:
    • fencai-hmily-money-from 应用
      1
      2
      出账 开始
      出账 取消
    • fencai-hmily-money-to 应用
      1
      2
      入账 开始
      入账 取消
  • (5) 最后观察数据库的数据是否没有发生变化,如果数据没有发生变化,则说明 Hmily 正常运行
    • 数据库表 account_from.t_account:账户余额保持不变
    • 数据库表 account_to.t_account:账户余额保持不变

常见错误

SPI 异常
  • 问题描述:使用 hmily-spring-boot-starter-springcloud2.1.1 版本时,无论是分布式事务发起者,还是分布式事务参与者,第一次执行 TCC 分布式事务之前都会抛出以下异常:
1
org.dromara.hmily.spi.ExtensionLoader : not found service provider for : org.dromara.hmily.core.service.HmilyTransactionHandlerFactory
  • 解决方案:这异常只会在 Hmily 第一次执行 TCC 分布式事务之前才会抛出,不会影响 TCC 事务的真正执行,暂时找不到好的解决办法,建议参考 GitHub Issues

参考资料