一、 噩梦开局:一个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?欢迎在评论区分享你们的实践,一起交流成长!