分布式事务实践:从问题到 Seata 解决方案
写在前面
去年我在做一个电商系统重构,把原来的单体应用拆成了订单服务、库存服务、支付服务、积分服务。拆分完成后,遇到了一个头疼的问题:用户下单时,需要同时扣库存、创建订单、扣积分,如果其中一步失败了怎么办?
这就是分布式事务要解决的问题。这篇文章我会结合 Seata 这个框架,深入聊聊我们在实践中是怎么处理这类问题的,包括原理分析、实战踩坑、性能优化、生产实践等方方面面。
问题从哪来?
一个典型的场景
假设你在做一个电商系统,用户下单的流程是这样的:
用户下单 ↓ 1. 库存服务:扣减库存 ↓ 2. 订单服务:创建订单 ↓ 3. 积分服务:扣减积分看起来很简单,但如果第 2 步创建订单成功了,第 3 步扣积分失败了,这时候订单已经存在了,但积分没扣。用户可能会觉得自己占了便宜,但对我们来说就是数据不一致。
在单体应用时代,这个问题不存在。因为所有的操作都在同一个数据库里,可以用数据库的本地事务保证:
@TransactionalpublicvoidcreateOrder(OrderRequestrequest){// 扣库存inventoryService.reduceStock(request.getProductId());// 创建订单orderService.create(request);// 扣积分pointService.deduct(request.getUserId());}只要加了@Transactional,这三步要么全部成功,要么全部失败回滚。
但在微服务架构下,这三个服务各自有独立的数据库。库存服务操作库存库,订单服务操作订单库,积分服务操作积分库。这时候本地事务就无能为力了,因为事务的范围只限于单个数据库。
这就是为什么需要分布式事务。
常见的解决方案
两阶段提交(2PC)
这是最经典的分布式事务协议。它的核心思想是引入一个协调者(Coordinator),协调者负责询问所有参与者(Participant)是否准备好提交,如果所有人都说准备好了,协调者再通知大家提交。
详细流程
阶段一:准备阶段(Prepare Phase)
协调者 参与者1 参与者2 | | | |----- prepare request ----------->| | | |---- 执行事务但不提交 -------->| | |---- 锁定资源,写日志 -------->| | | | |<---- YES (ready to commit) ------| | | | | | prepare request | |-------------------------------------------------->| | | | | | |---- 执行事务 | | |---- 锁定资源 | | | | |<------------------------------------------------- YES |阶段二:提交阶段(Commit Phase)
如果所有参与者都回复 YES:
协调者 参与者1 参与者2 | | | |----- commit request ------------>| | | |---- 提交事务,释放锁 -------->| | | | |<---- ACK (committed) ------------| | | | | | commit request | |-------------------------------------------------->| | | |---- 提交事务 | | |---- 释放锁 | | | | |<------------------------------------------------- ACK |如果有任何一个参与者回复 NO:
协调者 参与者1 参与者2 | | | |----- rollback request ---------->| | | |---- 回滚事务,释放锁 -------->| | | | |<---- ACK (rolled back) ----------| |2PC 的问题
同步阻塞:在阶段一之后,参与者的资源会被锁定,如果协调者挂了,所有参与者都会一直等待,直到协调者恢复。这在高并发场景下是不可接受的。
单点故障:协调者是单点,如果协调者挂了,事务就无法完成。
数据不一致的可能性:如果协调者在发送 commit 请求后挂了,部分参与者可能已经提交,部分还没有,导致数据不一致。
性能问题:需要两轮网络通信,延迟较高。
三阶段提交(3PC)的改进
3PC 在 2PC 的基础上增加了超时机制和预提交阶段:
- 阶段一:CanCommit- 协调者询问参与者是否可以提交,参与者不锁定资源
- 阶段二:PreCommit- 协调者发送预提交请求,参与者锁定资源,执行事务
- 阶段三:DoCommit- 协调者发送提交请求,参与者提交或回滚
3PC 通过超时机制减少了阻塞时间,但仍然无法彻底解决数据一致性问题,而且实现更复杂,实际应用较少。
TCC 模式
TCC 是 Try-Confirm-Cancel 的缩写。它要求业务代码实现三个方法:
- Try:尝试执行,预留资源。比如扣库存,不是直接扣,而是先冻结。
- Confirm:确认执行,提交操作。如果 Try 成功,Confirm 就真正扣减库存。
- Cancel:取消执行,释放资源。如果 Try 失败或者需要回滚,Cancel 就释放冻结的库存。
TCC 的执行流程
TM (事务管理器) | |--- 开启全局事务 | |--- 调用 Try 阶段 | | | |--- 服务A.try() -> 预留资源A | |--- 服务B.try() -> 预留资源B | |--- 服务C.try() -> 预留资源C | |--- 判断结果 | | | |--- 全部成功 -> 调用 Confirm | | |--- 服务A.confirm() -> 真正扣减资源A | | |--- 服务B.confirm() -> 真正扣减资源B | | |--- 服务C.confirm() -> 真正扣减资源C | | | |--- 任何失败 -> 调用 Cancel | |--- 服务C.cancel() -> 释放资源C | |--- 服务B.cancel() -> 释放资源B | |--- 服务A.cancel() -> 释放资源ATCC 的设计原则
TCC 模式虽然灵活,但实现起来有严格的要求:
- 幂等性:Confirm 和 Cancel 必须保证幂等,因为网络重试可能导致重复调用
- 空回滚:如果 Try 根本没执行(比如网络超时),Cancel 也要能正常执行
- 防悬挂:如果 Cancel 比 Try 先执行,Try 执行时要判断是否已经被 Cancel
举个例子,扣库存的 TCC 实现需要注意这些问题:
@ServicepublicclassInventoryTccServiceImplimplementsInventoryTccService{@AutowiredprivateInventoryMapperinventoryMapper;@AutowiredprivateTccActionMappertccActionMapper;// 记录 TCC 操作状态@Override@TransactionalpublicbooleantryReduceStock(LongproductId,Integerquantity){// 1. 检查是否已经被 Cancel(防悬挂)TccActionaction=tccActionMapper.selectByXidAndAction(RootContext.getXID(),"reduceStock",productId);if(action!=null&&action.getStatus()==TccActionStatus.CANCELLED){// 已经被 Cancel,直接返回 falsereturnfalse;}// 2. 冻结库存Inventoryinventory=inventoryMapper.selectByProductId(productId);if(inventory.getStock()<quantity){thrownewRuntimeException("库存不足");}inventory.setFrozenStock(inventory.getFrozenStock()+quantity);inventoryMapper.updateById(inventory);// 3. 记录 TCC 操作TccActiontccAction=newTccAction();tccAction.setXid(RootContext.getXID());tccAction.setAction("reduceStock");tccAction.setProductId(productId);tccAction.setQuantity(quantity);tccAction.setStatus(TccActionStatus.TRYING);tccActionMapper.insert(tccAction);returntrue;}@Override@Transactionalpublicbooleanconfirm(BusinessActionContextcontext){LongproductId=(Long)context.getActionContext("productId");Integerquantity=(Integer)context.getActionContext("quantity");Stringxid=context.getXid();// 幂等性检查TccActionaction=tccActionMapper.selectByXidAndAction(xid,"reduceStock",productId);if(action==null||action.getStatus()==TccActionStatus.CONFIRMED){// 已经确认过了,直接返回(幂等)returntrue;}// 真正扣减库存Inventoryinventory=inventoryMapper.selectByProductId(productId);inventory.setStock(inventory.getStock()-quantity);inventory.setFrozenStock(inventory.getFrozenStock()-quantity);inventoryMapper.updateById(inventory);// 更新状态action.setStatus(TccActionStatus.CONFIRMED);tccActionMapper.updateById(action);returntrue;}@Override@Transactionalpublicbooleancancel(BusinessActionContextcontext){LongproductId=(Long)context.getActionContext("productId");Integerquantity=(Integer)context.getActionContext("quantity");Stringxid=context.getXid();// 空回滚检查TccActionaction=tccActionMapper.selectByXidAndAction(xid,"reduceStock",productId);if(action==null){// Try 没执行,记录空回滚标记action=newTccAction();action.setXid(xid);action.setAction("reduceStock");action.setProductId(productId);action.setQuantity(quantity);action.setStatus(TccActionStatus.CANCELLED);tccActionMapper.insert(action);returntrue;// 空回滚,直接返回}// 幂等性检查if(action.getStatus()==TccActionStatus.CANCELLED){returntrue;// 已经回滚过了}// 释放冻结的库存Inventoryinventory=inventoryMapper.selectByProductId(productId);inventory.setFrozenStock(inventory.getFrozenStock()-quantity);inventoryMapper.updateById(inventory);// 更新状态action.setStatus(TccActionStatus.CANCELLED);tccActionMapper.updateById(action);returntrue;}}这样做的好处是业务可控性强,不需要数据库支持,性能也比较好。但缺点也很明显:需要改造大量的业务代码,开发成本高,而且容易出错。
本地消息表(Transactional Outbox)
这个方案的思路是:既然跨服务的事务不好搞,那就用消息队列来保证最终一致性。
基本流程
订单服务 | |--- 开启本地事务 | | | |--- 1. 创建订单(写入 order 表) | |--- 2. 插入消息(写入 message 表) | | |--- 提交事务(保证订单和消息的原子性) | |--- 定时任务扫描 message 表 | | | |--- 读取未发送消息 | |--- 发送到消息队列 | |--- 更新消息状态为"已发送" | 消息队列 | |--- 库存服务消费消息 -> 扣库存 |--- 积分服务消费消息 -> 扣积分完整的实现示例
首先,创建消息表:
CREATETABLEoutbox_message(idBIGINTPRIMARYKEYAUTO_INCREMENT,aggregate_idVARCHAR(100)NOTNULLCOMMENT'聚合ID,如订单ID',event_typeVARCHAR(50)NOTNULLCOMMENT'事件类型',payloadTEXTNOTNULLCOMMENT'消息内容(JSON)',statusTINYINTNOTNULLDEFAULT0COMMENT'0-待发送,1-已发送,2-发送失败',retry_countINTNOTNULLDEFAULT0COMMENT'重试次数',created_atDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,updated_atDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,INDEXidx_status(status,created_at))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;消息实体和 Mapper:
@Data@TableName("outbox_message")publicclassOutboxMessage{privateLongid;privateStringaggregateId;privateStringeventType;privateStringpayload;privateIntegerstatus;privateIntegerretryCount;privateLocalDateTimecreatedAt;privateLocalDateTimeupdatedAt;}@MapperpublicinterfaceOutboxMessageMapperextendsBaseMapper<OutboxMessage>{}订单服务代码:
@Service@Slf4jpublicclassOrderServiceImplimplementsOrderService{@AutowiredprivateOrderMapperorderMapper;@AutowiredprivateOutboxMessageMapperoutboxMessageMapper;@AutowiredprivateRabbitTemplaterabbitTemplate;@Transactional(rollbackFor=Exception.class)@OverridepublicvoidcreateOrder(OrderRequestrequest){// 1. 创建订单Orderorder=newOrder();order.setUserId(request.getUserId());order.setProductId(request.getProductId());order.setQuantity(request.getQuantity());order.setStatus(OrderStatus.CREATED);orderMapper.insert(order);// 2. 在同一事务中插入消息OutboxMessageinventoryMessage=newOutboxMessage();inventoryMessage.setAggregateId(order.getId().toString());inventoryMessage.setEventType("INVENTORY_REDUCE");inventoryMessage.setPayload(JSON.toJSONString(newInventoryReduceEvent(order.getProductId(),order.getQuantity())));inventoryMessage.setStatus(0);outboxMessageMapper.insert(inventoryMessage);OutboxMessagepointMessage=newOutboxMessage();pointMessage.setAggregateId(order.getId().toString());pointMessage.setEventType("POINT_DEDUCT");pointMessage.setPayload(JSON.toJSONString(newPointDeductEvent(order.getUserId(),order.getAmount())));pointMessage.setStatus(0);outboxMessageMapper.insert(pointMessage);// 事务提交后,订单和消息都已持久化}}定时任务扫描并发送消息:
@Component@Slf4jpublicclassOutboxMessageSender{@AutowiredprivateOutboxMessageMapperoutboxMessageMapper;@AutowiredprivateRabbitTemplaterabbitTemplate;privatestaticfinalintMAX_RETRY=3;privatestaticfinalintBATCH_SIZE=100;@Scheduled(fixedDelay=1000)// 每秒执行一次publicvoidsendPendingMessages(){// 查询待发送的消息List<OutboxMessage>messages=outboxMessageMapper.selectList(newLambdaQueryWrapper<OutboxMessage>().eq(OutboxMessage::getStatus,0).lt(OutboxMessage::getRetryCount,MAX_RETRY).orderByAsc(OutboxMessage::getCreatedAt).last("LIMIT "+BATCH_SIZE));for(OutboxMessagemessage:messages){try{// 发送到消息队列rabbitTemplate.convertAndSend(getExchange(message.getEventType()),getRoutingKey(message.getEventType()),message.getPayload());// 更新状态为已发送message.setStatus(1);outboxMessageMapper.updateById(message);log.info("消息发送成功: id={}, eventType={}",message.getId(),message.getEventType());}catch(Exceptione){log.error("消息发送失败: id={}, eventType={}",message.getId(),message.getEventType(),e);// 增加重试次数message.setRetryCount(message.getRetryCount()+1);if(message.getRetryCount()>=MAX_RETRY){message.setStatus(2);// 标记为发送失败}outboxMessageMapper.updateById(message);}}}privateStringgetExchange(StringeventType){// 根据事件类型返回对应的 Exchangeswitch(eventType){case"INVENTORY_REDUCE":return"inventory.exchange";case"POINT_DEDUCT":return"point.exchange";default:return"default.exchange";}}privateStringgetRoutingKey(StringeventType){// 根据事件类型返回对应的 RoutingKeyreturneventType.toLowerCase().replace("_",".");}}库存服务消费消息:
@Component@Slf4jpublicclassInventoryEventConsumer{@AutowiredprivateInventoryServiceinventoryService;@RabbitListener(queues="inventory.reduce.queue")publicvoidhandleInventoryReduce(Stringpayload){try{InventoryReduceEventevent=JSON.parseObject(payload,InventoryReduceEvent.class);// 幂等性检查:检查订单是否已经处理过if(isProcessed(event.getOrderId())){log.warn("订单已处理,跳过: orderId={}",event.getOrderId());return;}// 扣减库存inventoryService.reduceStock(event.getProductId(),event.getQuantity());// 记录处理结果(用于幂等性检查)markAsProcessed(event.getOrderId());log.info("库存扣减成功: productId={}, quantity={}",event.getProductId(),event.getQuantity());}catch(Exceptione){log.error("库存扣减失败",e);thrownewAmqpRejectAndDontRequeueException("处理失败",e);}}}本地消息表的优缺点
优点:
- 实现相对简单
- 保证了消息的可靠投递(消息和业务数据在同一事务中)
- 可以通过重试机制保证最终一致性
缺点:
- 只能保证最终一致性,不能保证强一致性
- 需要额外的定时任务和消息表,增加系统复杂度
- 消息发送有延迟(取决于定时任务的执行频率)
- 需要处理幂等性问题
改进方案:CDC + Outbox
为了减少延迟,可以使用 CDC(Change Data Capture)技术,通过监听数据库的 binlog 来实时捕获消息,而不需要定时扫描。比如使用 Canal 或者 Debezium。
但这个方案有个问题:是"最终一致",不是"强一致"。从订单创建到库存扣减完成,中间可能有延迟。对于实时性要求高的场景,可能不太合适。
Seata 是什么?
Seata(Simple Extensible Autonomous Transaction Architecture)是阿里巴巴开源的分布式事务解决方案。它支持多种事务模式,其中 AT 模式对业务代码侵入最小,也是最常用的。
Seata 的架构
Seata 有三个核心角色:
- TC(Transaction Coordinator):事务协调器,独立部署的服务,负责维护全局事务的状态,协调分支事务的提交或回滚
- TM(Transaction Manager):事务管理器,通常就是我们的业务代码,负责开启、提交或回滚全局事务
- RM(Resource Manager):资源管理器,管理分支事务,负责和 TC 通信,报告分支事务的状态,并执行 TC 的指令(提交或回滚)
┌─────────────┐ │ TC │ (事务协调器) │ (Server) │ └──────┬──────┘ │ ┌──────────────┼──────────────┐ │ │ │ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ TM │ │ RM │ │ RM │ │(订单服务)│ │(库存服务)│ │(积分服务)│ └─────────┘ └─────────┘ └─────────┘AT 模式的详细工作原理
1. 全局事务的开启
当我们在方法上加@GlobalTransactional时,Seata 的拦截器会:
// Seata 拦截器的简化实现publicclassGlobalTransactionalInterceptorimplementsMethodInterceptor{@OverridepublicObjectinvoke(MethodInvocationinvocation)throwsThrowable{// 1. 开启全局事务GlobalTransactiontx=GlobalTransactionContext.getCurrentOrCreate();tx.begin(timeout,name);// 2. 将 XID 绑定到当前线程Stringxid=tx.getXid();RootContext.bind(xid);try{// 3. 执行业务方法Objectresult=invocation.proceed();// 4. 提交全局事务tx.commit();returnresult;}catch(Throwablee){// 5. 回滚全局事务tx.rollback();throwe;}finally{// 6. 清理上下文RootContext.unbind();}}}全局事务开启后,会生成一个全局唯一的 XID(Transaction ID),格式类似:192.168.1.100:8091:2023120112345678
2. XID 的传递机制
XID 需要在服务间传递,这样 TC 才能知道哪些分支事务属于同一个全局事务。Seata 通过拦截器自动处理:
Feign 调用时的 XID 传递:
// Seata 的 Feign 拦截器(简化版)publicclassSeataFeignRequestInterceptorimplementsRequestInterceptor{@Overridepublicvoidapply(RequestTemplatetemplate){// 获取当前线程的 XIDStringxid=RootContext.getXID();if(StringUtils.isNotBlank(xid)){// 将 XID 添加到请求头template.header(RootContext.KEY_XID,xid);}}}Dubbo 调用时的 XID 传递:
// Seata 的 Dubbo 过滤器(简化版)publicclassTransactionPropagationFilterimplementsFilter{@OverridepublicResultinvoke(Invoker<?>invoker,Invocationinvocation){// 从 RPC 上下文中获取 XIDStringxid=invocation.getAttachment(RootContext.KEY_XID);if(StringUtils.isNotBlank(xid)){// 绑定到当前线程RootContext.bind(xid);}try{returninvoker.invoke(invocation);}finally{// 清理上下文RootContext.unbind();}}}3. 分支事务的注册
当 RM 执行 SQL 时,Seata 的ConnectionProxy会拦截:
// ConnectionProxy 的简化实现publicclassConnectionProxyimplementsConnection{@OverridepublicPreparedStatementprepareStatement(Stringsql){// 1. 解析 SQL,判断是否需要全局事务if(needGlobalLock(sql)){// 2. 获取全局锁acquireGlobalLock();}// 3. 执行 SQL 前,记录前镜像TableRecordsbeforeImage=buildBeforeImage(sql);// 4. 执行 SQLPreparedStatementstatement=targetConnection.prepareStatement(sql);intupdateCount=statement.executeUpdate();// 5. 执行 SQL 后,记录后镜像TableRecordsafterImage=buildAfterImage(sql,beforeImage);// 6. 生成 Undo LogUndoLogundoLog=buildUndoLog(beforeImage,afterImage);insertUndoLog(undoLog);// 7. 注册分支事务registerBranch();returnstatement;}privatevoidregisterBranch(){BranchRegisterRequestrequest=newBranchRegisterRequest();request.setXid(RootContext.getXID());request.setResourceId(dataSourceProxy.getResourceId());request.setBranchType(BranchType.AT);request.setApplicationId(applicationId);// 向 TC 注册分支事务BranchRegisterResponseresponse=defaultRMHandler.getRMChannel().sendSyncRequest(request);// 保存 branchIdthis.branchId=response.getBranchId();}}4. Undo Log 的结构
Undo Log 存储在undo_log表中,结构如下:
{"branchId":123456789,"xid":"192.168.1.100:8091:2023120112345678","context":"serializer=jackson","rollbackInfo":{"@class":"io.seata.rm.datasource.undo.BranchUndoLog","xid":"192.168.1.100:8091:2023120112345678","branchId":123456789,"sqlUndoLogs":[{"@class":"io.seata.rm.datasource.undo.SQLUndoLog","sqlType":"UPDATE","tableName":"inventory","beforeImage":{"@class":"io.seata.rm.datasource.sql.struct.TableRecords","tableName":"inventory","rows":[{"@class":"io.seata.rm.datasource.sql.struct.Row","fields":[{"keyType":"PrimaryKey","name":"id","type":4,"value":1},{"name":"stock","type":4,"value":100}]}]},"afterImage":{"@class":"io.seata.rm.datasource.sql.struct.TableRecords","tableName":"inventory","rows":[{"@class":"io.seata.rm.datasource.sql.struct.Row","fields":[{"keyType":"PrimaryKey","name":"id","type":4,"value":1},{"name":"stock","type":4,"value":80}]}]}}]},"logStatus":1,"logCreated":1701417600000,"logModified":1701417600000}5. SQL 解析原理
Seata 使用 Druid SQL Parser 来解析 SQL:
// SQL 解析的简化流程publicclassSQLRecognizer{publicstaticSQLRecognizerparse(Stringsql,StringdbType){// 1. 使用 Druid 解析 SQLSQLStatementstatement=SQLUtils.parseSingleStatement(sql,dbType);// 2. 判断 SQL 类型if(statementinstanceofSQLUpdateStatement){returnnewMySQLUpdateRecognizer(sql,statement);}elseif(statementinstanceofSQLInsertStatement){returnnewMySQLInsertRecognizer(sql,statement);}elseif(statementinstanceofSQLDeleteStatement){returnnewMySQLDeleteRecognizer(sql,statement);}thrownewNotSupportYetException("不支持的 SQL 类型");}}// UPDATE 语句的识别器publicclassMySQLUpdateRecognizerextendsBaseMySQLRecognizer{@OverridepublicTableRecordsgetBeforeImage(TableMetatableMeta,List<SQLStatement>sqlStatements){// 1. 根据 WHERE 条件查询数据(前镜像)StringselectSQL=buildSelectSQL(tableMeta,sqlStatements);returnexecutor.query(selectSQL);}@OverridepublicTableRecordsgetAfterImage(TableRecordsbeforeImage,TableMetatableMeta,List<SQLStatement>sqlStatements){// 2. 执行 UPDATE,然后根据主键查询数据(后镜像)executor.update(sqlStatements);StringselectSQL=buildSelectSQLByPK(beforeImage);returnexecutor.query(selectSQL);}}6. 全局提交流程
如果所有分支事务都成功:
TM TC RM | | | |---- commit request ---------->| | | | | | |---- branch commit request --->| | | | | |<--- ACK (committed) ----------| | | | |<--- ACK (committed) ----------| | | | | | |---- delete undo_log --------->| | | |TC 收到 TM 的提交请求后,会异步通知各 RM 删除 Undo Log(因为此时所有分支事务已经提交,不需要回滚了)。
7. 全局回滚流程
如果任何分支事务失败:
TM TC RM | | | |---- rollback request -------->| | | | | | |---- branch rollback request ->| | | | | | |---- 查询 undo_log | | |---- 根据前镜像恢复数据 | | |---- 删除 undo_log | | | | |<--- ACK (rolled back) --------| | | | |<--- ACK (rolled back) --------| |TC 收到 TM 的回滚请求后,会同步通知各 RM 根据 Undo Log 回滚数据。
关键点在于,Seata 自动帮我们处理了回滚逻辑,我们只需要在业务方法上加一个@GlobalTransactional注解。
用 Seata 解决我们的问题
环境准备
1. 数据库准备
Seata Server 需要连接数据库来存储事务日志。我们可以在数据库中执行 Seata 提供的建表脚本:
-- Seata Server 使用的数据库表CREATEDATABASEseata;USEseata;-- 全局事务表CREATETABLEglobal_table(xidVARCHAR(128)NOTNULL,transaction_idBIGINT,statusTINYINTNOTNULL,application_idVARCHAR(32),transaction_service_groupVARCHAR(32),transaction_nameVARCHAR(128),timeoutINT,begin_timeBIGINT,application_dataVARCHAR(2000),gmt_createDATETIME,gmt_modifiedDATETIME,PRIMARYKEY(xid),KEYidx_gmt_modified_status(gmt_modified,status),KEYidx_transaction_id(transaction_id))ENGINE=InnoDBDEFAULTCHARSET=utf8;-- 分支事务表CREATETABLEbranch_table(branch_idBIGINTNOTNULL,xidVARCHAR(128)NOTNULL,transaction_idBIGINT,resource_group_idVARCHAR(32),resource_idVARCHAR(256),branch_typeVARCHAR(8),statusTINYINT,client_idVARCHAR(64),application_dataVARCHAR(2000),gmt_createDATETIME(6),gmt_modifiedDATETIME(6),PRIMARYKEY(branch_id),KEYidx_xid(xid))ENGINE=InnoDBDEFAULTCHARSET=utf8;-- 分布式锁表CREATETABLElock_table(row_keyVARCHAR(128)NOTNULL,xidVARCHAR(96),transaction_idBIGINT,branch_idBIGINTNOTNULL,resource_idVARCHAR(256),table_nameVARCHAR(32),pkVARCHAR(36),gmt_createDATETIME,gmt_modifiedDATETIME,PRIMARYKEY(row_key),KEYidx_branch_id(branch_id))ENGINE=InnoDBDEFAULTCHARSET=utf8;2. 启动 Seata Server
方式一:Docker 运行(最简单)
docker run -d\--name seata-server\-p8091:8091\-eSEATA_PORT=8091\seataio/seata-server:latest方式二:本地部署(推荐生产环境)
从官网下载 Seata Server,解压后修改配置文件conf/application.yml:
server:port:7091spring:application:name:seata-serverseata:config:type:dbdb:datasource:druiddb-type:mysqldriver-class-name:com.mysql.cj.jdbc.Driverurl:jdbc:mysql://127.0.0.1:3306/seata?rewriteBatchedStatements=trueuser:rootpassword:your_passwordmin-conn:10max-conn:100global-table:global_tablebranch-table:branch_tablelock-table:lock_tabledistributed-lock-table:distributed_lockquery-limit:100max-wait:5000registry:type:nacosnacos:application:seata-serverserver-addr:127.0.0.1:8848group:SEATA_GROUPnamespace:""cluster:defaultusername:nacospassword:nacosstore:mode:dbdb:datasource:druiddb-type:mysqldriver-class-name:com.mysql.cj.jdbc.Driverurl:jdbc:mysql://127.0.0.1:3306/seata?rewriteBatchedStatements=trueuser:rootpassword:your_passwordmin-conn:10max-conn:100global-table:global_tablebranch-table:branch_tablelock-table:lock_tablequery-limit:100max-wait:5000security:secretKey:SeataSecretKey0c382ef121d778043159209298fd40bf3850a017tokenValidityInMilliseconds:1800000ignore:urls:/,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login然后启动:
shbin/seata-server.sh方式三:Kubernetes 部署(生产环境高可用)
如果需要高可用,可以在 K8s 中部署:
apiVersion:apps/v1kind:Deploymentmetadata:name:seata-servernamespace:defaultspec:replicas:3selector:matchLabels:app:seata-servertemplate:metadata:labels:app:seata-serverspec:containers:-name:seata-serverimage:seataio/seata-server:latestports:-containerPort:8091-containerPort:7091env:-name:SEATA_PORTvalue:"8091"-name:STORE_MODEvalue:"db"-name:SEATA_CONFIG_NAMEvalue:"file:/root/seata-config/registry"3. 验证 Seata Server 是否启动成功
访问 Seata 控制台:http://localhost:7091,默认用户名和密码都是seata。
或者通过 API 检查:
curlhttp://localhost:7091/v1/metrics依赖引入
在订单服务、库存服务、积分服务的 pom.xml 中都加入 Seata 依赖:
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-seata</artifactId></dependency>配置 Seata
基础配置
在 application.yml 中配置 Seata:
seata:enabled:trueapplication-id:order-service# 服务名,用于标识不同的服务tx-service-group:my_test_tx_group# 事务组名# 服务配置service:# 事务组与集群的映射关系vgroup-mapping:my_test_tx_group:default# 事务组名 -> 集群名# Seata Server 地址列表grouplist:default:127.0.0.1:8091# 集群名 -> Server 地址(多个用逗号分隔)# 是否启用降级enable-degrade:false# 降级阈值(当全局事务失败率达到这个值时,会降级)degrade-check-period:2000# 降级阈值百分比degrade-check-allow-times:10# 配置中心配置config:type:nacos# 支持 file、nacos、apollo、zk、consul 等nacos:server-addr:127.0.0.1:8848namespace:""group:SEATA_GROUPusername:nacospassword:nacosdata-id:seataServer.properties# 注册中心配置registry:type:nacos# 支持 file、nacos、eureka、redis、zk、etcd3、sofa、consul 等nacos:application:seata-serverserver-addr:127.0.0.1:8848group:SEATA_GROUPnamespace:""cluster:defaultusername:nacospassword:nacos# 客户端配置client:# RM 配置rm:# 是否异步提交分支事务async-commit-buffer-limit:10000# 报告重试次数report-retry-count:5# 表元数据检查table-meta-check-enable:false# 是否上报成功report-success-enable:false# Saga 分支注册使能saga-branch-register-enable:false# Saga JSON 解析器saga-json-parser:fastjson# Saga 重试持久化模式(异步或同步)saga-retry-persist-mode-update:false# Saga 补偿持久化模式(异步或同步)saga-compensate-persist-mode-update:false# 锁重试间隔(毫秒)lock-retry-interval:10# 锁重试次数lock-retry-times:30# TM 配置tm:# 提交重试次数commit-retry-count:5# 回滚重试次数rollback-retry-count:5# 降级检查degrade-check:false# 降级检查周期(毫秒)degrade-check-period:2000# 降级检查允许次数degrade-check-allow-times:10# Undo 配置undo:# 是否启用数据验证data-validation:true# 日志序列化方式(jackson、kryo、protobuf、fastjson)log-serialization:jackson# 日志表名log-table:undo_log# 是否只关心更新后的数据only-care-update-columns:true# 负载均衡配置load-balance:# 负载均衡类型(RandomLoadBalance、RoundRobinLoadBalance、LeastActiveLoadBalance、ConsistentHashLoadBalance)type:RandomLoadBalance# 虚拟节点数(仅适用于 ConsistentHashLoadBalance)virtual-nodes:10使用 Nacos 作为配置中心
如果使用 Nacos 作为配置中心,需要在 Nacos 中创建配置:
- 登录 Nacos 控制台
- 创建命名空间(可选)
- 创建配置,Data ID:
seataServer.properties,Group:SEATA_GROUP
配置内容:
# 事务组配置 service.vgroupMapping.my_test_tx_group=default service.default.grouplist=127.0.0.1:8091 # 事务服务降级策略 service.enableDegrade=false service.disableGlobalTransaction=false # 客户端与 TC 通信配置 transport.type=TCP transport.server=NIO transport.heartbeat=true transport.enableClientBatchSendRequest=true transport.threadFactory.bossThreadPrefix=NettyBoss transport.threadFactory.workerThreadPrefix=NettyServerNIOWorker transport.threadFactory.serverExecutor=nettyServerHandler transport.threadFactory.shareBossWorker=false transport.threadFactory.clientSelectorThreadPrefix=NettyClientSelector transport.threadFactory.clientSelectorThreadSize=1 transport.threadFactory.clientWorkerThreadPrefix=NettyClientWorkerThread transport.threadFactory.bossThreadSize=1 transport.threadFactory.workerThreadSize=default transport.shutdown.wait=3 # TC 存储配置 store.mode=db store.db.datasource=druid store.db.dbType=mysql store.db.driverClassName=com.mysql.cj.jdbc.Driver store.db.url=jdbc:mysql://127.0.0.1:3306/seata?rewriteBatchedStatements=true store.db.user=root store.db.password=your_password store.db.minConn=5 store.db.maxConn=100 store.db.globalTable=global_table store.db.branchTable=branch_table store.db.lockTable=lock_table store.db.queryLimit=100 store.db.maxWait=5000使用 Nacos 作为注册中心
使用 Nacos 作为注册中心时,客户端会自动从 Nacos 获取 Seata Server 的地址,不需要手动配置grouplist。
数据源代理
Seata 需要代理数据源,这样才能拦截 SQL 并生成 Undo Log。有多种方式可以实现:
方式一:使用 DataSourceProxy(推荐)
@ConfigurationpublicclassDataSourceConfig{@Bean@ConfigurationProperties("spring.datasource")publicDruidDataSourcedruidDataSource(){DruidDataSourcedruidDataSource=newDruidDataSource();returndruidDataSource;}@Primary@BeanpublicDataSourcedataSource(DruidDataSourcedruidDataSource){// AT 模式使用 DataSourceProxyreturnnewDataSourceProxy(druidDataSource);}}方式二:使用 Seata 自动代理(Spring Boot Starter)
如果使用spring-cloud-starter-alibaba-seata,Seata 会自动代理数据源,不需要手动配置。但需要注意数据源的配置顺序:
@ConfigurationpublicclassDataSourceConfig{@Bean@ConfigurationProperties("spring.datasource")publicDruidDataSourcedruidDataSource(){returnnewDruidDataSource();}// 如果使用自动代理,不要手动创建 DataSourceProxy// Seata 会自动拦截 @Bean DataSource 类型的方法}方式三:使用 SeataDataSourceBeanPostProcessor
对于动态数据源(如 Druid 的多数据源),需要特殊处理:
@ConfigurationpublicclassDynamicDataSourceConfig{@BeanpublicDataSourcedataSource(){// 创建动态数据源DynamicRoutingDataSourcedynamicDataSource=newDynamicRoutingDataSource();// 添加数据源Map<String,DataSource>dataSourceMap=newHashMap<>();dataSourceMap.put("master",masterDataSource());dataSourceMap.put("slave1",slave1DataSource());dynamicDataSource.setTargetDataSources(dataSourceMap);dynamicDataSource.setDefaultTargetDataSource(masterDataSource());returndynamicDataSource;}@BeanpublicDataSourcemasterDataSource(){DruidDataSourcedataSource=newDruidDataSource();// ... 配置数据源returnnewDataSourceProxy(dataSource);}@BeanpublicDataSourceslave1DataSource(){DruidDataSourcedataSource=newDruidDataSource();// ... 配置数据源returnnewDataSourceProxy(dataSource);}}数据源代理的原理
DataSourceProxy会拦截所有通过该数据源获取的Connection,返回一个ConnectionProxy:
// DataSourceProxy 的简化实现publicclassDataSourceProxyextendsAbstractDataSourceProxy{@OverridepublicConnectiongetConnection()throwsSQLException{ConnectiontargetConnection=targetDataSource.getConnection();returnnewConnectionProxy(this,targetConnection);}}// ConnectionProxy 会拦截 SQL 执行publicclassConnectionProxyextendsAbstractConnectionProxy{@OverridepublicPreparedStatementprepareStatement(Stringsql)throwsSQLException{// 解析 SQLSQLRecognizersqlRecognizer=SQLRecognizerFactory.get(sql,getDbType());if(sqlRecognizer!=null){// 生成前镜像TableRecordsbeforeImage=buildBeforeImage(sqlRecognizer);// 执行 SQLPreparedStatementtargetStatement=targetConnection.prepareStatement(sql);// 生成后镜像TableRecordsafterImage=buildAfterImage(sqlRecognizer,beforeImage);// 生成 Undo LogUndoLogundoLog=buildUndoLog(beforeImage,afterImage);insertUndoLog(undoLog);returnnewPreparedStatementProxy(this,targetStatement,sqlRecognizer);}returntargetConnection.prepareStatement(sql);}}业务代码改造
最关键的一步,在开启全局事务的地方加上注解:
@ServicepublicclassOrderServiceImplimplementsOrderService{@AutowiredprivateInventoryServiceinventoryService;@AutowiredprivateOrderMapperorderMapper;@AutowiredprivatePointServicepointService;@GlobalTransactional(rollbackFor=Exception.class)@OverridepublicvoidcreateOrder(OrderRequestrequest){// 1. 扣库存inventoryService.reduceStock(request.getProductId(),request.getQuantity());// 2. 创建订单Orderorder=newOrder();order.setUserId(request.getUserId());order.setProductId(request.getProductId());order.setQuantity(request.getQuantity());orderMapper.insert(order);// 3. 扣积分pointService.deduct(request.getUserId(),request.getAmount());}}注意,这三个服务的调用需要保证 XID 能够传递。如果是用 Feign 或者 Dubbo,Seata 的 starter 已经帮我们自动处理了 XID 的传递。
在库存服务和积分服务中,也需要开启本地事务:
@ServicepublicclassInventoryServiceImplimplementsInventoryService{@AutowiredprivateInventoryMapperinventoryMapper;@Transactional@OverridepublicvoidreduceStock(LongproductId,Integerquantity){Inventoryinventory=inventoryMapper.selectByProductId(productId);if(inventory.getStock()<quantity){thrownewRuntimeException("库存不足");}inventory.setStock(inventory.getStock()-quantity);inventoryMapper.updateById(inventory);}}Undo Log 表
Seata 的 AT 模式需要在每个业务数据库中创建 undo_log 表:
CREATETABLEundo_log(idBIGINT(20)NOTNULLAUTO_INCREMENT,branch_idBIGINT(20)NOTNULL,xidVARCHAR(100)NOTNULL,contextVARCHAR(128)NOTNULL,rollback_infoLONGBLOBNOTNULL,log_statusINT(11)NOTNULL,log_createdDATETIME(6)NOTNULL,log_modifiedDATETIME(6)NOTNULL,PRIMARYKEY(id),UNIQUEKEYux_undo_log(xid,branch_id))ENGINE=InnoDBDEFAULTCHARSET=utf8;这个表用来存储回滚日志。当需要回滚时,Seata 会从这个表读取数据的前镜像,然后恢复数据。
实际遇到的问题
问题 1:回滚失败
场景描述
我们在测试时发现,有时候回滚会失败,报错信息类似:
Cannot get table meta for table xxx, unknown table或者
Primary key is null, cannot generate undo log原因分析
排查后发现主要有几个原因:
表没有主键:Seata 的 AT 模式需要根据主键来定位要回滚的记录,如果没有主键,就无法生成回滚 SQL。
主键类型不支持:Seata 只支持简单的单字段主键,不支持复合主键。虽然可以配置支持,但建议尽量避免。
表元数据缓存未更新:如果表结构改了(比如新增了字段),但 Seata 的元数据缓存没有更新,也会导致回滚失败。
解决方案
- 所有业务表都要有主键,最好是单字段主键:
-- 好的设计CREATETABLEorder(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINTNOTNULL,-- ...);-- 避免的设计CREATETABLEorder_item(order_idBIGINT,item_idBIGINT,-- ... 没有主键);-- 如果确实需要复合主键,确保 Seata 配置支持CREATETABLEorder_item(order_idBIGINT,item_idBIGINT,PRIMARYKEY(order_id,item_id));- 配置表元数据检查(开发环境可以开启,生产环境建议关闭以提升性能):
seata:client:rm:table-meta-check-enable:true# 开启表元数据检查- 手动刷新元数据缓存(如果确实需要):
// 在启动时刷新表元数据@ComponentpublicclassTableMetaInitializer{@AutowiredprivateDataSourceProxydataSourceProxy;@PostConstructpublicvoidinit(){TableMetaCacheFactory.getTableMetaCache(DataSourceType.MYSQL).refresh(dataSourceProxy,"order");}}问题 2:并发更新导致数据不一致
场景描述
在高并发场景下,多个全局事务同时更新同一条记录时,可能会出现数据不一致的问题。
比如,两个订单同时扣减同一个商品的库存:
事务1:库存 100 -> 扣减 10 -> 库存 90 事务2:库存 100 -> 扣减 20 -> 库存 80如果这两个事务并发执行,最终库存可能是 90 或 80,但期望的应该是 70。
原因分析
Seata 的 AT 模式使用的是全局锁(Global Lock),但在某些情况下,全局锁可能无法及时获取,导致并发更新。
全局锁的工作原理:
- 当 RM 执行 UPDATE 语句时,会先获取本地数据库锁
- 然后向 TC 申请全局锁
- 如果全局锁被其他事务占用,会重试,直到超时
但如果两个事务几乎同时执行,可能出现:
- 事务1 获取了本地锁,正在申请全局锁
- 事务2 也获取了本地锁(因为事务1还没拿到全局锁),也在申请全局锁
- 最终两个事务都成功提交,导致数据不一致
解决方案
- 使用 SELECT FOR UPDATE:在更新前先加行锁
@ServicepublicclassInventoryServiceImplimplementsInventoryService{@Transactional@OverridepublicvoidreduceStock(LongproductId,Integerquantity){// 使用 FOR UPDATE 加锁Inventoryinventory=inventoryMapper.selectByIdForUpdate(productId);if(inventory.getStock()<quantity){thrownewRuntimeException("库存不足");}inventory.setStock(inventory.getStock()-quantity);inventoryMapper.updateById(inventory);}}// Mapper 中定义@Select("SELECT * FROM inventory WHERE id = #{id} FOR UPDATE")InventoryselectByIdForUpdate(Longid);- 使用乐观锁:通过版本号控制
@Data@TableName("inventory")publicclassInventory{privateLongid;privateLongproductId;privateIntegerstock;@Version// MyBatis-Plus 乐观锁注解privateIntegerversion;}@ServicepublicclassInventoryServiceImplimplementsInventoryService{@Transactional@OverridepublicvoidreduceStock(LongproductId,Integerquantity){Inventoryinventory=inventoryMapper.selectByProductId(productId);if(inventory.getStock()<quantity){thrownewRuntimeException("库存不足");}// 使用版本号更新introws=inventoryMapper.updateStock(inventory.getId(),inventory.getVersion(),quantity);if(rows==0){// 版本冲突,重试或抛异常thrownewRuntimeException("库存更新冲突,请重试");}}}// SQLUPDATE inventorySETstock=stock-#{quantity},version=version+1WHEREid=#{id}ANDversion=#{version}- 使用分布式锁:对于热点数据,可以使用 Redis 分布式锁
@ServicepublicclassInventoryServiceImplimplementsInventoryService{@AutowiredprivateRedisTemplate<String,String>redisTemplate;@Transactional@OverridepublicvoidreduceStock(LongproductId,Integerquantity){StringlockKey="inventory:lock:"+productId;StringlockValue=UUID.randomUUID().toString();try{// 尝试获取分布式锁Booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,10,TimeUnit.SECONDS);if(!locked){thrownewRuntimeException("获取锁失败,请重试");}// 执行库存扣减Inventoryinventory=inventoryMapper.selectByProductId(productId);if(inventory.getStock()<quantity){thrownewRuntimeException("库存不足");}inventory.setStock(inventory.getStock()-quantity);inventoryMapper.updateById(inventory);}finally{// 释放锁Stringscript="if redis.call('get', KEYS[1]) == ARGV[1] then "+"return redis.call('del', KEYS[1]) else return 0 end";redisTemplate.execute(newDefaultRedisScript<>(script,Long.class),Collections.singletonList(lockKey),lockValue);}}}问题 3:自定义 SQL 不支持
场景描述
Seata 的 AT 模式只支持标准的 INSERT、UPDATE、DELETE 语句。如果你的业务代码里有复杂的 SQL,比如:
-- 带子查询的 UPDATEUPDATEorderSETstatus=1WHEREuser_idIN(SELECTidFROMuserWHERElevel>5);-- 批量更新UPDATEinventorySETstock=CASEWHENid=1THENstock-10WHENid=2THENstock-20ELSEstockENDWHEREidIN(1,2);-- 使用函数UPDATEorderSETtotal_amount=(SELECTSUM(amount)FROMorder_itemWHEREorder_id=order.id);这种复杂 SQL,Seata 可能无法正确解析和回滚。
原因分析
Seata 使用 Druid SQL Parser 解析 SQL,只支持标准的单表操作。对于复杂 SQL:
- 无法准确识别影响的数据范围
- 无法生成准确的回滚 SQL
- 可能导致回滚失败或数据不一致
解决方案
- 把复杂 SQL 拆成多条简单 SQL(推荐)
// 不推荐@Update("UPDATE order SET status = 1 WHERE user_id IN "+"(SELECT id FROM user WHERE level > 5)")voidupdateOrderStatusByUserLevel();// 推荐publicvoidupdateOrderStatusByUserLevel(){// 1. 先查询用户IDList<Long>userIds=userMapper.selectIdsByLevel(5);// 2. 再更新订单(Seata 可以正确处理)if(!userIds.isEmpty()){orderMapper.updateStatusByUserIds(userIds,OrderStatus.COMPLETED);}}- 使用编程式事务,手动处理回滚逻辑
@ServicepublicclassComplexOrderService{@GlobalTransactionalpublicvoidcomplexUpdate(OrderRequestrequest){try{// 执行复杂 SQLexecuteComplexSQL(request);}catch(Exceptione){// 手动回滚manualRollback(request);throwe;}}privatevoidmanualRollback(OrderRequestrequest){// 根据业务逻辑手动回滚// 比如记录需要回滚的操作,然后补偿}}- 对于这种特殊情况,考虑用 TCC 模式
TCC 模式不依赖 SQL 解析,可以灵活处理复杂业务逻辑。
问题 4:性能影响
性能测试数据
我们在生产环境的监控中发现,使用 Seata 后:
- 数据库写入量增加约 30-50%:每次 UPDATE/DELETE 都会生成 Undo Log
- 事务耗时增加约 20-30ms:主要是生成 Undo Log 和注册分支事务的开销
- 数据库连接数略有增加:RM 需要额外的连接与 TC 通信
具体数据(单机 1000 TPS):
| 指标 | 未使用 Seata | 使用 Seata | 增加幅度 |
|---|---|---|---|
| 平均响应时间 | 50ms | 72ms | +44% |
| P99 响应时间 | 200ms | 280ms | +40% |
| 数据库 QPS | 3000 | 4500 | +50% |
| 数据库连接数 | 50 | 65 | +30% |
性能瓶颈分析
Undo Log 写入开销:每次 UPDATE/DELETE 都需要:
- 查询前镜像(一次 SELECT)
- 执行业务 SQL(一次 UPDATE/DELETE)
- 查询后镜像(一次 SELECT)
- 插入 Undo Log(一次 INSERT)
网络通信开销:分支事务注册需要与 TC 通信
锁竞争:全局锁可能导致事务等待
优化方案
- 优化 Undo Log 存储
seata:client:undo:# 只记录变更的字段(减少 Undo Log 大小)only-care-update-columns:true# 使用更高效的序列化方式(kryo 或 protobuf)log-serialization:kryo- 异步提交分支事务(减少提交阶段的耗时)
seata:client:rm:# 异步提交分支事务async-commit-buffer-limit:10000# 不报告成功(减少网络通信)report-success-enable:false- 批量操作优化
对于批量更新,如果支持,尽量使用批量接口:
// 不推荐:循环更新for(Orderorder:orders){orderMapper.updateById(order);// 每次都会生成 Undo Log}// 推荐:批量更新(如果 MyBatis-Plus 支持批量 Undo Log)orderMapper.updateBatchById(orders);// 只生成一个 Undo Log- 读写分离
将只读操作路由到从库,减少主库压力:
@DS("slave")// 使用从库(不会开启事务,也不会生成 Undo Log)publicList<Order>queryOrders(LonguserId){returnorderMapper.selectByUserId(userId);}- 对于高并发场景,考虑用 TCC 模式或者消息队列
TCC 模式不生成 Undo Log,性能更好。但需要业务改造。
- 监控和调优
定期监控关键指标,及时发现问题:
@ComponentpublicclassSeataMetrics{@AutowiredprivateMeterRegistrymeterRegistry;@EventListenerpublicvoidonGlobalTransactionBegin(GlobalTransactionBeginEventevent){meterRegistry.counter("seata.global.transaction.begin").increment();}@EventListenerpublicvoidonGlobalTransactionCommit(GlobalTransactionCommitEventevent){Timer.Samplesample=Timer.start(meterRegistry);sample.stop(meterRegistry.timer("seata.global.transaction.commit"));}@EventListenerpublicvoidonGlobalTransactionRollback(GlobalTransactionRollbackEventevent){meterRegistry.counter("seata.global.transaction.rollback").increment();}}问题 5:网络分区和数据不一致
场景描述
在网络不稳定的环境下,可能出现:
- RM 与 TC 网络分区,无法通信
- 分支事务已经提交,但 TC 不知道
- 全局事务超时后,TC 尝试回滚已经提交的分支事务
解决方案
- 设置合理的超时时间
@GlobalTransactional(timeoutMills=300000,// 5 分钟rollbackFor=Exception.class)publicvoidlongRunningBusiness(){// ...}- 使用 Saga 模式处理长事务
对于执行时间不确定的长事务,Saga 模式更合适。
- 实现补偿机制
对于可能出现的部分提交情况,实现补偿逻辑:
@ServicepublicclassOrderService{@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){try{inventoryService.reduceStock(request.getProductId());orderService.create(request);pointService.deduct(request.getUserId());}catch(Exceptione){// Seata 会自动回滚,但为了保险,我们可以记录日志log.error("订单创建失败,开始补偿",e);compensate(request);throwe;}}privatevoidcompensate(OrderRequestrequest){// 记录需要补偿的操作CompensationLoglog=newCompensationLog();log.setOrderId(request.getOrderId());log.setAction("createOrder");log.setStatus("NEED_COMPENSATE");compensationLogMapper.insert(log);}// 定时任务扫描并执行补偿@Scheduled(fixedDelay=60000)publicvoidexecuteCompensation(){List<CompensationLog>logs=compensationLogMapper.selectList(newLambdaQueryWrapper<CompensationLog>().eq(CompensationLog::getStatus,"NEED_COMPENSATE"));for(CompensationLoglog:logs){try{doCompensate(log);log.setStatus("COMPENSATED");}catch(Exceptione){log.error("补偿失败",e);}compensationLogMapper.updateById(log);}}}问题 6:超时时间设置
场景描述
全局事务有个超时时间,默认是 60 秒。如果业务执行时间超过这个时间,事务会被强制回滚。
我们在处理大批量订单时遇到过这个问题:
@GlobalTransactionalpublicvoidbatchCreateOrder(List<OrderRequest>requests){// 如果 requests 有 10000 条,处理时间可能超过 60 秒for(OrderRequestrequest:requests){createOrder(request);// 每个订单 10ms,总共 100 秒}}解决方案
- 设置合理的超时时间
@GlobalTransactional(timeoutMills=300000)// 设置超时时间为 5 分钟publicvoidbatchCreateOrder(List<OrderRequest>requests){// ...}- 合理拆分事务
把大事务拆成多个小事务:
// 不推荐:一个大事务处理所有订单@GlobalTransactionalpublicvoidbatchCreateOrder(List<OrderRequest>requests){for(OrderRequestrequest:requests){createOrder(request);}}// 推荐:批量处理,每个批次一个事务publicvoidbatchCreateOrder(List<OrderRequest>requests){intbatchSize=100;for(inti=0;i<requests.size();i+=batchSize){List<OrderRequest>batch=requests.subList(i,Math.min(i+batchSize,requests.size()));processBatch(batch);}}@GlobalTransactional(timeoutMills=30000)privatevoidprocessBatch(List<OrderRequest>batch){for(OrderRequestrequest:batch){createOrder(request);}}- 异步处理
对于非实时要求的业务,可以异步处理:
@ServicepublicclassOrderService{@AutowiredprivateThreadPoolTaskExecutorasyncExecutor;publicvoidbatchCreateOrder(List<OrderRequest>requests){// 异步处理,不开启全局事务asyncExecutor.execute(()->{processBatch(requests);});}@GlobalTransactionalprivatevoidprocessBatch(List<OrderRequest>requests){// 异步处理中的事务for(OrderRequestrequest:requests){createOrder(request);}}}Seata 的其他模式
除了 AT 模式,Seata 还支持 TCC 模式和 Saga 模式。每种模式都有其适用场景。
TCC 模式详解
TCC 模式需要业务代码实现 Try、Confirm、Cancel 三个方法。对于扣库存这个场景,可以这样实现:
接口定义
publicinterfaceInventoryTccService{@TwoPhaseBusinessAction(name="reduceStock",commitMethod="confirm",rollbackMethod="cancel")booleantryReduceStock(@BusinessActionContextParameter("productId")LongproductId,@BusinessActionContextParameter("quantity")Integerquantity,@BusinessActionContextParameter("orderId")LongorderId);booleanconfirm(BusinessActionContextcontext);booleancancel(BusinessActionContextcontext);}完整实现(包含幂等、空回滚、防悬挂)
@Service@Slf4jpublicclassInventoryTccServiceImplimplementsInventoryTccService{@AutowiredprivateInventoryMapperinventoryMapper;@AutowiredprivateTccActionMappertccActionMapper;@Override@TransactionalpublicbooleantryReduceStock(LongproductId,Integerquantity,LongorderId){Stringxid=RootContext.getXID();// 1. 防悬挂:检查是否已经被 CancelTccActionaction=tccActionMapper.selectByXidAndAction(xid,"reduceStock",productId.toString());if(action!=null&&action.getStatus()==TccActionStatus.CANCELLED){log.warn("Try 阶段被悬挂,xid={}, productId={}",xid,productId);returnfalse;}// 2. 幂等性检查if(action!=null&&action.getStatus()==TccActionStatus.TRYING){log.info("Try 阶段已执行,直接返回,xid={}, productId={}",xid,productId);returntrue;}// 3. 冻结库存Inventoryinventory=inventoryMapper.selectByProductId(productId);if(inventory.getStock()<quantity){thrownewRuntimeException("库存不足");}inventory.setFrozenStock(inventory.getFrozenStock()+quantity);inventoryMapper.updateById(inventory);// 4. 记录 TCC 操作状态if(action==null){action=newTccAction();action.setXid(xid);action.setAction("reduceStock");action.setResourceId(productId.toString());action.setContext(JSON.toJSONString(Map.of("productId",productId,"quantity",quantity,"orderId",orderId)));action.setStatus(TccActionStatus.TRYING);tccActionMapper.insert(action);}log.info("Try 阶段执行成功,xid={}, productId={}, quantity={}",xid,productId,quantity);returntrue;}@Override@Transactionalpublicbooleanconfirm(BusinessActionContextcontext){Stringxid=context.getXid();Map<String,Object>params=JSON.parseObject(context.getActionContext("context").toString(),Map.class);LongproductId=Long.valueOf(params.get("productId").toString());Integerquantity=Integer.valueOf(params.get("quantity").toString());// 1. 幂等性检查TccActionaction=tccActionMapper.selectByXidAndAction(xid,"reduceStock",productId.toString());if(action==null){log.warn("Confirm 阶段未找到 Try 记录,可能已确认,xid={}",xid);returntrue;// 可能已经确认过了}if(action.getStatus()==TccActionStatus.CONFIRMED){log.info("Confirm 阶段已执行,直接返回,xid={}",xid);returntrue;}// 2. 真正扣减库存Inventoryinventory=inventoryMapper.selectByProductId(productId);inventory.setStock(inventory.getStock()-quantity);inventory.setFrozenStock(inventory.getFrozenStock()-quantity);inventoryMapper.updateById(inventory);// 3. 更新状态action.setStatus(TccActionStatus.CONFIRMED);tccActionMapper.updateById(action);log.info("Confirm 阶段执行成功,xid={}, productId={}, quantity={}",xid,productId,quantity);returntrue;}@Override@Transactionalpublicbooleancancel(BusinessActionContextcontext){Stringxid=context.getXid();Map<String,Object>params=JSON.parseObject(context.getActionContext("context").toString(),Map.class);LongproductId=Long.valueOf(params.get("productId").toString());Integerquantity=Integer.valueOf(params.get("quantity").toString());// 1. 空回滚检查TccActionaction=tccActionMapper.selectByXidAndAction(xid,"reduceStock",productId.toString());if(action==null){// Try 没执行,记录空回滚标记action=newTccAction();action.setXid(xid);action.setAction("reduceStock");action.setResourceId(productId.toString());action.setContext(context.getActionContext("context").toString());action.setStatus(TccActionStatus.CANCELLED);tccActionMapper.insert(action);log.warn("Cancel 阶段空回滚,xid={}, productId={}",xid,productId);returntrue;}// 2. 幂等性检查if(action.getStatus()==TccActionStatus.CANCELLED){log.info("Cancel 阶段已执行,直接返回,xid={}",xid);returntrue;}// 3. 释放冻结的库存Inventoryinventory=inventoryMapper.selectByProductId(productId);inventory.setFrozenStock(inventory.getFrozenStock()-quantity);inventoryMapper.updateById(inventory);// 4. 更新状态action.setStatus(TccActionStatus.CANCELLED);tccActionMapper.updateById(action);log.info("Cancel 阶段执行成功,xid={}, productId={}, quantity={}",xid,productId,quantity);returntrue;}}TCC 模式的使用
@ServicepublicclassOrderServiceImplimplementsOrderService{@AutowiredprivateInventoryTccServiceinventoryTccService;@GlobalTransactional@OverridepublicvoidcreateOrder(OrderRequestrequest){// 1. Try 阶段:冻结库存inventoryTccService.tryReduceStock(request.getProductId(),request.getQuantity(),request.getOrderId());// 2. 创建订单Orderorder=newOrder();order.setUserId(request.getUserId());order.setProductId(request.getProductId());orderMapper.insert(order);// 如果后续步骤失败,Seata 会自动调用 Cancel// 如果全部成功,Seata 会自动调用 Confirm}}TCC 模式的优缺点
优点:
- 性能好,不依赖数据库事务
- 不需要 Undo Log,数据库压力小
- 业务可控性强
缺点:
- 需要大量改造业务代码
- 需要处理幂等性、空回滚、防悬挂等复杂逻辑
- 开发成本高,容易出错
Saga 模式详解
Saga 模式适用于长事务场景。它的核心思想是:把一个大事务拆成多个小事务,每个小事务都有对应的补偿操作。
注解方式实现
@ServicepublicclassOrderSagaService{@SagaStart@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){reduceStock(request);createOrderRecord(request);deductPoint(request);}@Compensable(compensationMethod="reduceStockCompensate")publicvoidreduceStock(OrderRequestrequest){// 扣库存inventoryService.reduceStock(request.getProductId(),request.getQuantity());}publicvoidreduceStockCompensate(OrderRequestrequest){// 补偿:恢复库存inventoryService.restoreStock(request.getProductId(),request.getQuantity());}@Compensable(compensationMethod="createOrderRecordCompensate")publicvoidcreateOrderRecord(OrderRequestrequest){// 创建订单orderService.create(request);}publicvoidcreateOrderRecordCompensate(OrderRequestrequest){// 补偿:删除订单orderService.delete(request.getOrderId());}@Compensable(compensationMethod="deductPointCompensate")publicvoiddeductPoint(OrderRequestrequest){// 扣积分pointService.deduct(request.getUserId(),request.getAmount());}publicvoiddeductPointCompensate(OrderRequestrequest){// 补偿:恢复积分pointService.restore(request.getUserId(),request.getAmount());}}状态机方式实现
状态机方式更灵活,可以处理复杂的业务流程。需要定义一个 JSON 格式的状态机定义文件:
{"Name":"createOrderSaga","Comment":"创建订单流程","StartState":"ReduceStock","Version":"0.0.1","States":{"ReduceStock":{"Type":"ServiceTask","ServiceName":"inventoryService","ServiceMethod":"reduceStock","CompensateState":"ReduceStockCompensate","Next":"CreateOrder"},"ReduceStockCompensate":{"Type":"CompensationTask","ServiceName":"inventoryService","ServiceMethod":"restoreStock"},"CreateOrder":{"Type":"ServiceTask","ServiceName":"orderService","ServiceMethod":"create","CompensateState":"CreateOrderCompensate","Next":"DeductPoint"},"CreateOrderCompensate":{"Type":"CompensationTask","ServiceName":"orderService","ServiceMethod":"delete"},"DeductPoint":{"Type":"ServiceTask","ServiceName":"pointService","ServiceMethod":"deduct","CompensateState":"DeductPointCompensate","IsEnd":true},"DeductPointCompensate":{"Type":"CompensationTask","ServiceName":"pointService","ServiceMethod":"restore"}}}Saga 模式的优缺点
优点:
- 适合长事务
- 不锁定资源
- 性能好
缺点:
- 只能保证最终一致性
- 可能有短暂的中间状态
- 需要实现补偿逻辑
异构系统分布式事务的挑战
问题场景
在实际项目中,我们经常需要与第三方系统集成。比如:
我们的系统(Java + Seata) ↓ 1. 扣库存(本地数据库) ↓ 2. 创建订单(本地数据库) ↓ 3. 调用第三方支付接口(第三方系统,可能是 PHP、Python 等) ↓ 4. 扣积分(本地数据库)当你使用@GlobalTransactional时,如果第三方接口返回错误,你捕获错误编码后抛出异常,发现本地系统的数据并没有回滚。
典型错误代码示例
很多开发者会这样写代码:
@ServicepublicclassOrderService{@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){// 1. 扣库存inventoryService.reduceStock(request.getProductId(),request.getQuantity());// 2. 创建订单Orderorder=newOrder();order.setUserId(request.getUserId());order.setProductId(request.getProductId());orderMapper.insert(order);// 3. 调用第三方接口try{ThirdPartyResponseresponse=thirdPartyService.callPayment(request);// 第三方返回错误编码if(!response.isSuccess()){// ❌ 错误:只是记录日志,没有抛出异常log.error("第三方支付失败: code={}, msg={}",response.getCode(),response.getMsg());// 这里没有 throw,事务不会回滚!}}catch(Exceptione){// ❌ 错误:捕获异常后没有重新抛出log.error("调用第三方接口异常",e);// 这里没有 throw,事务不会回滚!}// 4. 扣积分pointService.deduct(request.getUserId(),request.getAmount());// 结果:即使第三方返回错误,本地数据也不会回滚}}为什么数据没有回滚?
原因一:异常被吞掉了
最常见的问题:捕获异常后没有重新抛出。
// ❌ 错误写法try{ThirdPartyResponseresponse=thirdPartyService.callPayment(request);if(!response.isSuccess()){log.error("支付失败");// 没有 throw,事务不会回滚}}catch(Exceptione){log.error("异常",e);// 没有 throw,事务不会回滚}// ✅ 正确写法try{ThirdPartyResponseresponse=thirdPartyService.callPayment(request);if(!response.isSuccess()){thrownewBusinessException("第三方支付失败: "+response.getMsg());}}catch(Exceptione){log.error("调用第三方接口异常",e);thrownewBusinessException("第三方接口调用失败",e);// 必须重新抛出}原因二:异常类型不在 rollbackFor 范围内
如果抛出的异常类型不在rollbackFor指定的范围内,事务也不会回滚。
// ❌ 错误:抛出的异常类型不在 rollbackFor 范围内@GlobalTransactional(rollbackFor={BusinessException.class})// 只回滚 BusinessExceptionpublicvoidcreateOrder(OrderRequestrequest){// ...if(!response.isSuccess()){thrownewRuntimeException("支付失败");// RuntimeException 不在 rollbackFor 中// 事务不会回滚!}}// ✅ 正确:抛出指定类型的异常@GlobalTransactional(rollbackFor=Exception.class)// 所有异常都回滚publicvoidcreateOrder(OrderRequestrequest){// ...if(!response.isSuccess()){thrownewBusinessException("支付失败");// 或者直接 throw new Exception}}原因三:第三方调用在事务提交后执行
如果第三方调用是在事务提交后执行的(比如用了@Async或@TransactionalEventListener),即使抛出异常也不会回滚。
// ❌ 错误:异步调用,事务已经提交@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){inventoryService.reduceStock(request.getProductId());orderMapper.insert(order);// 异步调用,事务可能已经提交asyncThirdPartyService.callPayment(request);// 这个方法有 @Async}// ✅ 正确:同步调用,在事务内@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){inventoryService.reduceStock(request.getProductId());orderMapper.insert(order);// 同步调用,在事务内thirdPartyService.callPayment(request);}原因四:第三方调用被包装成了非异常返回
有些开发者喜欢用返回值来表示错误,而不是抛异常。
// ❌ 错误:用返回值表示错误,不抛异常@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){inventoryService.reduceStock(request.getProductId());orderMapper.insert(order);// 返回 Result 对象,不抛异常Resultresult=thirdPartyService.callPayment(request);if(!result.isSuccess()){log.error("支付失败");return;// 只是返回,事务不会回滚}}// ✅ 正确:失败时抛出异常@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){inventoryService.reduceStock(request.getProductId());orderMapper.insert(order);Resultresult=thirdPartyService.callPayment(request);if(!result.isSuccess()){thrownewBusinessException("支付失败: "+result.getMsg());}}正确的处理方式
方式一:直接抛出异常(推荐)
@Service@Slf4jpublicclassOrderService{@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){// 1. 扣库存inventoryService.reduceStock(request.getProductId(),request.getQuantity());// 2. 创建订单Orderorder=newOrder();order.setUserId(request.getUserId());order.setProductId(request.getProductId());order.setStatus(OrderStatus.CREATED);orderMapper.insert(order);// 3. 调用第三方接口ThirdPartyResponseresponse=thirdPartyService.callPayment(request);// 检查返回码,失败直接抛异常if(!response.isSuccess()){log.error("第三方支付失败: code={}, msg={}",response.getCode(),response.getMsg());thrownewBusinessException("第三方支付失败: code="+response.getCode()+", msg="+response.getMsg());}// 4. 扣积分pointService.deduct(request.getUserId(),request.getAmount());}}方式二:封装第三方调用,统一异常处理
@Service@Slf4jpublicclassOrderService{@AutowiredprivateThirdPartyServicethirdPartyService;@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){// 1. 扣库存inventoryService.reduceStock(request.getProductId(),request.getQuantity());// 2. 创建订单Orderorder=newOrder();order.setUserId(request.getUserId());order.setProductId(request.getProductId());orderMapper.insert(order);// 3. 调用第三方接口(封装的方法会自动处理异常)callThirdPartyWithException(request);// 4. 扣积分pointService.deduct(request.getUserId(),request.getAmount());}/** * 调用第三方接口,失败时抛出异常 */privatevoidcallThirdPartyWithException(OrderRequestrequest){try{ThirdPartyResponseresponse=thirdPartyService.callPayment(request);if(!response.isSuccess()){// 根据错误码判断是否需要回滚if(isRollbackRequired(response.getCode())){thrownewBusinessException("第三方支付失败: "+response.getMsg());}else{// 某些错误码不需要回滚(比如参数错误,本地数据是正确的)log.warn("第三方返回错误,但不回滚: code={}",response.getCode());}}}catch(BusinessExceptione){// 业务异常,直接抛出throwe;}catch(Exceptione){// 系统异常,包装后抛出log.error("调用第三方接口异常",e);thrownewSystemException("第三方接口调用失败",e);}}/** * 判断错误码是否需要回滚 */privatebooleanisRollbackRequired(StringerrorCode){// 根据业务规则判断// 比如:余额不足、系统错误等需要回滚// 参数错误、重复请求等不需要回滚return!"PARAM_ERROR".equals(errorCode)&&!"DUPLICATE_REQUEST".equals(errorCode);}}方式三:使用自定义异常和异常处理器
/** * 第三方调用异常 */publicclassThirdPartyExceptionextendsRuntimeException{privateStringerrorCode;privateStringerrorMsg;publicThirdPartyException(StringerrorCode,StringerrorMsg){super("第三方调用失败: code="+errorCode+", msg="+errorMsg);this.errorCode=errorCode;this.errorMsg=errorMsg;}// getter/setter}@Service@Slf4jpublicclassOrderService{@GlobalTransactional(rollbackFor={Exception.class,ThirdPartyException.class})publicvoidcreateOrder(OrderRequestrequest){inventoryService.reduceStock(request.getProductId(),request.getQuantity());orderMapper.insert(order);// 调用第三方ThirdPartyResponseresponse=thirdPartyService.callPayment(request);if(!response.isSuccess()){// 抛出自定义异常thrownewThirdPartyException(response.getCode(),response.getMsg());}pointService.deduct(request.getUserId(),request.getAmount());}}排查步骤
如果遇到"抛出异常但数据没有回滚"的问题,按以下步骤排查:
- 检查异常是否真的抛出了
@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){try{// 业务逻辑thirdPartyService.callPayment(request);}catch(Exceptione){log.error("异常",e);// 在这里打断点,确认异常是否被捕获// 确认这里是否有 throwthrowe;// 必须要有这一行}}- 检查 rollbackFor 配置
// 确认异常类型在 rollbackFor 中@GlobalTransactional(rollbackFor=Exception.class)// 所有异常都回滚// 或者@GlobalTransactional(rollbackFor={BusinessException.class,ThirdPartyException.class})- 检查事务是否真的开启了
@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){// 打印 XID,确认事务是否开启Stringxid=RootContext.getXID();log.info("当前事务 XID: {}",xid);if(xid==null){log.error("事务未开启!");return;}// 业务逻辑}- 检查是否有异步调用
// 确认第三方调用不是异步的// 如果方法上有 @Async,事务不会回滚publicvoidcreateOrder(OrderRequestrequest){// 检查 thirdPartyService.callPayment 是否有 @Async}- 检查日志,确认 Seata 是否收到回滚请求
开启 Seata 的 DEBUG 日志:
logging:level:io.seata:DEBUGio.seata.tm:DEBUGio.seata.rm:DEBUG查看日志中是否有:
Global transaction beginGlobal transaction rollbackBranch transaction rollback
如果没有看到 rollback 日志,说明 Seata 没有收到回滚请求,可能是异常没有正确抛出。
最佳实践
- 统一异常处理:定义业务异常基类,统一处理
publicclassBusinessExceptionextendsRuntimeException{privateStringerrorCode;publicBusinessException(StringerrorCode,Stringmessage){super(message);this.errorCode=errorCode;}}@GlobalTransactional(rollbackFor=BusinessException.class)publicvoidcreateOrder(OrderRequestrequest){// 业务逻辑if(!response.isSuccess()){thrownewBusinessException(response.getCode(),response.getMsg());}}- 使用断言式编程:让代码更清晰
privatevoidassertThirdPartySuccess(ThirdPartyResponseresponse){if(!response.isSuccess()){thrownewBusinessException(response.getCode(),"第三方调用失败: "+response.getMsg());}}@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){inventoryService.reduceStock(request.getProductId());orderMapper.insert(order);ThirdPartyResponseresponse=thirdPartyService.callPayment(request);assertThirdPartySuccess(response);// 断言式,失败直接抛异常pointService.deduct(request.getUserId(),request.getAmount());}- 记录详细日志:方便排查问题
@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){Stringxid=RootContext.getXID();log.info("开始创建订单,XID: {}",xid);try{inventoryService.reduceStock(request.getProductId());log.info("扣库存成功,XID: {}",xid);orderMapper.insert(order);log.info("创建订单成功,XID: {}",xid);ThirdPartyResponseresponse=thirdPartyService.callPayment(request);if(!response.isSuccess()){log.error("第三方支付失败,准备回滚,XID: {}, code: {}, msg: {}",xid,response.getCode(),response.getMsg());thrownewBusinessException(response.getCode(),response.getMsg());}log.info("第三方支付成功,XID: {}",xid);pointService.deduct(request.getUserId(),request.getAmount());log.info("扣积分成功,XID: {}",xid);}catch(Exceptione){log.error("订单创建失败,准备回滚,XID: {}",xid,e);throwe;// 必须重新抛出}}为什么会出现这个问题?
Seata 的 AT 模式只能管理被 Seata 代理的数据源。对于第三方系统:
- 无法控制第三方系统的数据源:第三方系统有自己的数据库,Seata 无法代理
- 无法生成 Undo Log:第三方系统的操作无法被 Seata 拦截
- 无法参与分布式事务:第三方系统可能不支持 Seata,甚至不知道分布式事务的存在
所以,当第三方接口返回错误时:
- 第三方系统可能已经提交了事务(比如已经扣款)
- 或者第三方系统根本没有事务(比如只是记录日志)
- 你的本地系统虽然抛出了异常,但 Seata 只能回滚本地数据库的操作
- 第三方系统的状态无法回滚
解决方案
方案一:最大努力通知模式(推荐)
这是处理异构系统分布式事务最常用的方案。核心思想是:
- 先完成本地事务:保证本地数据的一致性
- 异步通知第三方:通过消息队列异步调用第三方接口
- 补偿机制:如果第三方调用失败,通过定时任务重试或人工补偿
@Service@Slf4jpublicclassOrderService{@AutowiredprivateOrderMapperorderMapper;@AutowiredprivateInventoryServiceinventoryService;@AutowiredprivatePointServicepointService;@AutowiredprivateThirdPartyServicethirdPartyService;@AutowiredprivateCompensationLogMappercompensationLogMapper;@AutowiredprivateRabbitTemplaterabbitTemplate;/** * 创建订单(不包含第三方调用) */@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){// 1. 扣库存(本地事务)inventoryService.reduceStock(request.getProductId(),request.getQuantity());// 2. 创建订单(本地事务)Orderorder=newOrder();order.setUserId(request.getUserId());order.setProductId(request.getProductId());order.setQuantity(request.getQuantity());order.setStatus(OrderStatus.CREATED);// 初始状态:已创建,待支付orderMapper.insert(order);// 3. 扣积分(本地事务)pointService.deduct(request.getUserId(),request.getAmount());// 4. 发送消息到队列,异步调用第三方// 注意:这里不直接调用第三方,而是发送消息sendThirdPartyNotification(order);// 如果上面的步骤都成功,本地事务提交// 如果失败,Seata 会回滚本地数据库的操作}/** * 发送第三方通知消息 */privatevoidsendThirdPartyNotification(Orderorder){ThirdPartyNotificationMessagemessage=newThirdPartyNotificationMessage();message.setOrderId(order.getId());message.setOrderNo(order.getOrderNo());message.setUserId(order.getUserId());message.setAmount(order.getAmount());// 发送到消息队列rabbitTemplate.convertAndSend("third-party.exchange","third-party.payment",JSON.toJSONString(message));// 同时记录补偿日志(用于失败重试)CompensationLoglog=newCompensationLog();log.setOrderId(order.getId());log.setOrderNo(order.getOrderNo());log.setAction("THIRD_PARTY_PAYMENT");log.setStatus(CompensationStatus.PENDING);log.setRetryCount(0);log.setPayload(JSON.toJSONString(message));compensationLogMapper.insert(log);}/** * 消费消息,调用第三方接口 */@RabbitListener(queues="third-party.payment.queue")publicvoidhandleThirdPartyPayment(StringmessageJson){ThirdPartyNotificationMessagemessage=JSON.parseObject(messageJson,ThirdPartyNotificationMessage.class);try{// 调用第三方接口ThirdPartyResponseresponse=thirdPartyService.callPaymentAPI(message);if(response.isSuccess()){// 成功:更新订单状态updateOrderStatus(message.getOrderId(),OrderStatus.PAID);// 更新补偿日志状态updateCompensationLog(message.getOrderId(),CompensationStatus.SUCCESS);log.info("第三方支付成功: orderId={}",message.getOrderId());}else{// 失败:标记需要重试handleThirdPartyFailure(message,response);}}catch(Exceptione){log.error("调用第三方接口失败: orderId={}",message.getOrderId(),e);handleThirdPartyFailure(message,null);}}/** * 处理第三方调用失败 */privatevoidhandleThirdPartyFailure(ThirdPartyNotificationMessagemessage,ThirdPartyResponseresponse){CompensationLoglog=compensationLogMapper.selectByOrderId(message.getOrderId());if(log==null){// 创建补偿日志log=newCompensationLog();log.setOrderId(message.getOrderId());log.setOrderNo(message.getOrderNo());log.setAction("THIRD_PARTY_PAYMENT");log.setStatus(CompensationStatus.FAILED);log.setRetryCount(0);log.setPayload(JSON.toJSONString(message));log.setErrorMsg(response!=null?response.getErrorMsg():"调用异常");compensationLogMapper.insert(log);}else{// 增加重试次数log.setRetryCount(log.getRetryCount()+1);log.setErrorMsg(response!=null?response.getErrorMsg():"调用异常");if(log.getRetryCount()>=3){// 超过最大重试次数,标记为需要人工处理log.setStatus(CompensationStatus.MANUAL_REQUIRED);}compensationLogMapper.updateById(log);}// 如果重试次数未超限,延迟重试if(log.getRetryCount()<3){// 发送延迟消息,30秒后重试rabbitTemplate.convertAndSend("third-party.exchange","third-party.payment.retry",JSON.toJSONString(message),msg->{msg.getMessageProperties().setDelay(30000);// 30秒延迟returnmsg;});}}/** * 定时任务:扫描需要补偿的记录 */@Scheduled(fixedDelay=60000)// 每分钟执行一次publicvoidcompensateFailedTransactions(){List<CompensationLog>logs=compensationLogMapper.selectList(newLambdaQueryWrapper<CompensationLog>().eq(CompensationLog::getStatus,CompensationStatus.FAILED).lt(CompensationLog::getRetryCount,3).lt(CompensationLog::getUpdatedAt,LocalDateTime.now().minusMinutes(5))// 5分钟前的失败记录);for(CompensationLoglog:logs){try{ThirdPartyNotificationMessagemessage=JSON.parseObject(log.getPayload(),ThirdPartyNotificationMessage.class);// 重新发送消息rabbitTemplate.convertAndSend("third-party.exchange","third-party.payment",JSON.toJSONString(message));log.info("重新发送第三方通知: orderId={}",message.getOrderId());}catch(Exceptione){log.error("补偿失败: orderId={}",log.getOrderId(),e);}}}}优点:
- 本地事务可以正常回滚
- 第三方调用失败不影响本地数据一致性
- 通过重试机制保证最终一致性
缺点:
- 只能保证最终一致性,不能保证强一致性
- 需要实现补偿机制
方案二:TCC 模式(如果第三方支持)
如果第三方系统也支持 TCC 模式(比如也是 Java 系统,可以集成 Seata),可以使用 TCC 模式:
@ServicepublicclassOrderService{@AutowiredprivateInventoryTccServiceinventoryTccService;@AutowiredprivateThirdPartyTccServicethirdPartyTccService;@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){// 1. Try 阶段:冻结库存inventoryTccService.tryReduceStock(request.getProductId(),request.getQuantity());// 2. Try 阶段:调用第三方 Try 接口(冻结资源)thirdPartyTccService.tryPayment(request.getUserId(),request.getAmount());// 如果后续步骤失败,Seata 会自动调用 Cancel// 如果全部成功,Seata 会自动调用 Confirm}}// 第三方 TCC 服务接口publicinterfaceThirdPartyTccService{@TwoPhaseBusinessAction(name="thirdPartyPayment",commitMethod="confirmPayment",rollbackMethod="cancelPayment")booleantryPayment(@BusinessActionContextParameter("userId")LonguserId,@BusinessActionContextParameter("amount")BigDecimalamount);booleanconfirmPayment(BusinessActionContextcontext);booleancancelPayment(BusinessActionContextcontext);}前提条件:
- 第三方系统必须支持 TCC 模式
- 第三方系统必须提供 Try、Confirm、Cancel 三个接口
- 第三方系统必须能够参与 Seata 的分布式事务
方案三:Saga 模式 + 补偿接口
如果第三方系统提供补偿接口,可以使用 Saga 模式:
@ServicepublicclassOrderSagaService{@SagaStart@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){reduceStock(request);createOrderRecord(request);callThirdParty(request);// 调用第三方deductPoint(request);}@Compensable(compensationMethod="callThirdPartyCompensate")publicvoidcallThirdParty(OrderRequestrequest){// 调用第三方接口ThirdPartyResponseresponse=thirdPartyService.payment(request);if(!response.isSuccess()){thrownewBusinessException("第三方支付失败: "+response.getErrorMsg());}}publicvoidcallThirdPartyCompensate(OrderRequestrequest){// 补偿:调用第三方的退款接口try{thirdPartyService.refund(request.getOrderId());}catch(Exceptione){log.error("第三方退款失败,需要人工处理: orderId={}",request.getOrderId(),e);// 记录到补偿日志,人工处理recordCompensationLog(request.getOrderId(),"THIRD_PARTY_REFUND_FAILED");}}}前提条件:
- 第三方系统必须提供补偿接口(如退款接口)
- 补偿接口必须是幂等的
方案四:本地消息表 + 两阶段提交(伪)
如果第三方系统支持两阶段提交协议,可以实现一个伪两阶段提交:
@ServicepublicclassOrderService{@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){// 1. 本地操作inventoryService.reduceStock(request.getProductId());orderService.create(request);// 2. 调用第三方(第一阶段:准备)ThirdPartyPrepareResponseprepareResponse=thirdPartyService.prepare(request);if(!prepareResponse.isPrepared()){thrownewBusinessException("第三方准备失败");}// 3. 记录第三方事务IDStringthirdPartyTxId=prepareResponse.getTransactionId();orderMapper.updateThirdPartyTxId(request.getOrderId(),thirdPartyTxId);// 如果后续步骤失败,需要调用第三方的 Cancel 接口}/** * 事务提交后的回调(需要 Seata 的 Hook) */@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)publicvoidafterCommit(OrderCreatedEventevent){// 事务提交后,调用第三方的 Confirm 接口try{thirdPartyService.confirm(event.getThirdPartyTxId());}catch(Exceptione){log.error("第三方确认失败,需要补偿: txId={}",event.getThirdPartyTxId(),e);// 记录补偿日志recordCompensationLog(event.getOrderId(),"THIRD_PARTY_CONFIRM_FAILED");}}/** * 事务回滚后的回调 */@TransactionalEventListener(phase=TransactionPhase.AFTER_ROLLBACK)publicvoidafterRollback(OrderCreatedEventevent){// 事务回滚后,调用第三方的 Cancel 接口if(event.getThirdPartyTxId()!=null){try{thirdPartyService.cancel(event.getThirdPartyTxId());}catch(Exceptione){log.error("第三方取消失败: txId={}",event.getThirdPartyTxId(),e);}}}}注意:这个方案需要第三方系统支持两阶段提交协议,实际项目中很少见。
最佳实践建议
对于异构系统分布式事务,我推荐使用最大努力通知模式:
- 本地事务保证强一致性:使用 Seata 保证本地数据库操作的原子性
- 第三方调用异步化:通过消息队列异步调用第三方,避免阻塞本地事务
- 补偿机制:如果第三方调用失败,通过重试或人工补偿保证最终一致性
- 状态机管理:使用状态机管理订单状态,清晰标识每个阶段
// 订单状态枚举publicenumOrderStatus{CREATED,// 已创建,待支付PAYING,// 支付中(已发送第三方通知)PAID,// 已支付(第三方确认成功)PAY_FAILED,// 支付失败(第三方返回失败)CANCELLED,// 已取消REFUNDING,// 退款中REFUNDED// 已退款}总结
异构系统分布式事务是 Seata 的局限性,因为 Seata 只能管理被它代理的资源。对于第三方系统:
- Seata AT 模式无法直接支持:因为无法代理第三方系统的数据源
- 推荐使用最大努力通知模式:本地事务 + 异步通知 + 补偿机制
- 如果第三方支持 TCC/Saga:可以考虑使用对应的模式
- 关键是要有补偿机制:保证最终一致性
记住:分布式事务不是万能的,对于异构系统,最终一致性往往比强一致性更实际。
如何选择?
模式对比
| 特性 | AT 模式 | TCC 模式 | Saga 模式 |
|---|---|---|---|
| 开发成本 | 低(只需加注解) | 高(需要实现三个方法) | 中(需要实现补偿) |
| 性能 | 中等(需要 Undo Log) | 高(无需 Undo Log) | 高(无锁) |
| 一致性 | 强一致 | 强一致 | 最终一致 |
| 锁机制 | 全局锁 | 业务锁 | 无锁 |
| 适用场景 | 标准 CRUD 操作 | 高性能核心链路 | 长事务、异步流程 |
| 数据库要求 | 需要支持事务 | 不需要 | 不需要 |
| 复杂度 | 低 | 高(需要处理幂等等) | 中 |
选择建议
使用 AT 模式的情况
- 标准 CRUD 操作:INSERT、UPDATE、DELETE 语句
- 开发成本要求低:希望快速接入分布式事务
- 性能要求不是特别高:TPS < 5000
- 强一致性要求:需要强一致性保证
- 同构系统:所有参与的服务都是 Java 系统,使用相同的数据源类型
示例场景:
- 订单创建(创建订单、扣库存、扣积分)-都是本地服务
- 支付流程(扣款、更新订单状态、发通知)-都是本地服务
- 账户操作(转账、充值、提现)-都是本地服务
不适用场景:
- ❌ 需要调用第三方系统(如第三方支付、第三方物流)
- ❌ 需要调用非 Java 系统(如 PHP、Python 服务)
- ❌ 第三方系统不支持 Seata
使用 TCC 模式的情况
- 高性能要求:TPS > 10000
- 核心链路:对性能要求极高的核心业务
- 复杂业务逻辑:无法用简单 SQL 实现的业务
- 有充足开发资源:可以投入时间实现 TCC 逻辑
示例场景:
- 秒杀场景(库存扣减)
- 账户余额操作(需要精确控制)
- 复杂计算场景(需要多步骤计算)
使用 Saga 模式的情况
- 长事务:事务执行时间不确定或很长
- 异步流程:可以接受最终一致性
- 业务流程复杂:涉及多个步骤,需要灵活控制
示例场景:
- 订单退款(涉及多个服务,可能需要人工审核)
- 数据同步(跨系统数据同步)
- 工作流(多步骤审批流程)
混合使用
在实际项目中,我们通常是混合使用:
@ServicepublicclassOrderService{// 标准订单创建用 AT 模式@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){inventoryService.reduceStock(request.getProductId());orderMapper.insert(order);pointService.deduct(request.getUserId());}// 秒杀订单用 TCC 模式@GlobalTransactionalpublicvoidcreateSeckillOrder(OrderRequestrequest){inventoryTccService.tryReduceStock(request.getProductId());orderMapper.insert(order);// TCC Confirm 会在全局事务提交时自动调用}// 退款流程用 Saga 模式@SagaStartpublicvoidrefundOrder(LongorderId){// 状态机控制流程sagaEngine.start("refundSaga",orderId);}}我们在项目中主要用 AT 模式,只有对性能要求特别高的核心链路(如秒杀)才考虑用 TCC。对于退款、数据同步等长流程,使用 Saga 模式。
最佳实践
1. 事务边界设计
避免长事务
// ❌ 不推荐:事务范围太大@GlobalTransactionalpublicvoidprocessOrder(OrderRequestrequest){// 1. 参数校验(不需要事务)validate(request);// 2. 业务处理(需要事务)createOrder(request);// 3. 发送消息(不需要事务)sendMessage(request);// 4. 记录日志(不需要事务)logOperation(request);}// ✅ 推荐:只对需要的事务操作加注解publicvoidprocessOrder(OrderRequestrequest){// 1. 参数校验validate(request);// 2. 业务处理(需要事务的部分)doCreateOrder(request);// 3. 发送消息(事务外)sendMessage(request);// 4. 记录日志(事务外)logOperation(request);}@GlobalTransactionalprivatevoiddoCreateOrder(OrderRequestrequest){createOrder(request);}合理拆分事务
// ❌ 不推荐:一个大事务处理所有逻辑@GlobalTransactionalpublicvoidbatchProcess(List<OrderRequest>requests){for(OrderRequestrequest:requests){createOrder(request);sendNotification(request);updateStatistics(request);}}// ✅ 推荐:按业务语义拆分publicvoidbatchProcess(List<OrderRequest>requests){// 每个订单一个事务for(OrderRequestrequest:requests){processSingleOrder(request);}// 统计更新用一个事务updateStatisticsBatch(requests);}@GlobalTransactionalprivatevoidprocessSingleOrder(OrderRequestrequest){createOrder(request);sendNotification(request);}2. 异常处理
统一异常处理
@GlobalTransactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderRequestrequest){try{inventoryService.reduceStock(request.getProductId());orderService.create(request);pointService.deduct(request.getUserId());}catch(BusinessExceptione){// 业务异常,需要回滚log.error("订单创建失败",e);throwe;// 抛出异常触发回滚}catch(Exceptione){// 系统异常,也需要回滚log.error("系统异常",e);thrownewSystemException("订单创建失败",e);}}区分可回滚和不可回滚的异常
@GlobalTransactional(rollbackFor={BusinessException.class,SystemException.class})publicvoidcreateOrder(OrderRequestrequest){// 如果抛出 BusinessException 或 SystemException,会回滚// 如果抛出其他异常(如日志记录失败),不回滚inventoryService.reduceStock(request.getProductId());orderService.create(request);try{logService.log(request);// 日志失败不影响主事务}catch(Exceptione){log.error("日志记录失败",e);}}3. 幂等性设计
使用唯一键保证幂等
@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){// 使用订单号作为唯一键StringorderNo=generateOrderNo(request);// 先检查是否已存在OrderexistOrder=orderMapper.selectByOrderNo(orderNo);if(existOrder!=null){log.warn("订单已存在,直接返回: orderNo={}",orderNo);returnexistOrder;}// 创建订单(数据库唯一键约束也会保证幂等)Orderorder=newOrder();order.setOrderNo(orderNo);orderMapper.insert(order);returnorder;}使用分布式锁保证幂等
@ServicepublicclassOrderService{@AutowiredprivateRedisTemplate<String,String>redisTemplate;@GlobalTransactionalpublicvoidcreateOrder(OrderRequestrequest){StringlockKey="order:create:"+request.getOrderNo();StringlockValue=UUID.randomUUID().toString();try{// 尝试获取锁Booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,30,TimeUnit.SECONDS);if(!locked){thrownewBusinessException("订单正在处理中,请勿重复提交");}// 执行业务逻辑doCreateOrder(request);}finally{// 释放锁releaseLock(lockKey,lockValue);}}}4. 监控和告警
集成 Prometheus 监控
@ComponentpublicclassSeataMetricsCollector{privatefinalCountertransactionBeginCounter;privatefinalCountertransactionCommitCounter;privatefinalCountertransactionRollbackCounter;privatefinalTimertransactionDurationTimer;publicSeataMetricsCollector(MeterRegistrymeterRegistry){this.transactionBeginCounter=Counter.builder("seata.transaction.begin").description("全局事务开始次数").register(meterRegistry);this.transactionCommitCounter=Counter.builder("seata.transaction.commit").description("全局事务提交次数").register(meterRegistry);this.transactionRollbackCounter=Counter.builder("seata.transaction.rollback").description("全局事务回滚次数").register(meterRegistry);this.transactionDurationTimer=Timer.builder("seata.transaction.duration").description("全局事务耗时").register(meterRegistry);}@EventListenerpublicvoidonGlobalTransactionBegin(GlobalTransactionBeginEventevent){transactionBeginCounter.increment();}@EventListenerpublicvoidonGlobalTransactionCommit(GlobalTransactionCommitEventevent){transactionCommitCounter.increment();transactionDurationTimer.record(Duration.between(event.getBeginTime(),Instant.now()));}@EventListenerpublicvoidonGlobalTransactionRollback(GlobalTransactionRollbackEventevent){transactionRollbackCounter.increment();}}告警配置
# Prometheus 告警规则groups:-name:seata_alertsrules:-alert:SeataHighRollbackRateexpr:rate(seata_transaction_rollback_total[5m]) / rate(seata_transaction_begin_total[5m])>0.1for:5mannotations:summary:"Seata 回滚率过高"description:"过去5分钟回滚率超过10%"-alert:SeataHighTransactionDurationexpr:histogram_quantile(0.99,seata_transaction_duration_seconds_bucket)>5for:5mannotations:summary:"Seata 事务耗时过高"description:"P99 事务耗时超过5秒"5. 压测和性能调优
压测脚本示例
@SpringBootTestpublicclassSeataPerformanceTest{@AutowiredprivateOrderServiceorderService;@TestpublicvoidtestCreateOrderPerformance(){intthreadCount=100;intrequestPerThread=100;CountDownLatchlatch=newCountDownLatch(threadCount);AtomicIntegersuccessCount=newAtomicInteger(0);AtomicIntegerfailCount=newAtomicInteger(0);List<Long>durations=newCopyOnWriteArrayList<>();ExecutorServiceexecutor=Executors.newFixedThreadPool(threadCount);longstartTime=System.currentTimeMillis();for(inti=0;i<threadCount;i++){executor.submit(()->{try{for(intj=0;j<requestPerThread;j++){longbegin=System.currentTimeMillis();try{orderService.createOrder(createTestRequest());successCount.incrementAndGet();}catch(Exceptione){failCount.incrementAndGet();}finally{durations.add(System.currentTimeMillis()-begin);}}}finally{latch.countDown();}});}latch.await();longtotalTime=System.currentTimeMillis()-startTime;// 统计结果doubletps=(double)(successCount.get()+failCount.get())*1000/totalTime;doubleavgDuration=durations.stream().mapToLong(Long::longValue).average().orElse(0);longp99Duration=durations.stream().sorted().skip((long)(durations.size()*0.99)).findFirst().orElse(0L);System.out.println("总请求数: "+(successCount.get()+failCount.get()));System.out.println("成功数: "+successCount.get());System.out.println("失败数: "+failCount.get());System.out.println("TPS: "+tps);System.out.println("平均耗时: "+avgDuration+"ms");System.out.println("P99耗时: "+p99Duration+"ms");}}性能调优参数
seata:client:rm:# 异步提交缓冲区大小async-commit-buffer-limit:10000# 报告重试次数report-retry-count:5# 锁重试间隔(毫秒)lock-retry-interval:10# 锁重试次数lock-retry-times:30tm:# 提交重试次数commit-retry-count:5# 回滚重试次数rollback-retry-count:5undo:# 只记录变更字段only-care-update-columns:true# 使用高效的序列化方式log-serialization:kryo6. 故障排查
常见问题排查清单
事务不回滚
- 检查是否抛出了异常
- 检查
rollbackFor配置 - 检查是否有
try-catch吞掉了异常
Undo Log 清理失败
- 检查数据库连接是否正常
- 检查 Undo Log 表结构是否正确
- 检查是否有长时间未提交的事务
性能下降
- 检查 Undo Log 表大小,定期清理
- 检查是否有长事务
- 检查数据库连接池配置
XID 传递失败
- 检查 Feign/Dubbo 拦截器是否配置
- 检查请求头/上下文是否被过滤
- 检查异步调用是否正确传递 XID
日志配置
logging:level:io.seata:DEBUGio.seata.rm.datasource:DEBUGio.seata.tm:DEBUGpattern:console:"%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"总结
分布式事务是一个复杂的问题,没有银弹。Seata 提供了一套相对完善的解决方案,但在实际使用中还是会遇到各种问题。
我的建议是:
优先考虑是否真的需要强一致性:很多时候最终一致性就足够了,可以用消息队列、事件驱动等方式实现。
如果确实需要分布式事务,AT 模式是个不错的起点:开发成本低,适合大部分场景。
注意性能影响,做好监控和优化:定期检查 Undo Log 表大小,监控事务耗时和回滚率。
关键业务要做好降级方案:比如可以手动补偿数据,或者提供数据修复工具。
合理设计事务边界:避免长事务,合理拆分事务。
做好幂等性设计:使用唯一键、分布式锁等方式保证幂等。
最后,分布式事务只是解决数据一致性的一种手段,但不是唯一手段。在架构设计时,可以通过合理拆分服务、减少跨服务事务来降低复杂度。毕竟,最好的分布式事务就是没有分布式事务。
如果业务允许,尽量通过以下方式避免分布式事务:
- 数据库拆分时考虑业务边界:相关的数据尽量放在同一个数据库中
- 使用最终一致性:对于非核心业务,可以使用消息队列保证最终一致性
- 业务补偿:对于一些场景,可以接受短暂不一致,通过补偿机制最终达到一致
分布式事务是一把双刃剑,它在解决一致性问题的同时,也带来了复杂性和性能损耗。我们需要在一致性和性能之间找到平衡点。
附录
A. 完整项目结构示例
seata-demo/ ├── seata-server/ # Seata Server 配置 │ ├── conf/ │ │ └── application.yml │ └── script/ │ └── db/ │ └── mysql.sql ├── order-service/ # 订单服务 │ ├── src/main/java/ │ │ └── com/example/order/ │ │ ├── OrderApplication.java │ │ ├── config/ │ │ │ └── DataSourceConfig.java │ │ ├── controller/ │ │ │ └── OrderController.java │ │ ├── service/ │ │ │ └── OrderService.java │ │ └── mapper/ │ │ └── OrderMapper.java │ └── src/main/resources/ │ └── application.yml ├── inventory-service/ # 库存服务 │ └── ... ├── point-service/ # 积分服务 │ └── ... └── common/ # 公共模块 └── ...B. 常用 SQL 脚本
创建 Undo Log 表
CREATETABLEundo_log(idBIGINT(20)NOTNULLAUTO_INCREMENTCOMMENT'increment id',branch_idBIGINT(20)NOTNULLCOMMENT'branch transaction id',xidVARCHAR(100)NOTNULLCOMMENT'global transaction id',contextVARCHAR(128)NOTNULLCOMMENT'undo_log context,such as serialization',rollback_infoLONGBLOBNOTNULLCOMMENT'rollback info',log_statusINT(11)NOTNULLCOMMENT'0:normal status,1:defense status',log_createdDATETIME(6)NOTNULLCOMMENT'create datetime',log_modifiedDATETIME(6)NOTNULLCOMMENT'modify datetime',PRIMARYKEY(id),UNIQUEKEYux_undo_log(xid,branch_id))ENGINE=InnoDBAUTO_INCREMENT=1DEFAULTCHARSET=utf8COMMENT='AT transaction mode undo table';清理历史 Undo Log
-- 清理7天前的 Undo LogDELETEFROMundo_logWHERElog_created<DATE_SUB(NOW(),INTERVAL7DAY)ANDlog_status=1;-- 或者定期归档CREATETABLEundo_log_archiveLIKEundo_log;INSERTINTOundo_log_archiveSELECT*FROMundo_logWHERElog_created<DATE_SUB(NOW(),INTERVAL7DAY);DELETEFROMundo_logWHERElog_created<DATE_SUB(NOW(),INTERVAL7DAY);C. 配置参考
Nacos 配置示例
在 Nacos 中创建配置,Data ID:seataServer.properties,Group:SEATA_GROUP
# 事务组配置 service.vgroupMapping.my_test_tx_group=default service.default.grouplist=127.0.0.1:8091 service.enableDegrade=false service.disableGlobalTransaction=false # 传输配置 transport.type=TCP transport.server=NIO transport.heartbeat=true transport.enableClientBatchSendRequest=true # 存储配置 store.mode=db store.db.datasource=druid store.db.dbType=mysql store.db.driverClassName=com.mysql.cj.jdbc.Driver store.db.url=jdbc:mysql://127.0.0.1:3306/seata?rewriteBatchedStatements=true store.db.user=root store.db.password=your_password store.db.minConn=5 store.db.maxConn=100 store.db.globalTable=global_table store.db.branchTable=branch_table store.db.lockTable=lock_table store.db.queryLimit=100 store.db.maxWait=5000 # 事务配置 server.recovery.committingRetryPeriod=1000 server.recovery.asynCommittingRetryPeriod=1000 server.recovery.rollbackingRetryPeriod=1000 server.recovery.timeoutRetryPeriod=1000 server.maxCommitRetryTimeout=-1 server.maxRollbackRetryTimeout=-1 server.rollbackRetryTimeoutUnlockEnable=false # 指标配置 metrics.enabled=false metrics.registryType=compact metrics.exporterList=prometheus metrics.exporterPrometheusPort=9898D. 常见错误及解决方案
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
Cannot get table meta for table xxx | 表不存在或表名错误 | 检查表名是否正确,是否有权限 |
Primary key is null | 表没有主键 | 为表添加主键 |
Global lock acquire failed | 全局锁获取失败 | 检查是否有其他事务持有锁,增加重试次数 |
XID is not found | XID 传递失败 | 检查 Feign/Dubbo 拦截器配置 |
Undo log not found | Undo Log 不存在 | 检查 Undo Log 表是否正确创建 |
Branch transaction timeout | 分支事务超时 | 增加超时时间或优化业务逻辑 |
第三方系统调用失败,本地数据未回滚 | 第三方系统无法参与 Seata 事务 | 使用最大努力通知模式,异步调用第三方 |
E. 性能基准测试结果
测试环境:
- CPU: 8核
- 内存: 16GB
- 数据库: MySQL 5.7
- Seata Server: 单机部署
测试场景:
- 3个服务参与分布式事务
- 每个服务执行1次 UPDATE 操作
测试结果:
| 并发数 | TPS | 平均耗时 | P99耗时 | 成功率 |
|---|---|---|---|---|
| 100 | 450 | 220ms | 450ms | 99.8% |
| 500 | 1200 | 420ms | 850ms | 99.5% |
| 1000 | 1800 | 550ms | 1200ms | 98.9% |
| 2000 | 2200 | 910ms | 2000ms | 97.5% |
结论:
- 在并发数 < 1000 时,Seata AT 模式性能表现良好
- 当并发数 > 1000 时,建议使用 TCC 模式或优化数据库性能
参考资料
- Seata 官方文档
- Seata GitHub
- 分布式事务解决方案
- Seata 源码解析
- 分布式事务理论基础
作者注:这篇文章是基于我们在生产环境使用 Seata 的实践经验总结而成。如果你在使用过程中遇到问题,欢迎交流讨论。也欢迎关注我的技术博客,我会持续分享更多分布式系统的实践经验。