32 万行单体,我们用了 5 个月切成微服务,一次没停过机
去年 10 月启动的拆分,到今年 2 月底核心链路切完。这期间系统一天没停过,用户侧没有感知到任何一次发布异常。回过头看,技术选型上没什么新鲜的,真正难的是"怎么一步步把流量搬过去,以及搬错了怎么退回来"。
先说被拆的那个东西
shop-all.war(Spring MVC 4.3 + MyBatis 3.4 + JDK 7)
├── 商品模块 3.1 万行
├── 订单模块 6.8 万行
├── 库存模块 2.2 万行
├── 促销/优惠券 4.4 万行
├── 用户/会员 3.9 万行
├── 支付/对账 5.1 万行
├── 后台管理 6.2 万行
└── 公共/工具 1.1 万行
合计 32.8 万行,war 包 187 MB
部署形态是 8 台物理机,每台 Tomcat 8.5。发一次版要全量回归,测试组三个人跑三天。任何一个模块改一行,整个 war 重新打、8 台机器轮着重启,前后 40 分钟。
两个方案的取舍
当时摆在我面前的是两条路:
| 方案 | 周期 | 风险 | 我们的判断 |
|---|---|---|---|
| 大爆炸重写 | 预计 9 到 12 个月 | 新旧系统并行期间业务还要迭代,需求永远追不上 | 否定 |
| 绞杀者模式 | 按模块逐个切,每个 3 到 6 周 | 数据双写、链路变长 | 选择 |
绞杀者(Strangler Fig)这个名字来自 Martin Fowler 2014 年那篇博客,指的是一种藤蔓从树冠开始生长,最后把宿主树完全包住取而代之。落到我们这里就是:在单体前面加一层代理,新功能或者被拆出来的模块走新服务,其余流量继续打给单体,逐步把单体架空。
最关键的一点:整个过程中系统是可用状态,不需要"切一次大的"。
流量切换:三层都要能切
入口层,用 Gateway 按规则分流
我们在 Nginx 后面加了 Spring Cloud Gateway(Hoxton.SR1),所有 /api/** 的流量先进网关。网关里挂了一个自定义的灰度 Filter:
@Component
public class GrayRouteFilter implements GlobalFilter, Ordered {
@Autowired
private GrayRuleService ruleService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().value();
String uid = exchange.getRequest().getHeaders().getFirst("X-User-Id");
GrayRule rule = ruleService.match(path, uid);
if (rule != null && rule.isHit()) {
// 打到新的微服务集群
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR,
URI.create("lb://order-service"));
exchange.getRequest().mutate()
.header("X-Gray-Tag", rule.getTag()).build();
}
// 否则走默认的 lb://shop-monolith
return chain.filter(exchange);
}
@Override
public int getOrder() {
return RouteToRequestUrlFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER - 1;
}
}
getOrder() 这个值是必须的,比负载均衡 Filter 早一步执行才能改掉目标服务。
灰度规则存在 Nacos 的配置里,支持热更新,改完不用重启网关:
gray:
rules:
- path: /api/order/**
service: order-service
strategy: PERCENT
percent: 5 # 5% 流量
- path: /api/order/**
service: order-service
strategy: UID_MOD
mod: 100
range: [0, 4] # uid % 100 落在 0-4 的用户
- path: /api/order/**
service: order-service
strategy: WHITELIST
uids: [10086, 10087] # 内部测试账号
三种策略的用途不一样:白名单给测试,UID 取模给"稳定灰度"(同一个用户每次都落在新服务上,体验一致),百分比给放量。上线初期我们只用白名单加取模,百分比是最后一个阶段才开的。
为什么用 UID 取模而不是随机百分比?因为下单是个多步骤流程:加购物车、下单、支付、查订单。如果按请求随机分流,用户这一次下单在新服务、下一次查询在老服务,数据可能看不到。取模能保证同一个用户的完整链路都在同一边。
服务层,单体里用 Feign 反向调用
拆到一半的时候会出现一种情况:新服务需要查老系统的数据,或者老系统需要调新服务的能力。我们的处理是给单体也装上了 Nacos 客户端和 Feign,让它既能被新服务调,也能主动调出去。
// 单体里的老代码,逐步把内部方法调用换成 Feign
@Service
public class OrderServiceImpl {
@Autowired
private InventoryClient inventoryClient; // 指向新拆出来的库存服务
public void createOrder(OrderDTO dto) {
// 老:inventoryDao.deduct(...)
// 新:
inventoryClient.deduct(dto.getSkuId(), dto.getQty());
...
}
}
数据层:这是最难的部分
我的原则是先共享库,后拆库。一开始就拆库会引入跨库事务,那是另一个量级的复杂度。
第一步,新服务和单体连同一个 MySQL 实例,但严格划分表所有权:
shop 库
├── t_order / t_order_item → 归属 order-service,只有它能写
├── t_stock / t_stock_flow → 归属 inventory-service,只有它能写
├── t_coupon / t_coupon_use → 归属 promotion-service,只有它能写
└── t_user / t_user_address → 暂时归属单体,order-service 只能读
约定写进文档,也做了兜底:给每个服务配的 MySQL 账号只有自己表的写权限,其他表只有 SELECT。这样即使有人想偷懒直接改别的表,也会报错。
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.t_order* TO 'order_svc'@'%';
GRANT SELECT ON shop.t_user* TO 'order_svc'@'%';
第二步才是物理拆库,这个放到最后做,而且用了双写加校验的办法。下面单独讲。
数据双写,以及怎么保证双写是对的
拆订单库的时候,新老库要同时写一段时间。双写最大的问题不是写不进去,而是两边写的结果不一致,而且你不知道。
我们的做法:
@Transactional
public void createOrder(OrderDTO dto) {
// 主写新库
Long orderId = newOrderMapper.insert(buildOrder(dto));
// 影子写老库,失败只告警不回滚
try {
oldOrderMapper.insert(buildOrder(dto, orderId));
} catch (Exception e) {
log.error("双写失败 orderId={}", orderId, e);
shadowFailMapper.record("t_order", orderId); // 记下来供补偿
}
}
这里有个设计决策我纠结了很久:双写失败到底要不要回滚主写。最后定了不回滚。理由是双写阶段老库只是影子,主数据源已经是新库;因为影子写失败就把用户的下单请求打回去,不划算。代价是要有补偿和对账。
对账任务每 5 分钟跑一次,比对最近 2 小时的数据:
@Scheduled(fixedDelay = 300_000)
public void checkOrder() {
long end = System.currentTimeMillis();
long start = end - 2 * 3600 * 1000;
List<Long> newIds = newOrderMapper.selectIdsBetween(start, end);
List<Long> oldIds = oldOrderMapper.selectIdsBetween(start, end);
Set<Long> missing = new HashSet<>(newIds);
missing.removeAll(oldIds);
if (!missing.isEmpty()) {
log.warn("双写缺失 {} 条", missing.size());
alertService.ding("订单双写缺失: " + missing.size());
// 自动补写
for (Long id : missing) {
shadowFailMapper.record("t_order", id);
}
}
}
实际跑下来,双写的不一致率约 0.004%,主要是老库偶尔的死锁和超时。补偿任务兜住了。
双写跑了 3 周,对账连续 7 天零差异之后,才把读流量切到新库,再过 1 周停掉影子写。
回滚预案:每一步都要有退路
这是我最花时间的部分。拆分过程中出问题的概率很高,如果没有秒级回滚能力,就不敢往前走。
我们定的四步流程,每步都有对应的回滚动作:
| 阶段 | 灰度比例 | 观察时长 | 出问题怎么退 |
|---|---|---|---|
| 1 白名单 | 内部 8 个账号 | 1 天 | Nacos 改配置,删掉 uids,15 秒生效 |
| 2 UID 取模 | 1% | 2 天 | 把 range 改成空数组 |
| 3 取模放量 | 5% → 20% → 50% | 每档 2 天 | 回退到上一档 |
| 4 全量 | 100% | 保留开关 1 个月 | 开关关掉,全回单体 |
第 4 步强调"保留开关一个月"。全量切到新服务之后,单体的代码和部署依然保留,网关开关随时能切回去。这个开关我们真的用了一次。
12 月 18 号,切全量第 3 天,监控发现下单 P99 从 300 ms 涨到 1.8 秒。查下来是订单服务调库存服务的 Feign 连接池配小了(默认没开 httpclient,短连接)。当时的处理不是去改配置,而是先把开关切回单体,15 秒恢复,然后慢慢排查。这个顺序很重要,止血优先于诊断。
踩过的四个坑
一、灰度标识在异步链路里丢了
网关打上 X-Gray-Tag 之后,同步调用链路上没问题,但一旦经过 MQ 就丢了。解决办法是把 tag 塞进消息的属性里,消费者取出来放进 ThreadLocal:
@RocketMQMessageListener(topic = "ORDER_PAID_TOPIC", consumerGroup = "point-consumer")
public class PointConsumer implements RocketMQMessageListenerExt<MessageExt> {
@Override
public void onMessage(MessageExt msg) {
String tag = msg.getProperty("X-Gray-Tag");
GrayContext.set(tag);
try {
pointService.add(...);
} finally {
GrayContext.clear();
}
}
}
二、跨库 JOIN
拆库之后,原来的 SELECT o.*, u.nickname FROM t_order o JOIN t_user u ON ... 直接没了。只能拆成两次查询在应用层拼,或者做字段冗余。我们订单列表页冗余了 user_nickname,代价是改昵称后订单页不实时更新——和产品确认过,可以接受。
三、本地事务变成了分布式
原来下单减库存在同一个数据库事务里,拆开之后跨了两个服务两个库。这个我在 Seata 那篇里详细写了,结论是核心链路用 Seata AT,非核心用最终一致加补偿。
四、单体的数据库连接池
拆出去的服务各自开了连接池,单体那边还是老配置(Druid maxActive=200)。结果总连接数暴涨,MySQL 的 max_connections=500 被打满过一次。教训是拆之前先盘一遍总连接数。
5 个月的数字
| 指标 | 拆分前 | 拆分后 |
|---|---|---|
| 发版耗时 | 40 分钟(全量 8 台) | 单服务 3 到 5 分钟 |
| 回归测试 | 3 天 | 按服务 4 到 8 小时 |
| 单次发布影响面 | 全站 | 单个服务 |
| 下单 P99 | 310 ms | 295 ms |
| 可用性 | 99.92% | 99.95% |
| 服务数 | 1 | 7(还剩用户、后台管理没拆) |
性能上几乎没有提升,这一点要诚实。微服务的收益主要在研发效率和发布安全上,不是性能。
下篇预告
这篇先把《从单体迁移到微服务的灰度方案》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。