支付平台资金强一致实践:基于 Seata TCC+DB 模式的余额扣减与渠道支付落地案例

以下是一个基于 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_tablebranch_tablegmt_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 模式的核心价值

  1. 强一致性:TCC 三阶段严格控制资源预留与释放,结合 DB 存储的事务日志,确保支付过程中余额扣减与渠道支付的原子性。
  2. 数据可靠性:MySQL 持久化存储事务日志,支持故障恢复和长期审计,适合资金相关场景。
  3. 灵活性:TCC 模式允许自定义业务逻辑(如部分金额用余额、部分用渠道),比 AT 模式更适合复杂支付场景。

总结

该案例证明,在 支付、转账等资金敏感场景 中,Seata TCC 模式 + DB 存储是最优选择。通过合理设计 TCC 三阶段逻辑、优化数据库高可用配置、完善监控告警机制,可实现"零资金不一致"和"合规可审计"的核心目标,代价是需要投入更多数据库运维资源(主从集群、备份策略等),但对支付行业而言完全值得。

相关推荐
修一呀2 小时前
[后端快速搭建]基于 Django+DeepSeek API 快速搭建智能问答后端
后端·python·django
哈基米喜欢哈哈哈2 小时前
Spring Boot 3.5 新特性
java·spring boot·后端
当无2 小时前
Mac 使用Docker部署Mysql镜像,并使用DBever客户端连接
后端
野生的午谦2 小时前
PostgreSQL 部署全记录:Ubuntu从安装到故障排查的完整实践
后端
David爱编程2 小时前
可见性问题的真实案例:为什么线程看不到最新的值?
java·后端
00后程序员2 小时前
移动端网页调试实战,iOS WebKit Debug Proxy 的应用与替代方案
后端
whitepure3 小时前
我如何理解与追求整洁代码
java·后端·代码规范
用户8356290780513 小时前
Java高效读取Excel表格数据教程
java·后端
yinke小琪3 小时前
今天解析一下从代码到架构:Java后端开发的"破局"与"新生"
java·后端·架构