电商系统微服务拆分实战:从单体到Spring Cloud的分布式事务踩坑与补偿方案

一、 噩梦开局:一个300万行的"大泥球"

聊微服务之前,先说说我们曾经的那个"巨无霸"。

三年前,我们的电商系统是一个典型的单体应用(Monolith):

代码量:超过 300万行 Java代码。

模块:用户、商品、订单、库存、支付、营销、物流、售后......全部揉在同一个WAR包里。

部署:一台8C16G的服务器,硬扛所有流量。

技术栈:Struts2 + Spring + Hibernate(是的,你没看错,SSH年代的老项目)。

起初一切都好,直到业务开始爆炸式增长:

第一次崩溃:双十一大促,营销模块的优惠券计算逻辑出现死循环,直接把整个Tomcat跑崩了------所有业务全部瘫痪,包括正常下单。

第二次崩溃:物流模块的定时任务(每天凌晨同步第三方快递单号)因为数据量过大,执行了3个小时,占满了数据库连接池,导致白天用户无法登录。

第三次崩溃:一个小改动------在订单列表页加了一个字段,需要修改Order实体类。结果这个实体被100+个地方引用,改完引发了至少5个模块的连锁BUG。

那一刻我们意识到:不拆,就是等死。

本文将完整复盘我们从"大泥球"到Spring Cloud微服务架构的演进之路,重点分享服务拆分策略和分布式事务这两个最痛的环节,希望能给正在做同样决策的团队一些参考。

二、 服务拆分:如何"拆"才不后悔?

拆分的核心原则只有一条:高内聚、低耦合。但具体怎么拆,我们走过了不少弯路。

2.1 错误的拆分方式(我们踩过的坑)

错误一:按表拆分

"订单表拆成订单服务,用户表拆成用户服务,商品表拆成商品服务......"

听起来很合理?但现实是:一个下单流程需要同时操作订单表、库存表、优惠券表、用户积分表。如果每个表都是一个独立服务,一次下单要调用4个服务,而且涉及分布式事务,复杂度直接爆炸。

错误二:按团队拆分

"前端团队管前端服务,后端团队管后端服务,DBA团队管数据库服务......"

这完全是组织架构的惯性,和技术边界毫无关系。结果就是服务之间职责不清,互相调用混乱。

2.2 正确的拆分思路:领域驱动设计(DDD)

我们花了2个月时间梳理业务边界,最终按照业务领域拆分为以下核心服务:

服务名称 核心职责 数据表数量

用户中心 用户注册/登录/权限/会员等级 8张表

商品中心 商品SPU/SKU/分类/品牌/属性 12张表

库存中心 库存管理/锁定/释放/流水 4张表

订单中心 订单创建/状态流转/售后 6张表

支付中心 支付通道对接/回调/退款 5张表

营销中心 优惠券/满减/秒杀活动 7张表

物流中心 发货/轨迹/快递对接 4张表

消息中心 短信/推送/站内信 3张表

关键决策:

每个服务拥有自己的数据库,表之间不允许跨库Join查询。所有数据交互必须通过API调用。

服务边界要"厚":比如"订单中心"不仅管订单表,还负责订单状态的完整生命周期管理,外部服务只能通过订单状态变更事件来感知变化,不能直接修改订单字段。

2.3 拆分后的第一个教训:分布式事务

拆完后第一个上线功能是"下单",我们就立刻遇到了地狱级难题:

一个下单操作,涉及4个服务的协同:

text

用户点击"提交订单"

├─ 订单中心:创建订单(状态:待支付)

├─ 库存中心:扣减库存

├─ 营销中心:核销优惠券

└─ 用户中心:扣除积分(如有)

在单体时代,这4个操作在一个数据库事务里,要么全成功,要么全回滚,简单可靠。

但在微服务时代,每个服务都有自己的数据库,如何保证这4个操作的一致性?

第一次尝试:分布式事务框架(Seata AT模式)

我们引入Seata的AT模式,试图让分布式事务像本地事务一样简单:

java

@GlobalTransactional

public void submitOrder(OrderDTO dto) {

orderService.createOrder(dto); // 订单服务

stockService.decrease(dto); // 库存服务(RPC调用)

couponService.use(dto); // 营销服务(RPC调用)

userService.deductPoints(dto); // 用户服务(RPC调用)

}

看起来很完美?实际生产环境跑了一周,发现问题了:

性能暴跌:每个RPC调用都要加上全局锁等待,下单接口RT从200ms飙升到1.8s。

锁冲突频繁:爆款商品库存扣减时,大量线程竞争同一个全局锁,频繁出现超时回滚。

网络超时导致数据不一致:某个服务超时,Seata触发全局回滚,但回滚过程中某个服务刚好重启了------结果部分回滚成功,部分失败,数据还是不一致。

我们最终得出一个结论:强一致性分布式事务(XA/2PC)不适合互联网高并发场景。 性能和一致性之间,必须做出妥协。

三、 最终方案:TCC + 本地消息表 + 最终一致性

我们转向了柔性事务的思路,核心思想是:不追求实时强一致,而是确保最终一致。

3.1 TCC模式:最硬的分布式事务方案

TCC(Try-Confirm-Cancel)是我们下单核心链路采用的方案。

以"扣库存"为例:

阶段 操作 说明

Try 预扣库存:将库存从"可用库存"转入"冻结库存" 比如库存100件,预扣1件后:可用=99,冻结=1

Confirm 确认扣减:冻结库存清零,订单状态更新为"已支付" 支付成功后执行

Cancel 取消扣减:冻结库存释放回可用库存 支付超时或用户取消时执行

代码实现(简化版):

java

@Component

public class StockTccAction {

复制代码
/**
 * Try阶段:预扣库存
 */
@Transactional
public boolean tryDecrease(Long skuId, Integer quantity, String bizNo) {
    // 1. 检查可用库存是否充足
    Stock stock = stockMapper.selectForUpdate(skuId);
    if (stock.getAvailable() < quantity) {
        return false;
    }
    
    // 2. 扣减可用库存,增加冻结库存
    stock.setAvailable(stock.getAvailable() - quantity);
    stock.setFrozen(stock.getFrozen() + quantity);
    stockMapper.updateById(stock);
    
    // 3. 记录TCC流水(用于幂等和恢复)
    TccLog log = new TccLog();
    log.setBizNo(bizNo);          // 业务唯一标识(订单号)
    log.setSkuId(skuId);
    log.setQuantity(quantity);
    log.setPhase("TRY");
    log.setStatus(1);              // 1=成功
    tccLogMapper.insert(log);
    
    return true;
}

/**
 * Confirm阶段:确认扣减
 */
@Transactional
public boolean confirm(Long skuId, Integer quantity, String bizNo) {
    // 1. 检查TCC流水,防止重复Confirm(幂等)
    TccLog log = tccLogMapper.selectByBizNoAndPhase(bizNo, "CONFIRM");
    if (log != null) {
        return true; // 已处理过,直接返回成功
    }
    
    // 2. 冻结库存清零(在Try阶段已经完成了扣减,这里只做状态标记)
    Stock stock = stockMapper.selectForUpdate(skuId);
    stock.setFrozen(stock.getFrozen() - quantity);
    stockMapper.updateById(stock);
    
    // 3. 记录Confirm流水
    TccLog confirmLog = new TccLog();
    confirmLog.setBizNo(bizNo);
    confirmLog.setPhase("CONFIRM");
    confirmLog.setStatus(1);
    tccLogMapper.insert(confirmLog);
    
    return true;
}

/**
 * Cancel阶段:取消扣减,释放冻结库存
 */
@Transactional
public boolean cancel(Long skuId, Integer quantity, String bizNo) {
    // 1. 幂等检查
    TccLog log = tccLogMapper.selectByBizNoAndPhase(bizNo, "CANCEL");
    if (log != null) {
        return true;
    }
    
    // 2. 释放冻结库存
    Stock stock = stockMapper.selectForUpdate(skuId);
    stock.setAvailable(stock.getAvailable() + quantity);
    stock.setFrozen(stock.getFrozen() - quantity);
    stockMapper.updateById(stock);
    
    // 3. 记录Cancel流水
    TccLog cancelLog = new TccLog();
    cancelLog.setBizNo(bizNo);
    cancelLog.setPhase("CANCEL");
    cancelLog.setStatus(1);
    tccLogMapper.insert(cancelLog);
    
    return true;
}

}

3.2 TCC + 本地消息表:确保最终一致性

TCC保证了单个服务的原子性,但跨服务的协调呢?我们采用了本地消息表 + 定时轮询的模式。

下单流程完整时序图:

用户提交订单

订单中心

│ ① 创建订单(状态:待支付)

│ ② 插入本地消息表(记录:待扣库存、待用券、待扣积分)

异步轮询任务(每5秒扫描一次)

│ ③ 调用库存中心 Try预扣

│ ④ 调用营销中心 Try用券

│ ⑤ 调用用户中心 Try扣积分

全部Try成功 ──────> 订单状态更新为"待支付" ──────> 等待用户支付

任意Try失败\] ──────\> \[执行Cancel补偿\] ──────\> \[订单状态更新为"已取消"

用户支付成功\] ──────\> \[异步Confirm所有Try\] ──────\> \[订单状态更新为"已支付"

本地消息表结构:

sql

CREATE TABLE local_message (

id bigint(20) NOT NULL AUTO_INCREMENT,

biz_no varchar(64) NOT NULL COMMENT '业务唯一号(订单号)',

service_name varchar(32) NOT NULL COMMENT '目标服务:STOCK/MARKET/USER',

action varchar(16) NOT NULL COMMENT '操作:TRY/CONFIRM/CANCEL',

payload text NOT NULL COMMENT '请求参数JSON',

status tinyint(4) DEFAULT '0' COMMENT '0待处理 1成功 2失败 3重试中',

retry_count int(11) DEFAULT '0' COMMENT '重试次数',

next_retry_time datetime DEFAULT NULL COMMENT '下次重试时间',

create_time datetime DEFAULT CURRENT_TIMESTAMP,

update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

PRIMARY KEY (id),

KEY idx_biz_no (biz_no),

KEY idx_status_time (status, next_retry_time)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

定时任务核心逻辑:

java

@Component

public class LocalMessageScheduler {

复制代码
@Scheduled(fixedDelay = 5000) // 每5秒执行一次
public void processPendingMessages() {
    // 1. 查询待处理且到达重试时间的消息(限制每次100条)
    List<LocalMessage> messages = messageMapper.selectPending(100);
    
    for (LocalMessage msg : messages) {
        try {
            // 2. 根据service_name调用对应的RPC接口
            rpcClient.call(msg.getServiceName(), msg.getAction(), msg.getPayload());
            
            // 3. 成功:更新状态为成功
            msg.setStatus(1);
            messageMapper.updateById(msg);
            
        } catch (Exception e) {
            // 4. 失败:增加重试次数,计算下次重试时间(指数退避)
            msg.setRetryCount(msg.getRetryCount() + 1);
            msg.setStatus(3); // 重试中
            // 重试策略:1分钟、2分钟、4分钟、8分钟、16分钟...最多重试10次
            long delaySeconds = 60L * (1 << Math.min(msg.getRetryCount(), 10));
            msg.setNextRetryTime(new Date(System.currentTimeMillis() + delaySeconds * 1000));
            messageMapper.updateById(msg);
            
            // 5. 超过最大重试次数:触发人工告警
            if (msg.getRetryCount() >= 10) {
                alertService.send("分布式事务异常,订单:" + msg.getBizNo());
            }
        }
    }
}

}

3.3 最终一致性保证的关键点

关键点 实现方式

幂等性 每个TCC操作都记录流水,重复执行时先查流水,已执行则跳过

可靠性 本地消息表与业务操作在同一个数据库事务中,要么全成功,要么全失败

可恢复性 定时任务轮询+指数退避重试,确保最终一致性

可观测性 所有TCC流水落库,提供完整的事务追溯能力

四、 踩坑实录:Cancel补偿的"地狱级"难题

TCC最怕的是什么?Try成功了,Confirm失败了,要执行Cancel------但Cancel也失败了!

我们真实遇到过:

场景:下单Try预扣库存成功,但用户支付超时,触发Cancel释放库存。结果Cancel执行时,恰好数据库连接超时------Cancel失败了。

后果:库存被永久冻结,用户明明没买,库存却少了一件。如果这种错误堆积,就会造成隐形超卖。

解决方案:独立补偿任务 + 对账巡检

独立的补偿服务:不依赖主业务链路,独立进程定时扫描TCC流水中Try成功但Confirm/Cancel未完成的记录,自动触发补偿。

每日凌晨对账任务:

从订单中心拉取所有"已取消"的订单。

从库存中心拉取所有"冻结库存"记录。

交叉比对,找出"订单已取消但库存未释放"的异常数据。

自动触发补偿 + 发送告警给运维团队。

sql

-- 对账SQL(跨库无法Join,通过代码做二次比对)

-- Step1: 查询所有已取消但未释放库存的订单ID

SELECT order_no FROM order_db.order WHERE status = 'CANCELLED' AND create_time > '2024-01-01';

-- Step2: 查询库存中心的冻结记录

SELECT biz_no, sku_id, frozen_quantity FROM stock_db.tcc_log WHERE phase = 'TRY' AND status = 1 AND confirm_time IS NULL;

五、 服务治理:拆完了,怎么管理?

服务拆完了,新的问题来了:几十个服务,怎么管理?

5.1 注册中心与配置中心

Nacos:同时作为注册中心和配置中心,服务发现+动态配置一体化。

配置动态刷新:数据库连接池大小、熔断阈值、限流阈值全部配置化,不用重启服务。

5.2 熔断降级(Sentinel)

核心规则配置:

资源名 熔断策略 阈值 熔断时长

库存中心-扣减接口 慢调用比例 RT>500ms,比例>30% 30秒

营销中心-用券接口 异常比例 异常比例>20% 60秒

用户中心-积分接口 限流 QPS>1000 10秒(排队等待)

降级兜底逻辑:

java

@SentinelResource(value = "decreaseStock",

fallback = "decreaseStockFallback")

public Result decreaseStock(StockDTO dto) {

// 正常调用库存服务

return stockRpcClient.decrease(dto);

}

// 降级方法:返回缓存兜底,记录异常日志,异步补偿

public Result decreaseStockFallback(StockDTO dto, Throwable ex) {

log.error("库存服务熔断降级,订单号={}", dto.getBizNo(), ex);

// 1. 返回"不确定"状态,让上游走重试或人工介入

// 2. 写入本地异常表,后续人工补偿

exceptionLogService.save(dto, ex);

return Result.error("系统繁忙,请稍后重试");

}

5.3 链路追踪(SkyWalking)

微服务最大的痛点之一:一个请求穿越了4个服务,出了问题,不知道日志在哪。

接入SkyWalking实现全链路追踪,一个TraceId贯穿所有服务。

下单请求的完整调用链一目了然:

Gateway → 订单中心(120ms) → 库存中心(45ms) → 营销中心(38ms) → 用户中心(22ms)

响应时间:225ms 总耗时:225ms

六、 最终成果与数据

经过长达8个月的迭代和优化,最终成果如下:

维度 单体时期 微服务时期

核心服务TPS 约 800 单服务 > 3000,整体 > 10000

发布频率 2周/次(全量发布) 每日多次(单服务独立发布)

故障隔离 一个BUG炸全部 单服务故障不影响其他

团队协作 所有人在同一代码库冲突不断 按服务划分团队,独立开发互不干扰

数据库连接数 单一DB连接池经常打满 每个服务独立DB,资源隔离

分布式事务一致性 100%(ACID本地事务) 99.99%(最终一致性,有监控兜底)

七、 写在最后:给正在做微服务转型团队的建议

如果你也在做微服务转型,我有几条血的教训想分享:

先问自己:真的需要微服务吗?

如果团队少于20人、QPS低于1000,单体+合理的模块化可能更适合你。微服务不是银弹,引入的复杂度远超想象。

拆分要"狠",但上线要"稳"

不要一次性全拆完再上线。我们的策略是:绞杀者模式------新功能用新架构开发,老功能逐步迁移,新旧系统共存期间用网关做路由。

分布式事务别追求强一致

选TCC还是本地消息表?我们的经验:核心链路(下单/支付)用TCC,非核心链路(日志/统计)用本地消息表+最终一致性。

可观测性比功能更重要

微服务如果看不到链路、日志、指标,就是"盲人摸象"。先搭建好SkyWalking/Prometheus/Grafana,再上线业务。

做好"失败"的预案

微服务环境下,网络超时、服务宕机是常态。每一行RPC调用代码都要想清楚:如果它挂了,我的业务会怎样?

文末互动:

你们的微服务拆分到第几步了?遇到过最棘手的分布式事务场景是什么?是TCC、Saga还是Seata?欢迎在评论区分享你们的实践,一起交流成长!

相关推荐
k↑3 小时前
SpringCloud + React19 集成Scalar的API文档
后端·spring·spring cloud
ACP广源盛139246256734 小时前
破局 PCIe 4.0 交换瓶颈@ACP#IX8024 / ASM58024参数及应用对比
大数据·人工智能·分布式·嵌入式硬件
Aray12344 小时前
论服务网格在云原生微服务项目中的设计与落地实践
微服务·云原生·架构
淡然的煎蛋5 小时前
开始使用RabbitMQ
分布式·rabbitmq·ruby
LiaoWL1237 小时前
【SpringCloud合集-02】Spring Cloud LoadBalancer 负载均衡器学习笔记
学习·spring cloud·负载均衡
笨鸟先飞的橘猫1 天前
skynet——分布式支持
分布式·skynet
ACP广源盛139246256731 天前
GSV6155 @ACP#工业车规 DP1.4 重定时器 Retimer
大数据·人工智能·分布式·嵌入式硬件
Geek-Chow1 天前
微服务认证与授权:06 — Keycloak / IdP
微服务·信息可视化·架构
ACP广源盛139246256731 天前
GSV2221@ACP# DP1.4 MST 多流显示转换芯片国产工业多屏
大数据·人工智能·分布式·嵌入式硬件·spark