微服务架构实践踩坑记:从单体拆分到服务治理的100天
把一个运行 3 年的 Rails 单体拆成 12 个微服务,前 30 天把线上搞挂了 4 次。 这是我和团队花 100 天完成的一次"先拆后治"的真实经历。
这篇把踩过的坑、填坑的工具、以及最终沉淀下来的治理方案,原原本本摊出来。
一、为什么拆?—— 一个冠冕堂皇的真相
我们的单体是一个 Ruby on Rails 电商后台,3 年时间长到了 15 万行代码,部署一次 40 分钟,每次发版都像开奖。
表面理由:独立部署、独立扩容、技术栈自由。
真实原因:改一行代码要等 40 分钟才能上线,没人受得了。
说实话,拆分前的调研做得不够。第一次拆单体,先想清楚三个问题:
- 业务边界在哪里?(不是技术边界)
- 数据怎么切分?(最难的部分)
- 拆分后怎么调试?(大家都会忽略)
这三个问题我们一个都没想清楚就开工了。
二、第一阶段:拆(第 1-30 天)—— 把单体切碎
2.1 第一个服务:用户服务
选了最简单的切入——用户认证。Rails 里抽出 User 模型和相关逻辑,单独跑一个 Sinatra 服务,API 走 HTTP。
代码层面很简单:
# 原来的调用
current_user.orders
# 改成 API 调用
user_service.get_user(session[:user_id])
然后把原来的 User 表从主库拆到独立库,建了一个用户服务。看起来一切顺利。
2.2 第一次线上事故:Session 共享
上线后 10 分钟,监控告警:登录态大量失效。
排查过程:
单体 Session 存在 Redis key = session_id
用户服务也读同一个 Redis,但 key 前缀不同
负载均衡把请求随机打到两个服务
用户登录落到单体,下单落到用户服务 → 不认识这个 session
修复方案:统一 Session 存储,改成 JWT Token,用户服务只做 Token 签发和校验。
# 用户服务签发 JWT
payload = { user_id: user.id, exp: 24.hours.from_now.to_i }
token = JWT.encode(payload, SECRET, 'HS256')
当时为什么不用 JWT?因为"不想引入新依赖"。这个借口现在看起来蠢透了。
2.3 第二次事故:分布式事务
订单服务和库存服务拆开后,出现经典问题——下单扣库存不是原子操作了。
1. 订单服务创建订单 → 成功
2. 调用库存服务扣库存 → 超时(实际成功了)
3. 订单服务回滚 → 订单取消,但库存没加回来
试了最 naive 的方案:本地消息表。
-- 订单库里的本地消息表
CREATE TABLE local_messages (
id BIGSERIAL PRIMARY KEY,
business_type VARCHAR(32) NOT NULL, -- 'order.create'
business_id BIGINT NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(16) DEFAULT 'pending', -- pending/sent/failed
retry_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
轮询 + 重试,最多保证最终一致。但我们在第 7 天才加上幂等性,中间至少重复扣了 200 多次库存。
三、第二阶段:治(第 31-60 天)—— 治理比拆分更重要
3.1 服务注册与发现
服务从 2 个涨到 7 个,配置里写满了 IP 地址。发布时改配置、重启、祈祷。
用 Consul 做注册发现:
# docker-compose 里的 Consul 配置
consul:
image: consul:1.15
command: agent -server -bootstrap-expect=1 -ui -client=0.0.0.0
ports:
- "8500:8500"
服务启动时注册自己:
import consul
c = consul.Consul(host='consul:8500')
c.agent.service.register(
'order-service',
service_id=f'order-service-{hostname}',
address=host_ip,
port=50051,
check=consul.Check.tcp(host_ip, 50051, '10s')
)
客户端调用前先查服务地址,配合本地缓存和失败回退,不再硬编码。
3.2 可观测性:没有监控就等死
第 45 天,我们上了一套 Prometheus + Grafana + Jaeger。不是"锦上添花",是没有监控根本不知道系统怎么死的。
Metrics 埋点:
from prometheus_client import Counter, Histogram, generate_latest
REQUEST_COUNT = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status']
)
REQUEST_LATENCY = Histogram(
'http_request_duration_seconds',
'HTTP request latency',
['method', 'endpoint'],
buckets=[0.01, 0.05, 0.1, 0.5, 1, 2, 5]
)
第一个看到的数据就吓到我们了:有个服务的 P99 延迟是 12 秒。顺着 Jaeger 链路追踪查下去,发现是调用了另一个服务的同步 API,那个 API 又要查 MySQL —— 而 MySQL 连接池只有 5 个,全被慢查询占满了。
拆出微服务后,每多做一次远程调用,延迟就多一个数量级。以前是 5ms 的本地方法调用,变成 50ms 的 HTTP 调用,再经过层层传递,变成 500ms 的接口响应。
3.3 熔断与降级
链路问题暴露后,我们引入了 Hystrix 模式(用 Python 的 pybreaker):
import pybreaker
order_breaker = pybreaker.CircuitBreaker(
fail_max=5, # 连续 5 次失败
reset_timeout=30, # 30 秒后尝试恢复
)
@order_breaker
def call_order_service(order_id):
return http_client.post(
f"http://order-service/api/orders/{order_id}/status",
timeout=3
)
# 熔断后的降级
def get_order_status_fallback(order_id):
# 从本地缓存或 ES 读取只读数据
return order_cache.get(order_id) or {"status": "unknown"}
关键是:熔断不只是在代码里加个装饰器,得让业务方知道"这个接口可能返回降级数据"。 前端 PM 第一次看到订单状态显示 "unknown" 时,差点把手机摔了。
四、第三阶段:稳(第 61-90 天)—— 基础设施补全
4.1 API 网关统一入口
服务多了以后,客户端要记 12 个域名。用 Kong 做网关,统一接入层:
# 用 Deck 管理 Kong 配置
deck gateway sync kong.yaml
# kong.yaml 片段
services:
- name: order-service
url: http://order-service:50051
routes:
- name: order-route
paths:
- /api/v1/orders
plugins:
- name: rate-limiting
config:
minute: 100
- name: cors
网关层做限流、鉴权、日志,业务服务不再关心这些横切关注点。
4.2 配置中心化
之前配置散落在每个服务的 .env 文件里。改个数据库连接串要改 12 个文件。
用 Apollo 配置中心(或者简单的 Consul KV):
import consul
def load_config(service_name):
c = consul.Consul()
_, config = c.kv.get(f'configs/{service_name}/', recurse=True)
return {item['Key']: item['Value'] for item in config}
4.3 统一日志
每个服务写自己的日志文件,排查一个问题要 SSH 到 3 台机器上 grep。
# Filebeat 配置
filebeat.inputs:
- type: container
paths:
- "/var/lib/docker/containers/*/*.log"
output.elasticsearch:
hosts: ["elasticsearch:9200"]
index: "microservice-logs-%{+yyyy.MM.dd}"
Elasticsearch + Kibana,一个查询搞定跨服务日志搜索。这个应该在第一天就做。
五、第四阶段:优(第 91-100 天)—— 冷思考
5.1 数据一致性:Saga 模式
订单-库存-支付的分布式事务最终用 Saga 模式解决:
# Saga 编排器片段
class CreateOrderSaga:
steps = [
Step('inventory.reserve', compensate='inventory.release'),
Step('payment.charge', compensate='payment.refund'),
Step('order.confirm'),
]
def execute(self, context):
executed = []
for step in self.steps:
try:
step.action(context)
executed.append(step)
except Exception as e:
# 逆序补偿
for executed_step in reversed(executed):
executed_step.compensate(context)
raise SagaFailed(e)
不是所有业务都需要强一致性。花了 60 天才学会这个道理——那些拍着桌子说"必须即时一致"的人,看到最终一致方案加上补偿机制后,90% 都接受了。
5.2 不该拆的别拆
拆到第 10 个服务的时候,发现有些拆分是负优化。
- 配置服务和通知服务:调用频率极高,拆开导致 HTTP 开销大于业务计算开销
- 日志服务和审计服务:本质上写同一张表,拆分只是增加网络延迟
判断标准很简单:如果两个服务必须同时部署才能正常工作,它们就不该分开。
最后我们把 12 个服务合并回 8 个。
六、最终数据
100 天后的对比:
| 指标 | 拆分前 | 拆分后 |
|---|---|---|
| 单次部署时间 | 40 min | 3-8 min |
| 日均发布次数 | 0.5 次 | 12 次 |
| P99 延迟 | 800ms | 1.2s(变差了) |
| 线上事故 | 每月 2-3 次 | 0-1 次(但恢复更快) |
| 单服务启动时间 | 5 min | 10-30s |
| 团队开发效率 | 改 A 等 B | 各改各的 |
P99 变差了——这是微服务的代价。 延迟上付出了 50% 的代价,但换来了 24 倍的发布频率。这个交换值不值,取决于你的业务场景。
七、如果现在要拆
说四个大实话:
- 先治后拆。 没有可观测性、没有 CI/CD,不要动架构。先把监控、日志、部署流水线建好。
- 从最独立的业务开始。 用户服务是最好的切入点,订单和支付这种强耦合的留到最后。
- 接受最终一致。 分布式环境下 2PC 是反模式,用 Saga + 补偿 + 定期对账替代。
- 保留回退能力。 每个服务在拆分后至少运行两周"双写"模式,确保随时可以回滚。
100 天走下来,最深刻的感受是:微服务不是一个技术架构问题,是一个组织问题——团队能不能接受"调用会失败"这个事实,能不能为每个失败设计补偿路径。
如果团队只有 3-5 个人,单体加良好模块化可能是更好的选择。微服务值得拆,但不值得为了拆而拆。

评论已关闭!