微服务架构拆分踩坑记录

声明:本文部分内容使用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
// saga-orchestrator.js - 早期版本,已废弃
class OrderSaga {
async execute(orderData) {
const sagaId = generateUUID();
const steps = [];

try {
// Step 1: 扣减库存
const deductResult = await inventoryService.deduct(
orderData.productId,
orderData.quantity,
{ sagaId }
);
steps.push({ service: 'inventory', action: 'deduct', data: deductResult });

// Step 2: 创建订单
const orderResult = await orderService.create(orderData, { sagaId });
steps.push({ service: 'order', action: 'create', data: orderResult });

// Step 3: 扣减余额
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;
}
}
}

这段代码的问题在于:

  1. 补偿逻辑和业务逻辑耦合在一起,一个下单流程改了,补偿逻辑也要跟着改
  2. 网络超时的时候不知道到底执行到哪一步了,补偿可能重复执行
  3. 新增一个步骤要改 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
// order-service/internal/transaction.js - 最终版
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配置和实际节点数不一致导致的。更坑的是,脑裂恢复后,有些服务的注册信息丢失了,需要手动重新注册。

修正:简化架构,引入健康检查兜底

后来我们做了几个调整:

  1. Consul Server固定为5节点,奇数节点避免脑裂投票僵局
  2. 每个服务启动时强制重新注册,不依赖Consul的持久化状态
  3. 客户端增加缓存和降级:如果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
// service-discovery/client.js
class ServiceDiscoveryClient {
constructor() {
this.consul = new Consul({ host: process.env.CONSUL_HOST });
this.localCache = new Map();
this.cacheTTL = 30000; // 30秒缓存
}

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);
// Consul挂了,用缓存,即使可能过期
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和日志规范

我们强制要求所有服务遵循统一的日志规范:

  1. 每个请求入口生成Trace ID,全程透传
  2. 日志格式统一为JSON,包含trace_id、span_id、service_name、timestamp
  3. 引入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
// middleware/trace.js
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;
}
};
}

// HTTP客户端自动透传Trace ID
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
// config/index.js
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人、业务不复杂,单体应用加好模块划分可能更合适。

如果一定要拆,记住这几点:

  1. 拆分粒度宁粗勿细,先把边界搞清楚再动手
  2. 分布式事务能避免就避免,实在避免不了用最终一致性
  3. 可观测性要提前做,日志、追踪、监控一个都不能少
  4. 配置集中管理,别分散在各处
  5. 服务发现要有降级方案,注册中心挂了服务不能全挂

我们现在五个核心服务运行稳定,部署时间从半小时缩短到5分钟,团队并行开发效率也提升了不少。但回头看,如果当初能更早认识到拆分粒度的问题,至少能省两个月时间。