微服务架构实践踩坑记:从单体拆分到服务治理的100天

2026-05-22 22:33 微服务架构实践踩坑记:从单体拆分到服务治理的100天已关闭评论

微服务架构实践踩坑记:从单体拆分到服务治理的100天

把一个运行 3 年的 Rails 单体拆成 12 个微服务,前 30 天把线上搞挂了 4 次。 这是我和团队花 100 天完成的一次"先拆后治"的真实经历。

这篇把踩过的坑、填坑的工具、以及最终沉淀下来的治理方案,原原本本摊出来。


一、为什么拆?—— 一个冠冕堂皇的真相

我们的单体是一个 Ruby on Rails 电商后台,3 年时间长到了 15 万行代码,部署一次 40 分钟,每次发版都像开奖。

表面理由:独立部署、独立扩容、技术栈自由。

真实原因:改一行代码要等 40 分钟才能上线,没人受得了。

说实话,拆分前的调研做得不够。第一次拆单体,先想清楚三个问题:

  1. 业务边界在哪里?(不是技术边界)
  2. 数据怎么切分?(最难的部分)
  3. 拆分后怎么调试?(大家都会忽略)

这三个问题我们一个都没想清楚就开工了。


二、第一阶段:拆(第 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 倍的发布频率。这个交换值不值,取决于你的业务场景。


七、如果现在要拆

说四个大实话:

  1. 先治后拆。 没有可观测性、没有 CI/CD,不要动架构。先把监控、日志、部署流水线建好。
  2. 从最独立的业务开始。 用户服务是最好的切入点,订单和支付这种强耦合的留到最后。
  3. 接受最终一致。 分布式环境下 2PC 是反模式,用 Saga + 补偿 + 定期对账替代。
  4. 保留回退能力。 每个服务在拆分后至少运行两周"双写"模式,确保随时可以回滚。

100 天走下来,最深刻的感受是:微服务不是一个技术架构问题,是一个组织问题——团队能不能接受"调用会失败"这个事实,能不能为每个失败设计补偿路径。

如果团队只有 3-5 个人,单体加良好模块化可能是更好的选择。微服务值得拆,但不值得为了拆而拆。

你可能感兴趣的文章

来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
转载请注明出处: https://teachcourse.cn/4164.html ,谢谢支持!

资源分享

分类:Android 标签:
Python库JWT实现token校验的示例 Python库JWT实现token校验的
一键缓存清理工具 一键缓存清理工具
Android Studio如何快速更改目录结构和包名? Android Studio如何快速更改目
Markdown一键发送工具 Markdown一键发送工具

评论已关闭!