声明:本文部分内容使用AI辅助生成,经人工编辑、审核和补充个人经验。
更新说明:本文最后更新于 2026-05-19。
微服务架构拆分踩坑记录
去年公司一个Node.js单体项目发展到一定程度,代码量超过15万行,部署一次要半小时,团队六个人改代码天天冲突。老板拍板说拆微服务,我负责牵头,结果这一拆就是八个月,踩的坑比我前五年加起来都多。下面把这些血泪经验整理出来,希望能给正在考虑拆服务的同学一点参考。
服务拆分边界:拆得太细是灾难
一开始我们满腔热血,想着微服务嘛,就是一个功能一个服务,拆得越细越纯粹。结果第一个月就出了大问题。
踩坑:按表拆分导致分布式事务爆炸
我们最初的拆分策略简单粗暴:数据库有几张表,就拆几个服务。用户表对应用户服务,订单表对应订单服务,商品表对应商品服务。听起来很合理对吧?
问题是电商场景里下单这个操作,要同时操作用户余额、订单状态、商品库存。这三个操作分散在三个服务里,任何一个失败都要回滚。我们一开始用Saga模式,写补偿逻辑写得头皮发麻。
下面这段代码是我们早期Saga补偿逻辑的简化版,光是看就觉得维护成本极高:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| class OrderSaga { async execute(orderData) { const sagaId = generateUUID(); const steps = [];
try { const deductResult = await inventoryService.deduct( orderData.productId, orderData.quantity, { sagaId } ); steps.push({ service: 'inventory', action: 'deduct', data: deductResult });
const orderResult = await orderService.create(orderData, { sagaId }); steps.push({ service: 'order', action: 'create', data: orderResult });
const balanceResult = await userService.deductBalance( orderData.userId, orderData.amount, { sagaId } ); steps.push({ service: 'user', action: 'deductBalance', data: balanceResult });
return { success: true, orderId: orderResult.id }; } catch (error) { for (let i = steps.length - 1; i >= 0; i--) { const step = steps[i]; await this.compensate(step); } throw error; } }
async compensate(step) { switch (step.service) { case 'inventory': await inventoryService.restore(step.data.productId, step.data.quantity); break; case 'order': await orderService.cancel(step.data.id); break; case 'user': await userService.refund(step.data.userId, step.data.amount); break; } } }
|
这段代码的问题在于:
- 补偿逻辑和业务逻辑耦合在一起,一个下单流程改了,补偿逻辑也要跟着改
- 网络超时的时候不知道到底执行到哪一步了,补偿可能重复执行
- 新增一个步骤要改 orchestrator 的核心代码,违反开闭原则
修正:按业务领域拆分,减少跨服务事务
后来我们重新梳理了业务领域,参考了DDD的战略设计。核心思路是:把强一致性要求的操作尽量放在一个服务内。
我们调整了拆分策略:
| 拆分策略 |
服务数量 |
跨服务调用次数(下单流程) |
维护复杂度 |
| 按表拆分 |
12个 |
8次 |
极高 |
| 按领域拆分(第一版) |
6个 |
4次 |
高 |
| 按领域拆分(最终版) |
5个 |
2次 |
中等 |
最终我们把用户、商品、订单、支付、物流作为五个核心领域。下单流程里,库存扣减和订单创建放在订单服务内部用本地事务处理,只留支付和物流通知作为异步操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| async function createOrderWithInventory(orderData) { const transaction = await db.sequelize.transaction();
try { const inventory = await Inventory.findOne({ where: { productId: orderData.productId }, lock: transaction.LOCK.UPDATE, transaction });
if (inventory.stock < orderData.quantity) { throw new Error('库存不足'); }
await inventory.decrement('stock', { by: orderData.quantity, transaction });
const order = await Order.create({ userId: orderData.userId, productId: orderData.productId, quantity: orderData.quantity, amount: orderData.amount, status: 'pending_payment' }, { transaction });
await transaction.commit();
eventBus.publish('order.created', { orderId: order.id, userId: orderData.userId, amount: orderData.amount });
return order; } catch (error) { await transaction.rollback(); throw error; } }
|
这样改完之后,核心业务流程的可靠性大幅提升,代码也清爽了很多。
服务发现:Consul不是银弹
服务拆完了,服务之间怎么找到对方?我们一开始选了Consul,结果踩了一堆运维的坑。
踩坑:Consul集群脑裂
我们三台Consul Server部署在不同可用区,想着这样高可用。结果有一次网络抖动,两个节点认为第三个节点挂了,第三个节点认为另外两个挂了,形成了脑裂。新注册的服务一会儿在这个分区能看到,一会儿在那个分区能看到,调用方疯狂报错。
排查了整整两天,最后发现是Consul的bootstrap_expect配置和实际节点数不一致导致的。更坑的是,脑裂恢复后,有些服务的注册信息丢失了,需要手动重新注册。
修正:简化架构,引入健康检查兜底
后来我们做了几个调整:
- Consul Server固定为5节点,奇数节点避免脑裂投票僵局
- 每个服务启动时强制重新注册,不依赖Consul的持久化状态
- 客户端增加缓存和降级:如果Consul连不上,用本地缓存的地址列表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| class ServiceDiscoveryClient { constructor() { this.consul = new Consul({ host: process.env.CONSUL_HOST }); this.localCache = new Map(); this.cacheTTL = 30000; }
async resolve(serviceName) { const cached = this.localCache.get(serviceName); if (cached && Date.now() - cached.timestamp < this.cacheTTL) { return this.pickHealthyInstance(cached.instances); }
try { const services = await this.consul.health.service(serviceName); const instances = services[0]?.Checks .filter(check => check.Status === 'passing') .map(check => ({ host: check.Service.Address, port: check.Service.Port, tags: check.Service.Tags })) || [];
this.localCache.set(serviceName, { instances, timestamp: Date.now() });
return this.pickHealthyInstance(instances); } catch (error) { console.error(`Consul查询失败,使用缓存: ${serviceName}`, error.message); if (cached) { return this.pickHealthyInstance(cached.instances); } throw new Error(`服务发现失败: ${serviceName}`); } }
pickHealthyInstance(instances) { if (instances.length === 0) { throw new Error('没有可用的服务实例'); } const index = Math.floor(Math.random() * instances.length); return instances[index]; } }
|
链路追踪:从日志地狱到清晰可观测
微服务最大的问题之一就是排查故障困难。一个请求经过五六个服务,到底在哪一步慢了、哪一步错了?我们一开始靠日志,结果成了日志地狱。
踩坑:没有Trace ID,排查全靠猜
早期每个服务各自打日志,格式不统一,时间戳还可能有时区问题。有一次用户反馈下单慢,我们查了三个服务的日志,发现时间对不上,有的用UTC有的用本地时间,有的精确到毫秒有的只到秒。最后花了四个小时才定位到是支付服务的第三方接口超时。
修正:统一Trace ID和日志规范
我们强制要求所有服务遵循统一的日志规范:
- 每个请求入口生成Trace ID,全程透传
- 日志格式统一为JSON,包含trace_id、span_id、service_name、timestamp
- 引入Jaeger做分布式追踪
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| const { v4: uuidv4 } = require('uuid');
function traceMiddleware() { return async (ctx, next) => { const traceId = ctx.get('x-trace-id') || uuidv4(); const spanId = uuidv4();
ctx.traceId = traceId; ctx.spanId = spanId;
ctx.logger = ctx.logger.child({ trace_id: traceId, span_id: spanId });
ctx.set('x-trace-id', traceId); ctx.set('x-span-id', spanId);
const startTime = Date.now();
try { await next();
ctx.logger.info({ event: 'request_completed', duration_ms: Date.now() - startTime, status_code: ctx.status, path: ctx.path, method: ctx.method }); } catch (error) { ctx.logger.error({ event: 'request_failed', duration_ms: Date.now() - startTime, error: error.message, stack: error.stack }); throw error; } }; }
class TracedHttpClient { async request(url, options = {}) { const headers = { ...options.headers, 'x-trace-id': asyncLocalStorage.getStore()?.traceId, 'x-span-id': asyncLocalStorage.getStore()?.spanId }; return fetch(url, { ...options, headers }); } }
|
配合Jaeger的UI,现在一个请求的全链路一目了然,哪个服务耗时多少、有没有报错,一眼就能看出来。
配置管理:环境配置分散的噩梦
微服务多了之后,每个服务都有自己的配置文件,开发环境、测试环境、生产环境的配置还不一样。我们一开始用环境变量,结果很快就失控了。
踩坑:配置分散导致生产事故
有一次上线,订单服务的数据库连接池配置在生产环境还是开发环境的值(最大连接数10),结果高峰期连接池被打满,大量请求排队超时。事后复盘发现,这个服务有7个配置文件,散落在代码仓库、服务器环境变量、Docker Compose文件里,根本不知道哪个生效了。
修正:集中配置中心 + 本地默认值兜底
我们引入了etcd作为配置中心,同时要求每个服务必须有本地默认配置,启动时从etcd拉取配置覆盖默认值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| const etcd = require('etcd3'); const localConfig = require('./default.json');
class ConfigManager { constructor(serviceName) { this.serviceName = serviceName; this.etcd = new etcd.Etcd3(); this.config = { ...localConfig }; this.watchers = []; }
async load() { const etcdConfig = await this.etcd .get(`/configs/${this.serviceName}`) .json() .catch(() => null);
if (etcdConfig) { this.config = this.mergeDeep(this.config, etcdConfig); console.log(`从etcd加载配置成功: ${this.serviceName}`); } else { console.warn(`etcd无配置,使用本地默认值: ${this.serviceName}`); }
this.watchers.push( this.etcd.watch() .prefix(`/configs/${this.serviceName}`) .on('put', (res) => { console.log('配置变更,热更新:', res.value.toString()); this.config = this.mergeDeep(this.config, JSON.parse(res.value.toString())); }) );
return this.config; }
get(key) { return key.split('.').reduce((obj, k) => obj?.[k], this.config); }
mergeDeep(target, source) { const result = { ...target }; for (const key of Object.keys(source)) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { result[key] = this.mergeDeep(result[key] || {}, source[key]); } else { result[key] = source[key]; } } return result; } }
module.exports = ConfigManager;
|
总结:微服务不是目的,是手段
八个月折腾下来,我最大的感悟是:微服务不是技术炫技,是为了解决实际问题。如果团队不到10人、业务不复杂,单体应用加好模块划分可能更合适。
如果一定要拆,记住这几点:
- 拆分粒度宁粗勿细,先把边界搞清楚再动手
- 分布式事务能避免就避免,实在避免不了用最终一致性
- 可观测性要提前做,日志、追踪、监控一个都不能少
- 配置集中管理,别分散在各处
- 服务发现要有降级方案,注册中心挂了服务不能全挂
我们现在五个核心服务运行稳定,部署时间从半小时缩短到5分钟,团队并行开发效率也提升了不少。但回头看,如果当初能更早认识到拆分粒度的问题,至少能省两个月时间。