从单体到微服务:一次真实迁移的完整复盘与教训总结
从单体到微服务:一次真实迁移的完整复盘与教训总结
适读人群:Java中高级工程师、面临架构演进的技术负责人 | 阅读时长:约25分钟 | 难度:★★★★★
开篇故事
2022年我带队做了一次单体到微服务的迁移,历时18个月,投入了16个工程师,期间经历了4次生产故障、2次严重数据不一致,以及1次差点失败的全量回滚。
最终我们完成了迁移,系统的发布效率提升了5倍,但代价也很真实:比预期多花了6个月,多花了约30%的云资源成本,团队疲惫了很长一段时间。
这篇文章不是成功经验的炫耀,而是一次完整的复盘:我们做对了什么,做错了什么,如果重来一次会怎么做不同的选择。
这是第700篇,用这篇特殊的文章来结束"系统设计专题",我觉得很合适——因为架构演进本身就是系统设计最真实的考验。
一、迁移背景与动机
迁移前的单体状态
系统规模: 一个15万行代码的Spring Boot单体应用,涵盖用户管理、订单、商品、支付、物流、营销6个核心业务域。
痛点(迁移的真实动机):
发布效率低: 改一行CSS要把整个应用重新构建和发布,每次发布需要30分钟,而且要协调所有团队同时冻结代码。高峰期每天有5个团队要发布,互相阻塞,一天就过去了。
扩容不精细: 大促时商品搜索QPS暴增10倍,但只能把整个应用从10台扩到100台,搜索模块只用了20%的资源,其余资源全部浪费。
故障隔离差: 营销模块的一个死循环bug,把整个应用的线程池耗尽,导致订单、支付全部超时。
技术栈老化: 新模块想用新框架,但整个应用是Java 8 + Spring Boot 1.5,升级动一发动全身。
什么时候不需要迁移: 如果你的团队规模 < 10人,日活 < 10万,单体完全够用。过早的微服务化只会增加复杂度,降低开发效率。
二、迁移规模估算
迁移前先做评估,这是很多团队忽略的步骤:
服务拆分规划:
- 计划拆分为8个服务:用户服务、商品服务、订单服务、支付服务、库存服务、物流服务、营销服务、通知服务
- 新增基础设施:API网关、配置中心、服务注册中心、分布式链路追踪、统一日志
基础设施成本估算:
| 组件 | 单体 | 微服务 |
|---|---|---|
| 应用实例 | 10台 | 约60台(8服务×7-8台) |
| 数据库 | 1个集群 | 8个集群(服务独立DB) |
| Redis | 1个集群 | 共享+部分独立 |
| 基础设施(网关、注册中心等) | 0 | 新增约10台 |
云资源成本:迁移后预估月成本是迁移前的2.5倍。这个数字在迁移前就要跟管理层说清楚,否则事后解释很被动。
三、迁移架构设计
迁移策略:绞杀者模式(Strangler Fig Pattern)
不一次性全部重写,而是逐步把功能从单体"绞杀"出来,同时保证单体仍然可以正常运行。
关键原则: 在整个迁移过程中,系统必须始终可用,不允许有"停机迁移窗口"。
四、迁移路线图与实际执行
第一阶段(1-3个月):基础设施搭建
搭建微服务所需的基础设施,在不影响生产的情况下完成:
- 部署API网关(Spring Cloud Gateway)
- 部署服务注册中心(Nacos)
- 搭建分布式链路追踪(SkyWalking)
- 统一日志系统(ELK)
- CI/CD流水线改造(每个服务独立构建、部署)
这一阶段最重要的交付物: 不是微服务,而是基础设施。没有这些基础设施,后续的微服务一旦出问题,根本定位不了。
第二阶段(4-9个月):非核心服务先拆
原则:先易后难,先拆低风险的服务。
我们的顺序:通知服务(无状态,无数据库依赖)→ 用户服务(独立性强)→ 商品服务(读多写少)→ 营销服务
每个服务拆分的步骤:
- 在新代码库实现服务,用独立数据库
- 数据迁移:把单体数据库中的相关数据迁移到新库
- 双写阶段:新数据同时写单体DB和新服务DB,验证数据一致性
- 流量切换:API网关把相关接口路由到新服务(从1%逐渐到100%)
- 稳定运行2周后,停止写单体DB
- 清理单体代码中的相关模块
第三阶段(10-18个月):核心业务域拆分
订单、支付、库存三个核心服务,因为涉及分布式事务,是最难的部分。
五、关键代码实现:核心挑战的解法
5.1 数据迁移中的双写方案
/**
* 双写代理:在迁移阶段,写操作同时写旧系统和新系统
* 用于验证两个系统的数据一致性
*/
@Service
@Slf4j
public class UserServiceDualWriteProxy {
@Autowired
private OldUserRepository oldUserRepo; // 单体DB
@Autowired
private NewUserServiceClient newUserClient; // 新用户服务
@Autowired
private ConsistencyChecker checker;
/**
* 双写:用户信息更新
*/
@Transactional
public void updateUser(UpdateUserRequest request) {
// 1. 写旧系统(主写,保证数据安全)
oldUserRepo.update(request);
// 2. 异步写新系统(副写,出错只记录日志,不影响主流程)
try {
newUserClient.update(request);
// 3. 一致性校验(异步)
checker.checkAsync(request.getUserId());
} catch (Exception e) {
log.error("新服务写入失败,将稍后重试。userId={}",
request.getUserId(), e);
// 记录差异,由异步任务补偿
inconsistencyQueue.add(request.getUserId());
}
}
}
/**
* 一致性补偿任务:定期比较新旧系统的数据差异,进行修复
*/
@Scheduled(fixedDelay = 60000)
public void repairInconsistencies() {
List<String> inconsistentIds = inconsistencyQueue.drain(100);
for (String userId : inconsistentIds) {
User oldUser = oldUserRepo.findById(userId);
User newUser = newUserClient.findById(userId);
if (!isConsistent(oldUser, newUser)) {
// 以旧系统为准,强制更新新系统
newUserClient.forceUpdate(oldUser);
log.warn("数据不一致,已修复。userId={}", userId);
}
}
}5.2 分布式事务处理(Saga模式)
单体中的"创建订单→扣减库存→支付"在一个本地事务里完成。拆成微服务后,跨服务没有本地事务,需要用Saga模式。
/**
* 订单创建Saga:使用编排式Saga保证最终一致性
*/
@Service
@Slf4j
public class CreateOrderSaga {
@Autowired
private OrderService orderService;
@Autowired
private InventoryServiceClient inventoryClient;
@Autowired
private PaymentServiceClient paymentClient;
/**
* Saga执行流程:步骤失败时执行补偿操作(回滚)
*/
public OrderResult execute(CreateOrderRequest request) {
String sagaId = UUID.randomUUID().toString();
String orderId = null;
boolean inventoryDeducted = false;
try {
// 步骤1:创建订单(待支付状态)
orderId = orderService.createPendingOrder(sagaId, request);
// 步骤2:扣减库存
inventoryClient.deductStock(request.getSkuId(), request.getQuantity(), sagaId);
inventoryDeducted = true;
// 步骤3:创建支付单
String paymentUrl = paymentClient.createPayment(orderId, request.getAmount());
// 步骤4:更新订单状态为"待支付"
orderService.updateToPendingPayment(orderId, paymentUrl);
return OrderResult.success(orderId, paymentUrl);
} catch (Exception e) {
log.error("创建订单Saga执行失败, sagaId={}", sagaId, e);
// 执行补偿(逆序)
if (inventoryDeducted) {
try {
inventoryClient.rollbackStock(request.getSkuId(),
request.getQuantity(), sagaId);
} catch (Exception rollbackEx) {
log.error("库存回滚失败,需要人工处理。sagaId={}", sagaId, rollbackEx);
// 记录到补偿任务表,由人工或定时任务处理
}
}
if (orderId != null) {
orderService.cancelOrder(orderId, "创建失败自动取消");
}
throw new OrderCreationException("订单创建失败", e);
}
}
}5.3 服务降级(防止级联故障)
/**
* 商品服务调用:使用Resilience4j熔断
* 商品服务挂了,不影响订单服务的其他功能
*/
@Service
public class ProductServiceClientWrapper {
@Autowired
private ProductServiceClient productClient;
/**
* 查询商品信息,带熔断和降级
*/
@CircuitBreaker(name = "productService",
fallbackMethod = "getProductFallback")
@TimeLimiter(name = "productService")
public CompletableFuture<ProductInfo> getProduct(String productId) {
return CompletableFuture.supplyAsync(() ->
productClient.getProduct(productId));
}
/**
* 降级方法:商品服务不可用时,返回缓存数据或简化数据
*/
public CompletableFuture<ProductInfo> getProductFallback(
String productId, Throwable t) {
log.warn("商品服务降级, productId={}, reason={}", productId, t.getMessage());
// 尝试从本地缓存获取
ProductInfo cached = localCache.getIfPresent(productId);
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
// 返回最小化数据(只有ID,让前端展示"商品信息暂时不可用")
return CompletableFuture.completedFuture(
ProductInfo.minimal(productId));
}
}六、真实踩坑记录(18个月的教训)
坑1:服务拆分粒度太细(最大的错误)
我们最初把"用户服务"拆成了"账号服务"(登录注册)和"用户画像服务"(用户信息),理由是"单一职责"。结果每次接口调用要同时查两个服务,延迟增加,代码复杂度翻倍,后来又不得不合并回去,白白浪费了一个月。
教训: 微服务不是拆得越细越好。一个好的服务粒度标准是:团队能独立维护这个服务,且服务间的数据耦合很少。按DDD的限界上下文来拆,不要按技术层(Controller/Service/DAO)来拆。
坑2:分布式事务设计不足导致数据不一致
迁移第8个月,我们拆分了库存服务。某天大促,订单创建成功,但库存扣减失败(库存服务临时超时),Saga补偿没有执行成功(补偿接口也超时了),导致100个订单没有扣减库存,用户收到了货,但库存系统里数据对不上。
财务、仓库、客服三方扯皮了一周,最终人工对账处理。
教训: 分布式事务的补偿操作必须是幂等的,而且必须有最终兜底机制(比如定时任务扫描未完成的Saga,无限重试直到成功)。不能假设补偿操作会成功。
坑3:服务间依赖环导致雪崩
服务依赖关系没有管理好:订单服务 → 营销服务(查优惠券)→ 用户服务(查用户等级)→ 订单服务(查历史订单)。形成了循环依赖!当用户服务响应变慢时,所有服务的线程都在等待,最终全部超时。
教训: 微服务的依赖关系图必须是有向无环图(DAG)。在架构评审时就要检查循环依赖,一旦发现就要拆解。
坑4:基础设施成本超预期
原始估算月成本是迁移前的2.5倍,实际运行时是3.8倍。多出来的成本主要来自:
- 每个微服务的JVM内存占用(512MB起步 × 8服务 × 7台 = 约28GB),比预估高出了50%
- 服务间的网络通信增加了大量带宽消耗
- 链路追踪、日志等可观测性组件的存储成本被低估
教训: 做成本估算时要留出50%的余量,特别是JVM的内存开销。考虑使用GraalVM Native Image(Spring Boot 3支持),可以把内存降到50MB以内。
坑5:迁移期间发布流程更复杂了
迁移过渡期,单体和微服务同时存在。一个功能可能既有单体的实现也有新服务的实现,发布时要同步更新两套。运维经常搞错版本,造成了几次生产事故。
教训: 迁移期间的复杂度比迁移前后都要高,这段时间要特别加强代码审查和发布前的验证。并且要尽快完成每个服务的切换,缩短双系统并存的时间。
坑6:接口版本管理混乱
微服务之间的接口升级没有版本管理,某个服务改了接口字段名,所有调用方都跟着报错。
教训: 服务间接口必须严格做版本管理,新字段只能增加不能修改,老字段要保持向后兼容。可以用API契约测试(Spring Cloud Contract)在CI阶段就发现接口兼容性问题。
七、迁移效果评估
18个月后,迁移完成,下面是诚实的评估:
改善的地方:
- 独立发布:各团队发布频率从1次/天提升到3-5次/天
- 故障隔离:营销服务挂了不影响订单支付
- 定向扩容:大促期间只扩容搜索和商品服务,成本降低约40%
- 新技术引入:部分服务升级到Java 17,不影响其他服务
没有改善甚至变差的地方:
- 接口调试复杂度:单体时一个断点能调试全流程,微服务需要同时启动8个服务
- 本地开发环境:开发机内存不够,很多人只能在联调环境开发
- 数据查询灵活性:跨服务的复杂查询变得很难,很多报表需要引入数据仓库
- 运维复杂度:运维团队从管理1个应用到管理20+个服务,压力倍增
八、如果重来,我会做什么不同
更晚启动微服务迁移: 当时的QPS其实单体完全撑得住,真正的痛点是发布效率。可以先优化单体的发布流程(模块化、并行构建),解决80%的问题,延后2年再考虑微服务。
从单体模块化开始: 在单体内部先做好模块化(Maven多模块),强制各模块间通过接口交互,不允许直接调用其他模块的Repository。这样未来拆分时,模块间的边界已经清晰,拆分工作量会小很多。
先搞好可观测性: 第一件事不是拆服务,而是把日志、链路追踪、监控做好。没有可观测性的微服务,出了问题根本找不到原因。
分布式事务方案要在纸上演练: 不要到了真正拆数据库时才开始设计分布式事务,应该提前把所有涉及跨服务数据一致性的场景全部梳理出来,逐一设计Saga流程,包括所有的补偿路径。
准备好回滚方案: 每个服务上线前,都要想好"如果这个服务出问题,怎么最快速地回滚到单体"。我们差点全量回滚那次,就是因为回滚方案没有提前准备好,花了整整4个小时才完成回滚。
九、总结
微服务不是银弹,也不是终点。
单体适合:小团队、早期产品、快速迭代验证阶段。微服务适合:多团队协作、不同模块需要独立扩容、不同业务域需要独立演进。
迁移的代价是真实的:更高的基础设施成本、更复杂的运维、更高的分布式系统复杂度。在决定迁移之前,一定要诚实地问自己:我们遇到的问题,通过优化单体能解决吗?
如果答案是"不能",那么微服务是值得的。如果答案是"能,但我们觉得微服务更高端",那么不要做。
这20篇系统设计文章,从短链接系统到微服务迁移,覆盖了大多数后端工程师会遇到的设计挑战。架构没有标准答案,每个决策背后都是权衡。理解权衡,才是真正的架构师思维。
