去年春天,我们对运行了五年的单体系统做了一次彻底的服务拆分。回过头看,最难的从来不是技术选型,而是边界——哪些能力应该内聚,哪些调用必须显式。这篇文章记录整个决策过程,以及几个我们踩过的坑。

一、先回答:为什么拆

拆分的理由有三:发布互相阻塞、数据库连接数打满、核心链路和非核心链路争抢资源。但最触动我的,是大促前夜的一次故障——一个运营活动的缓存击穿,拖垮了整个交易主链路。

「服务拆分的本质,是用网络的复杂度,换取边界的清晰度。」

单体并不是原罪。团队十个人、日活几十万的时候,一个仓库、一个进程、一个数据库,是最高效的形态。问题在于系统会长大,而组织的沟通成本长得比代码更快。

二、边界怎么划

我们用了两轮工作坊来划边界:第一轮按业务域粗切,第二轮按数据所有权细调。判断标准只有一条——这张表,能不能只有一个服务写。

凡是要跨库 join 才能回答的问题,要么是边界划错了,要么是该用冗余和事件来解。

// 拆分后的订单服务:只依赖自己的库
export async function createOrder(cmd: CreateOrderCmd) {
  const stock = await inventory.lock(cmd.sku, cmd.qty);
  if (!stock.ok) return Err('OUT_OF_STOCK');
  return orderRepo.save(Order.create(cmd));
}

三、流量与数据迁移

迁移策略上我们选择了影子库 + 双写校验,而不是一次性切流:

  1. 新服务先以只读模式上线,旁路同步数据;
  2. 开启双写,比对新旧两边的结果差异;
  3. 差异率连续一周为零后,灰度切读流量;
  4. 最后切写,老库保留一个月作为回滚预案。

整个过程没有停机,但代价是两个月的双写维护成本。这是必须付的学费。

四、踩坑与复盘

  • 分布式事务不是免费的。 我们最终用本地消息表 + 最终一致性替代了最初设想的 Seata,复杂度立降。
  • 监控要先于拆分。 没有全链路 trace 就拆服务,等于蒙着眼睛做手术。
  • 拆分之后,接口契约成了最贵的东西。 一次字段改名引发的故障,比过去一年所有代码 bug 加起来都贵。

如果再来一次,我会更早拆分,但会更慢切流。边界想清楚之前,一行代码都不要搬。