1. 项目概述:为什么“分布式事务”是在线教育系统绕不开的硬骨头
“学成在线笔记十五:分布式事务(完结)”这个标题,乍看像是一份课程学习记录,但背后藏着一个真实业务场景里最棘手的工程难题——当一个用户点击“立即购买课程”,系统要同时完成扣减账户余额、生成订单、更新课程学习权限、触发学习提醒、记录支付日志这五六个动作,而这些动作分散在账户服务、订单服务、权限服务、消息中心、日志服务等至少四个独立部署的微服务中。任何一个环节失败,整个流程就必须回滚,否则就会出现“钱扣了但没下单”“订单生成了但用户看不到课”这种线上事故。这就是典型的分布式事务问题。
我带过好几个在线教育类项目,从早期单体架构升级到Spring Cloud微服务后,前十四篇笔记讲的都是服务拆分、API网关、熔断降级、配置中心这些“看得见”的改造,唯独第十五篇,专门留给“分布式事务”——不是因为它不重要,恰恰是因为它太重要,又太容易被低估。很多团队上线初期用本地事务+人工对账兜底,结果三个月后对账脚本越写越长,凌晨三点被财务电话叫醒查“32笔订单显示已支付但未到账”的情况成了常态。所谓“完结”,不是说问题彻底消失,而是建立起一套可监控、可补偿、可追溯、能承受真实流量冲击的事务保障机制。
这个内容适合三类人:一是正在做微服务落地的后端开发,尤其刚从单体转型、对跨服务数据一致性还没形成肌肉记忆;二是技术负责人或架构师,需要评估不同方案对系统复杂度、性能、运维成本的真实影响;三是测试和运维同学,理解事务边界才能设计出有效的异常注入测试用例,也才能看懂监控大盘里那条突兀的“Saga补偿失败率”曲线。它不教你怎么写Hello World,而是告诉你:当用户在页面点下“确认支付”的那一刻,背后至少有七层事务保障逻辑在同时运转,而你得知道哪一层该信任、哪一层必须盯死、哪一层出了问题该先查什么日志。
2. 分布式事务的本质与选型逻辑:为什么不用XA,也不全靠TCC
2.1 事务的ACID在分布式环境下的坍塌现场
先说清楚一个根本问题:单体应用里一个@Transactional注解就能搞定的事,在微服务里为什么变得如此复杂?核心在于ACID中的I(隔离性)和D(持久性)被物理网络和独立数据库撕开了口子。
举个具体例子:用户A购买《Python数据分析实战》课程,价格99元。在单体系统中,所有操作都在同一个数据库连接里执行,事务管理器天然保证原子性。但在微服务架构下:
- 账户服务调用
update_balance(user_id, -99),在自己的MySQL实例上执行; - 订单服务调用
insert_order(...),在另一台MySQL上插入新订单; - 权限服务调用
grant_course_access(user_id, course_id),在Redis集群里设置一个key; - 消息中心调用
send_notification(...),往Kafka写一条消息。
这四个操作分布在四台物理机器上,没有全局事务协调者。如果账户扣款成功,订单插入失败,系统就处于“钱没了但课没买成”的中间态。传统数据库的ACID在这里完全失效——因为每个服务只对自己的数据库负责,无法感知其他服务的状态。
提示:这不是理论风险。我们某次灰度发布时,因权限服务部署包版本错误导致
grant_course_access接口返回500,但账户和订单均已提交。当天产生17笔“幽灵扣款”,全部靠人工核对流水+客服补发权限才解决。这件事直接推动了第十五篇笔记的诞生。
2.2 主流方案对比:从强一致到最终一致的光谱选择
面对这个问题,业界演化出几条技术路径,它们不是非此即彼的替代关系,而是一个按业务容忍度排列的连续光谱:
| 方案类型 | 代表实现 | 一致性模型 | 典型适用场景 | 对开发侵入性 | 运维复杂度 |
|---|---|---|---|---|---|
| 两阶段提交(2PC) | Seata AT模式、Atomikos | 强一致 | 金融级核心交易(如实时资金划转) | 低(注解驱动) | 高(需部署TC组件) |
| TCC模式 | Seata TCC、自研框架 | 最终一致(补偿后) | 高并发、高可用要求场景(如秒杀下单) | 高(需写Try/Confirm/Cancel三套逻辑) | 中(依赖补偿幂等) |
| Saga模式 | Axon Framework、Eventuate Tram | 最终一致(事件驱动) | 流程长、参与方多、异步化程度高(如课程购买全流程) | 中(需定义事件与补偿) | 中(需事件存储与重试) |
| 最大努力通知 | 自研HTTP回调+定时任务 | 弱一致(尽力而为) | 非核心链路(如发送学习提醒、更新推荐权重) | 低(仅需提供回调地址) | 低(但需容忍丢失) |
我们最终在“学成在线”项目中采用的是Saga + 补偿事务 + 本地消息表的混合方案,而不是纯2PC或纯TCC。原因很实际:
不用2PC:Seata AT模式虽对代码侵入小,但其事务协调器(TC)是单点瓶颈,压测时QPS超过3000后TC自身CPU飙升至95%,且一旦TC宕机,所有进行中的事务会卡死。在线教育平台大促期间峰值QPS常达8000+,这个风险我们不敢赌。
不用纯TCC:TCC要求每个服务都提供Cancel能力,但像“消息中心发送通知”这种操作,Kafka消息一旦发出就无法真正“撤回”。强行实现Cancel只能是“再发一条取消通知”,这反而增加了下游处理复杂度。而且TCC的Try阶段要预留资源(如冻结余额),在课程库存有限时,冻结逻辑会和真实抢购冲突,引发超卖。
选择Saga:把整个购买流程拆解为可逆的原子步骤:
扣余额→创订单→授权限→发通知,每一步失败都触发前序步骤的补偿操作(退余额→删订单→撤权限→忽略通知)。关键在于,Saga不要求所有步骤实时同步,允许短暂不一致,只要最终能回到一致状态即可。这对用户感知影响最小——他看到“支付成功”页面时,后台可能还在异步处理权限授予,但只要10秒内完成,体验就是无缝的。
2.3 Saga模式的两种实现形态:Choreography vs Orchestration
Saga不是一种具体技术,而是一种设计思想,落地时有两种主流形态,我们选了后者:
Choreography(编排式):各服务通过事件总线(如Kafka)互相通信,订单服务发“OrderCreated”事件,权限服务监听后执行授权,再发“AccessGranted”事件……优点是去中心化,缺点是流程逻辑分散,调试困难。曾有个Bug是权限服务消费延迟导致用户等了2分钟才看到课程,排查时要翻遍4个服务的日志和Kafka消费位点,耗时3小时。
Orchestration(编排式):引入一个轻量级“事务协调器”(我们叫它OrderSagaService),由它统一控制流程。它不参与业务逻辑,只负责按顺序调用各服务接口,并在失败时驱动补偿。伪代码如下:
public void executePurchaseSaga(Long userId, Long courseId) { // Step 1: 扣余额 BalanceResult balanceResult = balanceService.deduct(userId, 99); if (!balanceResult.isSuccess()) { throw new SagaException("余额扣减失败"); } // Step 2: 创建订单 Order order = orderService.create(userId, courseId, 99); if (order == null) { // 补偿:退回余额 balanceService.refund(userId, 99); throw new SagaException("订单创建失败"); } // Step 3: 授予权限(此处用异步消息解耦) messageService.sendGrantAccessMessage(userId, courseId); // Step 4: 发送通知(同样异步) messageService.sendPurchaseSuccessMessage(userId, courseId); }这种方式让主流程一目了然,所有补偿逻辑集中在协调器内,新增一个步骤只需改一处代码。更重要的是,它天然支持“暂停-恢复”能力——当权限服务临时不可用时,协调器可以把当前状态存到数据库(如
saga_instance表),等服务恢复后再继续执行,避免整个流程阻塞。
注意:Orchestration模式的协调器本身必须是无状态的,所有状态(如当前执行到哪一步、参数是什么)必须持久化到共享存储(我们用MySQL)。我们曾把状态存在本地内存,结果协调器Pod重启后,正在执行的Saga全部丢失,导致用户重复扣款。这是踩过最痛的坑之一。
3. 核心实现细节:本地消息表如何解决“发消息”和“更新DB”的原子性
3.1 问题根源:为什么“先更新DB再发MQ”一定会丢消息
Saga流程中,第三步“授予权限”我们选择异步化,因为权限服务调用涉及Redis集群和多个缓存策略,平均耗时80ms,同步等待会拖慢主流程。但这就引出新问题:如何保证“订单创建成功”和“发送授予权限消息”这两个操作的原子性?
常见错误做法是:
// ❌ 危险!存在消息丢失风险 orderService.create(...); // 更新订单表 kafkaTemplate.send("grant-access-topic", message); // 发送Kafka消息这段代码看似简单,但存在经典“双写”问题:如果create()成功,send()因网络抖动失败,消息就永久丢失,用户永远看不到课程。反之,如果send()成功但create()因数据库主从延迟写入失败,又会产生“消息已发但订单不存在”的脏数据。
根本原因在于:数据库事务和消息队列是两个独立系统,无法纳入同一个ACID事务。你不能在一个MySQL事务里commit Kafka offset,就像不能在Kafka里rollback MySQL binlog。
3.2 解决方案:本地消息表 + 定时扫描 + 幂等消费
我们采用业界验证过的“本地消息表”方案,核心思想是:把消息当作业务数据的一部分,和业务操作一起写入同一张数据库表,利用数据库事务的原子性保证二者强一致。
具体实现分三步:
第一步:设计本地消息表结构
CREATE TABLE `local_message` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `topic` VARCHAR(64) NOT NULL COMMENT '目标Topic', `message_key` VARCHAR(128) NOT NULL COMMENT '消息Key,用于幂等', `message_body` TEXT NOT NULL COMMENT 'JSON序列化消息体', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待发送,1-已发送,2-发送失败', `retry_count` TINYINT NOT NULL DEFAULT 0 COMMENT '重试次数', `next_retry_time` DATETIME COMMENT '下次重试时间', `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_status_next_time (`status`, `next_retry_time`) ) ENGINE=InnoDB COMMENT='本地消息表';关键设计点:
message_key是业务唯一标识,比如grant_access_user_123456_course_789,下游消费时用它做幂等判断;status字段区分消息生命周期,避免重复投递;next_retry_time支持指数退避重试(第一次1秒后,第二次3秒后,第三次10秒后……),防止雪崩。
第二步:在订单创建事务中写入本地消息
@Transactional public Order createOrderWithMessage(Long userId, Long courseId, BigDecimal amount) { // 1. 创建订单(主业务) Order order = new Order(); order.setUserId(userId); order.setCourseId(courseId); order.setStatus(OrderStatus.PAID); orderMapper.insert(order); // 2. 写入本地消息表(同一事务!) LocalMessage message = new LocalMessage(); message.setTopic("grant-access-topic"); message.setMessageKey("grant_access_user_" + userId + "_course_" + courseId); message.setMessageBody(JSON.toJSONString(new GrantAccessPayload(userId, courseId))); message.setStatus(0); // 待发送 message.setNextRetryTime(new Date(System.currentTimeMillis() + 1000)); // 1秒后首次重试 localMessageMapper.insert(message); return order; }这里的关键是@Transactional将订单插入和消息插入包裹在同一个MySQL事务中。要么两者都成功,要么都回滚。即使Kafka集群宕机,消息也稳稳躺在MySQL里,不会丢失。
第三步:独立的消息发送服务定时扫描并投递
我们部署一个独立的message-sender服务,它不参与业务,只做一件事:定时扫描local_message表,找出status=0且next_retry_time <= now()的消息,尝试发送到Kafka,并根据结果更新状态。
// 每5秒扫描一次 @Scheduled(fixedDelay = 5000) public void sendPendingMessages() { List<LocalMessage> pending = localMessageMapper.selectByStatusAndTime(0, new Date()); for (LocalMessage msg : pending) { try { kafkaTemplate.send(msg.getTopic(), msg.getMessageKey(), msg.getMessageBody()); // 发送成功,更新状态为已发送 msg.setStatus(1); msg.setUpdatedTime(new Date()); localMessageMapper.updateById(msg); } catch (Exception e) { // 发送失败,更新状态为失败,增加重试次数 msg.setStatus(2); msg.setRetryCount(msg.getRetryCount() + 1); // 指数退避:重试次数越多,等待越久 long delay = (long) Math.pow(2, msg.getRetryCount()) * 1000; msg.setNextRetryTime(new Date(System.currentTimeMillis() + delay)); localMessageMapper.updateById(msg); log.error("消息发送失败,topic:{}, key:{}", msg.getTopic(), msg.getMessageKey(), e); } } }第四步:下游服务消费时做幂等校验
权限服务消费Kafka消息时,必须用message_key做幂等:
@KafkaListener(topics = "grant-access-topic") public void onGrantAccessMessage(ConsumerRecord<String, String> record) { String messageKey = record.key(); String messageBody = record.value(); // 1. 先查是否已处理过该key if (grantAccessLogService.existsByKey(messageKey)) { log.info("消息已幂等处理,key:{}", messageKey); return; } // 2. 执行业务逻辑 GrantAccessPayload payload = JSON.parseObject(messageBody, GrantAccessPayload.class); boolean success = permissionService.grant(payload.getUserId(), payload.getCourseId()); // 3. 记录处理日志(用于幂等) if (success) { grantAccessLogService.save(messageKey, payload.getUserId(), payload.getCourseId()); } }grant_access_log表结构很简单:
CREATE TABLE `grant_access_log` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `message_key` VARCHAR(128) UNIQUE NOT NULL, `user_id` BIGINT NOT NULL, `course_id` BIGINT NOT NULL, `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB;这套方案实测下来非常稳。我们做过故障演练:手动kill掉message-sender服务10分钟,期间订单照常创建,本地消息表积压了237条。恢复服务后,它在42秒内全部发送完毕,下游权限服务无一条重复处理。整个过程对用户零感知。
实操心得:本地消息表的
next_retry_time字段一定要建索引!我们最初没加,当积压消息超5000条时,selectByStatusAndTime查询耗时从50ms飙升到2.3秒,导致消息堆积越来越严重。加上索引后,查询稳定在3ms内。
4. 全链路事务追踪与问题定位:如何在5分钟内定位Saga失败根因
4.1 Saga失败的四种典型场景与日志特征
Saga流程失败不等于整个购买失败,它可能发生在任意环节。快速定位是保障SLA的关键。我们总结出四种高频失败场景,每种都有独特的日志指纹:
| 失败环节 | 日志特征 | 常见原因 | 快速定位方法 |
|---|---|---|---|
| Step1 扣余额失败 | BalanceService.deduct: insufficient balance for user 123456 | 用户余额不足、账户被冻结 | 在Kibana搜索"insufficient balance"+ 用户ID |
| Step2 创订单失败 | OrderService.create: duplicate entry for key 'uk_user_course' | 同一用户重复购买同一课程(幂等未生效) | 查order表是否存在该用户课程记录,再查local_message表是否有同key消息 |
| Step3 授权限失败 | PermissionService.grant: redis timeout after 2000ms | Redis集群响应慢、连接池耗尽 | 检查Redis监控(latency、connected_clients)、权限服务GC日志 |
| Step4 发通知失败 | MessageService.send: kafka producer error: NotEnoughReplicasException | Kafka副本数不足、磁盘满 | 查Kafka Broker监控(UnderReplicatedPartitions、DiskUsage) |
最麻烦的是Step2失败后的补偿也失败,比如订单创建失败,系统尝试balanceService.refund(),但退款接口也超时。这时日志里会出现两条连续报错:
ERROR [OrderSagaService] 订单创建失败,开始执行补偿... ERROR [OrderSagaService] 补偿操作[退余额]失败,用户123456余额异常!这种“补偿失败的补偿”就是所谓的“悬挂事务”,必须人工介入。
4.2 构建Saga事务追踪ID体系:从日志大海中捞针
没有统一追踪ID,查日志就是大海捞针。我们在Saga协调器启动时,为每个购买请求生成全局唯一ID:
public class SagaContext { private static final ThreadLocal<String> SAGA_ID = new ThreadLocal<>(); public static String generateSagaId() { String id = "saga-" + System.currentTimeMillis() + "-" + ThreadLocalRandom.current().nextInt(1000, 9999); SAGA_ID.set(id); return id; } public static String getCurrentSagaId() { return SAGA_ID.get(); } }然后在所有相关日志、数据库字段、Kafka消息头中透传这个ID:
- 订单表加
saga_id VARCHAR(64)字段; - 本地消息表加
saga_id VARCHAR(64)字段; - Kafka消息Header里加
X-Saga-ID: saga-1712345678-5678; - 所有服务日志开头打印
[saga-1712345678-5678]。
这样,当用户反馈“我付了钱但没看到课”,客服只要拿到订单号或手机号,我们就能在ELK里一键搜索:
"saga-1712345678-5678" AND ("OrderCreated" OR "GrantAccess" OR "refund")5分钟内就能拉出完整调用链:哪一步失败、失败时的入参、下游返回的错误码、补偿是否执行、补偿的返回结果。比以前靠人工拼接4个服务日志快10倍。
4.3 Saga监控大盘:三个必须盯死的核心指标
我们基于Prometheus + Grafana搭建了Saga专属监控面板,重点关注以下三个黄金指标:
指标1:Saga成功率(全局健康度)
公式:(成功完成的Saga数) / (总发起Saga数)
- 健康阈值:≥99.95%
- 报警规则:5分钟内低于99.9%持续3次,触发企业微信告警
- 异常解读:若突然下跌,优先检查Kafka集群健康度(
UnderReplicatedPartitions > 0)和Redis响应延迟(redis_latency_ms > 100)
指标2:平均Saga执行时长(用户体验)
公式:sum(saga_duration_seconds_sum) / sum(saga_duration_seconds_count)
- 健康阈值:P95 < 1.2秒
- 报警规则:P95 > 2秒持续5分钟
- 异常解读:若时长突增,重点排查权限服务Redis延迟和消息发送服务的
local_message表扫描耗时(SELECT ... WHERE status=0 AND next_retry_time <= ?)
指标3:悬挂事务数(风险兜底)
公式:count of local_message where status=2 and retry_count >= 5
- 健康阈值:= 0
- 报警规则:> 0立即告警
- 异常解读:说明有消息反复重试失败,大概率是下游服务不可用或数据异常。此时需立即登录服务器,手动执行补偿SQL:
-- 示例:手动退还用户123456的99元 UPDATE account_balance SET balance = balance + 99 WHERE user_id = 123456; -- 然后更新本地消息状态 UPDATE local_message SET status = 1 WHERE message_key = 'refund_user_123456';
这个监控大盘上线后,我们处理事务类故障的平均MTTR(平均修复时间)从47分钟降到8分钟。最直观的变化是:运维同学不再需要半夜爬起来查日志,因为90%的问题在报警发出时,指标曲线已经指明了根因方向。
注意事项:Saga监控必须采集“业务维度”而非“技术维度”。比如不要只看
kafka_produce_request_rate,而要看kafka_produce_request_rate{topic="grant-access-topic"}。因为权限服务出问题,只会影响grant-access-topic,不影响其他Topic。粒度太粗的监控会掩盖真实问题。
5. 常见问题与实战排障手册:那些文档里不会写的坑
5.1 问题1:Saga流程中,下游服务重启导致消息重复消费,但幂等逻辑没生效
现象:用户购买后收到两条学习提醒短信,权限服务日志显示同一message_key被处理了两次。
根因分析:我们发现幂等日志表grant_access_log的message_key字段没有建唯一索引!虽然代码里写了existsByKey查询,但并发场景下两个线程同时查到“不存在”,然后都执行了grant操作,最后都去save,第二条INSERT因违反唯一约束失败,但业务逻辑已经执行了。
解决方案:
- 立即给
message_key加唯一索引:ALTER TABLE grant_access_log ADD UNIQUE KEY uk_message_key (message_key); - 修改幂等逻辑,用
INSERT IGNORE代替SELECT + INSERT:// 用INSERT IGNORE尝试插入幂等日志 int inserted = grantAccessLogMapper.insertIgnore(messageKey, userId, courseId); if (inserted == 0) { // 插入失败,说明已存在,直接返回 return; } // 插入成功,执行业务逻辑 permissionService.grant(userId, courseId);
经验教训:幂等不是靠代码逻辑“判断”,而是靠数据库约束“强制”。任何SELECT + INSERT的组合在高并发下都是纸老虎。唯一索引是最后的防线。
5.2 问题2:本地消息表积压,message-sender服务CPU飙升至100%
现象:message-sender服务CPU持续95%以上,local_message表积压超1万条,新订单创建后10分钟内权限仍未授予。
排查过程:
jstack发现大量线程卡在LocalMessageMapper.selectByStatusAndTime方法;EXPLAIN该SQL,发现idx_status_next_time索引未被使用,实际走了全表扫描;- 进一步检查,发现
next_retry_time字段类型是DATETIME,但查询条件用了next_retry_time <= '2024-04-05 10:30:00',而MySQL在某些版本对DATETIME范围查询的索引选择不稳定。
终极修复:
- 将
next_retry_time类型改为BIGINT,存储毫秒时间戳(如1712345678000),避免日期格式解析开销; - 重建索引:
ALTER TABLE local_message DROP INDEX idx_status_next_time, ADD INDEX idx_status_next_time (status, next_retry_time); - 优化查询SQL,确保走索引:
SELECT * FROM local_message WHERE status = 0 AND next_retry_time <= 1712345678000 ORDER BY next_retry_time LIMIT 100;
效果:单次查询从2.3秒降至8ms,CPU回落至35%,积压消息10分钟内清空。
5.3 问题3:Saga补偿操作本身失败,导致用户余额被扣但订单未创建,形成“幽灵扣款”
现象:财务对账发现多笔“支付成功但无对应订单”的记录,金额均为99元。
根因深挖:
- 查
local_message表,发现这些记录status=2(发送失败),retry_count=5(已达最大重试); - 追查
message-sender日志,发现错误是Caused by: org.apache.kafka.common.errors.TimeoutException: Failed to update metadata after 60000 ms.; - 原来是Kafka集群因磁盘满导致元数据更新超时,
message-sender不断重试,但始终失败; - 更致命的是,我们的补偿逻辑写在
OrderService.create()方法里,而该方法抛出异常后,事务回滚,local_message记录也被删了!所以message-sender根本找不到要补偿的消息。
重构方案:
- 补偿逻辑外移:不再把
refund写在create()里,而是由Saga协调器统一管理。订单创建失败时,协调器记录CompensationTask到独立表:CREATE TABLE `compensation_task` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `saga_id` VARCHAR(64) NOT NULL, `compensation_type` VARCHAR(32) NOT NULL, -- "REFUND_BALANCE" `params` TEXT NOT NULL, -- JSON: {"userId":123456,"amount":99} `status` TINYINT DEFAULT 0, -- 0-待执行,1-成功,2-失败 `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); - 独立补偿服务:部署
compensation-executor服务,定时扫描compensation_task表,执行补偿,并记录结果。即使Saga协调器宕机,补偿任务依然存在。
效果:幽灵扣款归零。现在每笔失败的Saga,都会在compensation_task表留下明确记录,运维可以随时手动触发补偿。
5.4 问题4:跨库事务中,MySQL主从延迟导致“读己之写”失败
现象:用户支付成功后立即刷新课程列表页,却看不到刚买的课,等3秒后才出现。
技术背景:订单服务写入主库,权限服务从从库读取订单状态(为减轻主库压力),但MySQL主从延迟平均1.2秒。
解决方案:我们没用复杂的GTID或半同步复制,而是采用“读写分离+强制主库读”策略:
- 在Saga协调器中,订单创建成功后,不直接调用权限服务的普通接口,而是调用一个
grantAccessByOrderId(Long orderId)接口; - 该接口内部,先用
@DS("master")强制从主库查订单,确认存在且状态为PAID,再执行授权逻辑; - 同时,给前端加一个“支付成功”过渡页,文案是“正在为您开通课程权限,请稍候...”,停留2秒后自动跳转,完美覆盖1.2秒延迟。
为什么不用缓存?因为课程权限数据变更频率低,但一致性要求高,用Redis缓存反而增加双写复杂度。简单直接的主库读,是最可靠的方案。
6. 经验沉淀与后续演进:从“能用”到“好用”的跨越
做完“学成在线”分布式事务改造,我最大的体会是:没有银弹方案,只有适配场景的务实选择。Saga不是最炫的技术,但它用最低的开发成本、最清晰的逻辑、最可控的运维复杂度,解决了我们80%的分布式一致性问题。那些追求强一致的2PC方案,在真实高并发场景下,往往因为协调器瓶颈或单点故障,反而成为系统的阿喀琉斯之踵。
回头看整个过程,有几点经验值得固化为团队规范:
事务边界必须由产品定义,而非技术拍板:最初技术团队想把“发送学习提醒”也纳入Saga,但产品经理指出,这条消息丢了最多影响用户体验,绝不会导致资损。于是我们把它降级为“最大努力通知”,用简单的HTTP回调+失败重试,既降低Saga复杂度,又提升整体吞吐。
所有补偿操作必须可重入,且副作用可控:我们曾写过一个
cancelOrder补偿,里面包含“删除订单关联的优惠券使用记录”。后来发现,如果优惠券已被用户其他订单使用,这条SQL会报错,导致补偿中断。现在所有补偿SQL都加了WHERE EXISTS (...)条件,确保即使数据状态变化,也不会报错。监控不是锦上添花,而是事务系统的呼吸机:Saga的成功率、平均耗时、悬挂事务数,这三个指标必须像血压、心率、血氧一样,实时显示在运维大屏上。我们甚至把
悬挂事务数设为P0级告警,因为它的存在意味着用户真金白银的损失。
至于后续演进,我们已在规划两个方向:一是将Saga协调器容器化,通过K8s HPA自动扩缩容,应对大促流量;二是探索Seata的XA模式在极少数金融级场景(如机构结算)的应用,用更重的方案守住最后的底线。但核心原则不变:技术为业务服务,复杂度为可靠性让路,一切以用户最终体验为终点。
这个“完结”不是句号,而是分号。分布式事务的演进,永远在路上。