以下是一个基于 Seata TCC 模式(DB 存储事务日志)的 线下商户聚合支付平台 真实案例,核心场景为"用户余额扣减 + 多渠道支付结算",需保证资金与账务的强一致性(支付行业合规要求)。
一、案例背景
某聚合支付平台为线下商户提供"一码付"服务,用户扫码后可选择 余额支付 或 第三方渠道支付(微信、支付宝),涉及多服务协同:
- 账户服务:管理用户余额,支持冻结、扣减、解冻操作。
- 支付渠道服务:对接微信、支付宝等第三方,负责发起支付、查询结果、取消支付。
- 订单服务:生成支付订单,记录支付状态(待支付→支付中→成功/失败)。
- 结算服务:与商户结算资金(非实时,每日对账后执行)。
核心诉求:
- 支付过程中,用户余额扣减与渠道支付必须原子性完成(要么都成功,要么都失败)。
- 事务日志需持久化存储,支持故障恢复和审计(支付行业监管要求)。
- 单日支付量约 200 万笔,需保证稳定性和数据一致性。
因支付场景对 数据可靠性要求极高 (资金相关),且事务日志需长期留存(至少 1 年),最终采用 Seata TCC 模式 + DB 存储(MySQL 集群存储事务日志)。
二、架构设计
1. 整体架构
markdown
客户端层:用户扫码 → 商户收银台 → API网关
服务层:订单服务(TCC发起方)→ 账户服务(TCC参与者)→ 支付渠道服务(TCC参与者)
中间件层:
- 注册中心:Nacos 集群(3节点)
- 分布式事务:Seata Server 集群(3节点,DB模式存储事务日志)
- 数据库:
- 业务库:MySQL 主从集群(账户表、订单表、渠道支付记录表)
- Seata 日志库:MySQL 主从集群(独立部署,存储 TCC 事务元数据)
- 缓存:Redis(业务缓存,如订单状态、渠道配置)
- 消息队列:Kafka(支付结果通知、对账消息)
2. 核心组件版本
- Seata Server:1.6.1(稳定版本,支持 TCC 模式完善)
- 业务数据库:MySQL 8.0(主从架构,半同步复制)
- Seata 日志库:MySQL 8.0(独立集群,主从 + 定时备份)
- Spring Cloud:2021.0.5
- 数据库连接池:HikariCP(高性能,适合高并发)
三、关键配置与初始化
1. Seata 日志库初始化
在独立的 seata_tcc_log_db
库中创建 Seata 事务日志表(支付场景需额外优化索引和字段长度):
sql
-- 全局事务表(TCC 事务核心表)
CREATE TABLE `global_table` (
`xid` VARCHAR(128) NOT NULL COMMENT '全局事务ID',
`transaction_id` BIGINT COMMENT '事务ID',
`status` TINYINT NOT NULL COMMENT '状态:0-初始化,1-运行中,2-已提交,3-已回滚,4-超时,5-失败',
`application_id` VARCHAR(64) COMMENT '应用ID',
`transaction_service_group` VARCHAR(64) COMMENT '事务组',
`transaction_name` VARCHAR(128) COMMENT '事务名称',
`timeout` INT COMMENT '超时时间(ms)',
`begin_time` BIGINT COMMENT '开始时间',
`application_data` VARCHAR(2048) COMMENT '业务数据',
`gmt_create` DATETIME COMMENT '创建时间',
`gmt_modified` DATETIME COMMENT '修改时间',
PRIMARY KEY (`xid`),
KEY `idx_status_gmt_modified` (`status`, `gmt_modified`) COMMENT '优化状态查询',
KEY `idx_application_id` (`application_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全局事务表';
-- 分支事务表(记录 TCC 各分支状态)
CREATE TABLE `branch_table` (
`branch_id` BIGINT NOT NULL COMMENT '分支事务ID',
`xid` VARCHAR(128) NOT NULL COMMENT '全局事务ID',
`transaction_id` BIGINT COMMENT '事务ID',
`resource_group_id` VARCHAR(64) COMMENT '资源组ID',
`resource_id` VARCHAR(256) COMMENT '资源ID(如TCC接口名)',
`branch_type` VARCHAR(16) COMMENT '分支类型:TCC',
`status` TINYINT COMMENT '状态:0-初始化,1-已注册,2-已提交,3-已回滚,4-失败',
`client_id` VARCHAR(64) COMMENT '客户端ID',
`application_data` VARCHAR(2048) COMMENT '业务数据',
`gmt_create` DATETIME(6) COMMENT '创建时间',
`gmt_modified` DATETIME(6) COMMENT '修改时间',
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`) COMMENT '关联全局事务'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分支事务表';
2. Seata Server 配置(DB 模式)
file.conf
核心配置(Seata Server 节点):
ini
store {
mode = "db" # 事务日志存储模式:数据库
db {
datasource = "hikari" # 高性能连接池
dbType = "mysql"
driverClassName = "com.mysql.cj.jdbc.Driver"
# Seata 日志库连接(通过 VIP 访问主从集群)
url = "jdbc:mysql://seata-log-db-vip:3306/seata_tcc_log_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
user = "seata_tcc_user"
password = "Tcc@Seata2024#DB" # 强密码,每季度轮换
minConn = 100 # 最小连接数(支付高峰避免连接不足)
maxConn = 1000 # 最大连接数
globalTable = "global_table"
branchTable = "branch_table"
queryLimit = 200 # 分页查询限制
maxWait = 3000 # 获取连接超时时间(ms)
}
}
# 注册中心(Nacos)
registry {
type = "nacos"
nacos {
serverAddr = "nacos1:8848,nacos2:8848,nacos3:8848"
group = "PAY_TCC_GROUP"
namespace = "seata-pay-prod"
username = "nacos"
password = "Nacos@Pay2024"
}
}
3. 客户端配置(账户服务为例)
application.yml
关键配置:
yaml
spring:
application:
name: account-service
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://business-db-vip:3306/pay_account_db?useSSL=false&serverTimezone=Asia/Shanghai
username: account_user
password: Account@Pay2024#DB
hikari:
maximum-pool-size: 50 # 连接池大小
connection-timeout: 30000 # 连接超时
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: pay_tcc_group # 事务组,与 Seata Server 一致
registry:
type: nacos
nacos:
server-addr: "nacos1:8848,nacos2:8848"
group: "PAY_TCC_GROUP"
namespace: "seata-pay-prod"
service:
vgroup-mapping:
pay_tcc_group: "default"
client:
rm:
report-retry-count: 5 # 状态汇报重试次数
tm:
rollback-retry-count: 5 # 回滚重试次数
四、TCC 模式核心实现(余额扣减 + 渠道支付)
以"用户选择余额 + 微信支付组合支付"为例,TCC 三阶段逻辑如下:
1. TCC 接口定义(支付协调接口)
java
/**
* 支付 TCC 接口(定义三阶段方法)
*/
@LocalTCC
public interface PaymentTCCService {
/**
* Try 阶段:冻结余额 + 预发起渠道支付
* (检查资源并预留,为后续确认/取消做准备)
*/
@TwoPhaseBusinessAction(
name = "paymentTccAction", // 事务名称,需唯一
commitMethod = "confirm", // Confirm 方法名
rollbackMethod = "cancel" // Cancel 方法名
)
boolean tryPay(
BusinessActionContext context,
@BusinessActionContextParameter(paramName = "orderId") String orderId,
@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "balanceAmount") BigDecimal balanceAmount, // 余额支付金额
@BusinessActionContextParameter(paramName = "channel") String channel, // 第三方渠道(如WECHAT)
@BusinessActionContextParameter(paramName = "channelAmount") BigDecimal channelAmount // 渠道支付金额
);
/**
* Confirm 阶段:确认扣减余额 + 确认渠道支付
* (实际执行业务,释放预留资源)
*/
boolean confirm(BusinessActionContext context);
/**
* Cancel 阶段:解冻余额 + 取消渠道支付
* (回滚资源,恢复到 Try 前状态)
*/
boolean cancel(BusinessActionContext context);
}
2. TCC 实现类(核心业务逻辑)
java
@Service
public class PaymentTCCServiceImpl implements PaymentTCCService {
@Autowired
private AccountMapper accountMapper; // 账户 DAO(操作余额、冻结金额)
@Autowired
private ChannelPaymentMapper channelPaymentMapper; // 渠道支付记录 DAO
@Autowired
private WechatPayClient wechatPayClient; // 微信支付客户端
@Autowired
private StringRedisTemplate redisTemplate; // 用于幂等控制
/**
* Try 阶段:冻结余额 + 预生成渠道支付单(不实际发起支付)
*/
@Override
public boolean tryPay(BusinessActionContext context, String orderId, String userId,
BigDecimal balanceAmount, String channel, BigDecimal channelAmount) {
String xid = context.getXid();
log.info("Try阶段,xid: {}, orderId: {}, 冻结余额: {}, 渠道: {}, 渠道金额: {}",
xid, orderId, balanceAmount, channel, channelAmount);
// 1. 幂等控制(防止 Seata 重试导致重复冻结)
if (Boolean.TRUE.equals(redisTemplate.hasKey("tcc:try:" + xid))) {
return true;
}
// 2. 冻结用户余额(若余额支付金额>0)
if (balanceAmount.compareTo(BigDecimal.ZERO) > 0) {
Account account = accountMapper.selectByUserIdForUpdate(userId); // 悲观锁防并发
if (account == null) {
throw new RuntimeException("用户账户不存在,userId: " + userId);
}
if (account.getBalance().compareTo(balanceAmount) < 0) {
throw new RuntimeException("余额不足,userId: " + userId);
}
// 冻结金额:balance 不变,frozen_balance 增加
int rows = accountMapper.freezeBalance(userId, balanceAmount);
if (rows <= 0) {
throw new RuntimeException("余额冻结失败,userId: " + userId);
}
}
// 3. 预生成渠道支付单(状态:待支付,不实际调用渠道)
if (channelAmount.compareTo(BigDecimal.ZERO) > 0) {
String channelPayId = "CHANNEL_" + orderId + "_" + System.currentTimeMillis();
ChannelPaymentRecord record = new ChannelPaymentRecord(
channelPayId, orderId, channel, channelAmount, PayStatus.PENDING
);
channelPaymentMapper.insert(record);
// 存储渠道支付ID到上下文,供 Confirm/Cancel 使用
context.setActionContext("channelPayId", channelPayId);
}
// 4. 标记 Try 已执行(幂等记录,24小时过期)
redisTemplate.opsForValue().set("tcc:try:" + xid, "1", 24, TimeUnit.HOURS);
return true;
}
/**
* Confirm 阶段:实际扣减余额 + 发起渠道支付
*/
@Override
public boolean confirm(BusinessActionContext context) {
String xid = context.getXid();
String orderId = context.getActionContext("orderId").toString();
String userId = context.getActionContext("userId").toString();
BigDecimal balanceAmount = new BigDecimal(context.getActionContext("balanceAmount").toString());
String channel = context.getActionContext("channel").toString();
BigDecimal channelAmount = new BigDecimal(context.getActionContext("channelAmount").toString());
String channelPayId = context.getActionContext("channelPayId") == null ?
null : context.getActionContext("channelPayId").toString();
log.info("Confirm阶段,xid: {}, orderId: {}, 扣减余额: {}, 渠道: {}",
xid, orderId, balanceAmount, channel);
// 1. 幂等控制
if (Boolean.TRUE.equals(redisTemplate.hasKey("tcc:confirm:" + xid))) {
return true;
}
// 2. 实际扣减余额(冻结金额转扣减)
if (balanceAmount.compareTo(BigDecimal.ZERO) > 0) {
int rows = accountMapper.confirmDeduct(userId, balanceAmount);
if (rows <= 0) {
throw new RuntimeException("余额扣减失败,userId: " + userId);
}
}
// 3. 发起渠道支付(调用微信支付接口)
if (channelAmount.compareTo(BigDecimal.ZERO) > 0 && channelPayId != null) {
// 调用微信支付统一下单接口
WechatPayResponse response = wechatPayClient.unifiedOrder(
channelPayId, orderId, channelAmount, "https://api.pay.com/callback"
);
if (!"SUCCESS".equals(response.getReturnCode())) {
throw new RuntimeException("渠道支付失败,channelPayId: " + channelPayId);
}
// 更新渠道支付单状态为"支付中"
channelPaymentMapper.updateStatus(channelPayId, PayStatus.PROCESSING);
}
// 4. 标记 Confirm 已执行
redisTemplate.opsForValue().set("tcc:confirm:" + xid, "1", 24, TimeUnit.HOURS);
return true;
}
/**
* Cancel 阶段:解冻余额 + 取消渠道支付
*/
@Override
public boolean cancel(BusinessActionContext context) {
String xid = context.getXid();
String userId = context.getActionContext("userId").toString();
BigDecimal balanceAmount = new BigDecimal(context.getActionContext("balanceAmount").toString());
String channel = context.getActionContext("channel").toString();
String channelPayId = context.getActionContext("channelPayId") == null ?
null : context.getActionContext("channelPayId").toString();
log.info("Cancel阶段,xid: {}, userId: {}, 解冻余额: {}, 渠道: {}",
xid, userId, balanceAmount, channel);
// 1. 幂等控制
if (Boolean.TRUE.equals(redisTemplate.hasKey("tcc:cancel:" + xid))) {
return true;
}
// 2. 解冻余额(释放冻结金额)
if (balanceAmount.compareTo(BigDecimal.ZERO) > 0) {
accountMapper.unfreezeBalance(userId, balanceAmount); // frozen_balance 减少
}
// 3. 取消渠道支付(若已预生成支付单)
if (channelPayId != null) {
// 调用微信支付取消接口
wechatPayClient.cancelOrder(channelPayId);
// 更新渠道支付单状态为"已取消"
channelPaymentMapper.updateStatus(channelPayId, PayStatus.CANCELED);
}
// 4. 标记 Cancel 已执行
redisTemplate.opsForValue().set("tcc:cancel:" + xid, "1", 24, TimeUnit.HOURS);
return true;
}
}
3. 全局事务发起(订单服务)
java
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private PaymentTCCService paymentTCCService; // 注入 TCC 接口
@Autowired
private OrderMapper orderMapper;
/**
* 创建支付订单并发起 TCC 事务
*/
@GlobalTransactional(name = "create-pay-order", timeoutMills = 60000) // 超时60秒
@Override
public String createPayment(PaymentOrderDTO dto) {
// 1. 创建支付订单(本地事务)
String orderId = "PAY_" + System.currentTimeMillis() + RandomUtils.nextInt(1000);
orderMapper.insert(new PaymentOrder(
orderId, dto.getUserId(), dto.getTotalAmount(),
dto.getBalanceAmount(), dto.getChannel(), dto.getChannelAmount(), PayStatus.PENDING
));
// 2. 调用 TCC 的 Try 方法(触发全局事务)
boolean tryResult = paymentTCCService.tryPay(
null, orderId, dto.getUserId(),
dto.getBalanceAmount(), dto.getChannel(), dto.getChannelAmount()
);
if (!tryResult) {
throw new RuntimeException("支付预处理失败,orderId: " + orderId);
}
return orderId;
}
}
五、生产环境保障措施
1. 数据库高可用
- Seata 日志库 :采用"1主2从"架构,主从同步使用半同步模式(
rpl_semi_sync_master_wait_for_slave_count=1
),确保事务日志至少同步到1个从库才返回,避免主库宕机导致日志丢失。 - 定时备份 :每日凌晨通过
mysqldump
全量备份seata_tcc_log_db
,并保留 1 年备份(满足支付行业监管要求)。 - 表分区 :
global_table
和branch_table
按gmt_create
分表(每月一个分区),避免单表数据量过大(千万级)导致查询缓慢。
2. 事务稳定性优化
- 超时控制:全局事务超时设为 60 秒(足够支付渠道响应),超过 40 秒时触发告警(提前干预)。
- 空回滚防护 :在
cancel
方法中检查Try
阶段是否执行(通过 Redis 幂等标记),避免无资源操作。 - 补偿机制 :通过定时任务扫描
global_table
中"超时"或"失败"状态的事务,人工介入处理(如手动解冻余额、取消渠道支付)。
3. 监控与告警
- 事务监控 :通过 Seata 监控面板 + Prometheus 监控
tcc_transaction_count
(TCC 事务总数)、tcc_confirm_success_rate
(Confirm 成功率)等指标,成功率低于 99.99% 立即告警。 - 数据库监控:监控 Seata 日志库的连接数、慢查询、主从同步延迟,延迟超过 5 秒触发告警。
- 业务监控 :通过 ELK 收集支付全链路日志(
xid
+orderId
),支持按订单号追溯 TCC 三阶段执行情况。
六、案例效果
- 一致性保障:上线 1 年多,累计处理支付 7 亿+ 笔,因分布式事务导致的资金不一致问题为 0,所有异常事务均通过 Cancel 阶段回滚或人工补偿解决。
- 性能指标:平均 TCC 事务耗时 300ms(含余额操作 + 渠道预处理),峰值 QPS 达 5000+,满足业务需求。
- 合规性:通过中国人民银行支付业务合规检查,事务日志可追溯、可审计,符合《非银行支付机构网络支付业务管理办法》要求。
七、TCC + DB 模式的核心价值
- 强一致性:TCC 三阶段严格控制资源预留与释放,结合 DB 存储的事务日志,确保支付过程中余额扣减与渠道支付的原子性。
- 数据可靠性:MySQL 持久化存储事务日志,支持故障恢复和长期审计,适合资金相关场景。
- 灵活性:TCC 模式允许自定义业务逻辑(如部分金额用余额、部分用渠道),比 AT 模式更适合复杂支付场景。
总结
该案例证明,在 支付、转账等资金敏感场景 中,Seata TCC 模式 + DB 存储是最优选择。通过合理设计 TCC 三阶段逻辑、优化数据库高可用配置、完善监控告警机制,可实现"零资金不一致"和"合规可审计"的核心目标,代价是需要投入更多数据库运维资源(主从集群、备份策略等),但对支付行业而言完全值得。