14
微服务工程落地 · 从单体到服务拆分的实战路线
模块 14 · 微服务工程落地
本章目标:当客户系统从"一个大后端"长成"十几个服务"时,FDE 要能亲手拆分、部署、治理、排障。不讲微服务理论大全,只讲 FDE 在客户现场最常用的那套落地姿势:服务怎么拆、服务间怎么调用、分布式事务怎么兜底、网关和注册中心怎么配。
14.1 为什么要拆:单体的痛 vs 微服务的代价
客户最初的系统往往是一个 Spring Boot / FastAPI 单体。随着业务涨,开始痛:
- 发布越来越慢:改一个小功能要整包部署,回归测试全跑一遍。
- 互相影响:订单模块内存泄漏,把用户模块也拖垮。
- 团队协作打架:十个人改一个 Git 仓库,合并冲突天天有。
微服务的解法是把大应用按业务域拆成多个小服务,每个服务独立部署、独立数据库、独立扩缩容。但代价是:分布式复杂度全来了——网络调用会失败、数据一致性难保证、排障要跨服务追。
FDE 心法:不要为了拆而拆。客户系统日活不高、团队就三五个人,单体模块化比微服务更划算。微服务是"业务规模和团队规模到了那个点"之后的自然选择。
14.2 怎么拆:按业务边界,不按技术分层
最常见的拆法错误是按技术层拆:把"controller 层"拆一个服务、"service 层"拆一个服务——这纯属灾难,每次业务改动都要跨服务改三个地方。
正确姿势是按业务域拆(DDD 限界上下文):
| 服务 | 职责 | 自己的数据库表 |
|---|---|---|
| user-service | 注册、登录、用户资料、权限 | users, roles, sessions |
| order-service | 下单、订单状态流转、支付回调 | orders, order_items |
| payment-service | 对接微信/支付宝、对账 | payments, refunds |
| inventory-service | 库存扣减、回补 | stocks, stock_logs |
| notification-service | 短信、邮件、站内信 | messages, templates |
关键原则:一个服务独占一个数据库 schema,别的服务不能直接连它的表,只能通过 API 或消息拿数据。这叫"数据库 per 服务",是微服务自治的底线。
14.3 服务间怎么调用:同步 HTTP vs 异步消息
14.3.1 同步调用(HTTP / gRPC)
订单服务要扣库存,直接调 inventory-service 的接口:
# order-service 内部,下单时同步调库存服务
import requests
def create_order(user_id, items):
# 1. 先扣库存(同步 HTTP 调用)
for it in items:
resp = requests.post(
"http://inventory-service.internal/stocks/deduct",
json={"sku": it["sku"], "qty": it["qty"]},
timeout=3.0, # 必须设超时!
)
if resp.status_code != 200:
raise BusinessError(f"sku {it['sku']} 库存不足")
# 2. 写订单
order = Order(user_id=user_id, items=items)
db.session.add(order)
db.session.commit()
return order
同步调用的三个坑(必背):
- 没设超时:下游卡住,线程池耗尽,整个服务雪崩。
timeout=3.0是底线。 - 没重试/没熔断:下游短暂抖动就直接报错给用户。要加重试(只对幂等操作)和熔断(Hystrix/Resilience4j)。
- 循环依赖:A 调 B、B 调 A,发版顺序都排不出来。架构评审时就要禁掉。
14.3.2 异步消息(MQ)
下单成功后要发短信通知用户——这种"非核心路径"用异步消息解耦:
# order-service:下单成功后发一条消息,不等人
from kafka import KafkaProducer
producer = KafkaProducer(bootstrap_servers="kafka:9092")
def on_order_paid(order):
producer.send("order.events", value={
"event": "ORDER_PAID",
"order_id": order.id,
"user_id": order.user_id,
}.encode())
# 立刻返回,不等短信服务处理
# notification-service:订阅消息,慢慢处理
from kafka import KafkaConsumer
consumer = KafkaConsumer("order.events", bootstrap_servers="kafka:9092", group_id="notify")
for msg in consumer:
event = json.loads(msg.value)
if event["event"] == "ORDER_PAID":
send_sms(event["user_id"], f"您的订单 {event['order_id']} 已支付")
异步的好处:削峰、解耦、最终一致。坏处:调试难、消息可能重复(要做消费端幂等)。
14.4 分布式事务:别追求强一致,用最终一致
下单要同时写 orders 表和扣 stocks 表——跨服务了,本地事务管不到。常见三招:
| 方案 | 怎么做 | 适用场景 |
|---|---|---|
| 本地消息表 | 订单服务在同一个本地事务里写 orders + 写一条 outbox 消息,后台任务扫 outbox 发 MQ | 最常用,可靠 |
| TCC | Try-Confirm-Cancel 三步,每个服务实现两个接口 | 资金类强一致场景,成本高 |
| Saga | 一串本地事务,每步失败就反向补偿 | 长流程(如旅行预订) |
FDE 在客户项目里 90% 用本地消息表,简单可靠。代码骨架:
@app.route("/orders", methods=["POST"])
def create_order():
data = request.json
with db.transaction():
order = Order(**data)
db.add(order)
# 同一个事务里写 outbox
db.add(Outbox(
topic="order.events",
payload=json.dumps({"event": "ORDER_CREATED", "order_id": order.id}),
))
return {"order_id": order.id}
# 后台 worker:定时扫 outbox,发到 MQ,发成功了标记 sent
def outbox_relay():
pending = Outbox.query.filter_by(sent=False).limit(100).all()
for o in pending:
kafka.send(o.topic, o.payload)
o.sent = True
db.commit()
14.5 服务怎么找到彼此:注册中心 + 网关
服务 IP 是动态的(K8s Pod 重启就换),不能写死在配置里。需要注册中心:
- 每个服务启动时把自己的地址注册到 Nacos / Consul / etcd。
- 调用方从注册中心拿对端地址列表,做负载均衡。
- 服务挂了,注册中心心跳检测自动摘除。
API 网关是所有外部请求的统一入口:
# Nginx 网关路由示例
upstream user_service {
server user-svc:8000;
}
upstream order_service {
server order-svc:8001;
}
server {
listen 80;
location /api/users/ { proxy_pass http://user_service; }
location /api/orders/ { proxy_pass http://order_service; }
# 统一鉴权
location /api/ {
auth_request /internal/auth;
proxy_pass http://backend;
}
}
网关干的事:路由转发、统一鉴权、限流熔断、日志追踪、协议转换。业务服务本身不用关心这些横切关注点。
14.6 分布式追踪:一个请求跨了 5 个服务怎么查
单体时看一个日志文件就够了。微服务时一个下单请求可能经过网关→order→inventory→payment→notification,日志散在五台机器。必须上分布式追踪:
- 每个请求进来时生成一个
trace_id,通过 HTTP header / MQ 消息透传。 - 每个服务在日志里打印这个
trace_id。 - 用 Jaeger / Zipkin / OpenTelemetry 把同 trace_id 的跨度串起来,可视化看到请求链路和耗时。
# FastAPI 中间件:每个请求注入 trace_id
import uuid
@app.middleware("http")
async def add_trace(request, call_next):
trace_id = request.headers.get("X-Trace-Id") or str(uuid.uuid4())
request.state.trace_id = trace_id
response = await call_next(request)
response.headers["X-Trace-Id"] = trace_id
return response
14.7 动手练习(可折叠答案)
练习 1:给下面这个单体模块列表,圈出你会怎么拆成微服务
答案思路:典型电商单体里有——用户管理、商品管理、购物车、订单、支付、库存、优惠券、消息通知。
- 第一刀:用户服务、商品服务、订单服务(核心三域)。
- 第二刀:支付服务、库存服务(独立对账/扣减逻辑重)。
- 第三刀:通知服务(纯异步,最容易拆)。
- 购物车可以先跟订单放一起,优惠券跟商品放一起——不要一次拆太碎。
原则:先拆变化频繁、负载独立、边界清晰的;稳定的、简单的先留单体里。
练习 2:order-service 调 inventory-service 超时了,写出你的处理策略
答案思路:
- 设超时(如 2 秒),不能无限等。
- 失败重试 1~2 次(扣库存是写操作,要先确认是否幂等,否则不能盲目重试)。
- 重试还失败:熔断打开,快速失败给用户"系统繁忙请稍后",不要把线程池占满。
- 记录告警,通知值班。
- 如果库存服务是关键依赖,考虑降级:先创建订单待支付,后台异步补扣库存,失败了再关单。
练习 3:设计一个"下单后发站内信"的异步链路,保证消息不丢
答案思路:用本地消息表模式:
- 下单事务里同时写 orders 表和 outbox 表。
- outbox relay 进程轮询 outbox,发到 Kafka。
- Kafka 消息持久化,broker 挂了也不丢。
- notification-service 消费消息,处理完手动提交 offset。
- 消费端做幂等:message_id 唯一约束,重复消息直接跳过。
14.8 本章面试题
- "微服务是不是一定比单体好?"→ 答:不是。小团队、业务简单时单体更高效;微服务带来的分布式成本(网络、事务、排障)只有在业务规模和团队规模上去后才划算。
- "服务拆分怎么避免循环依赖?"→ 答:按业务域拆,建立依赖方向图(如 user 是最底层,order 依赖 user,不能反过来);架构评审时强制检查;公共逻辑下沉到共享库或独立基础服务。
- "跨服务数据一致性怎么处理?"→ 答:多数场景接受最终一致,用本地消息表 + MQ 兜底;资金类强一致才上 TCC;长流程用 Saga 补偿。
- "一个请求跨 5 个服务,怎么排查慢在哪?"→ 答:分布式追踪(trace_id 透传 + Jaeger),看每个 span 的耗时;结合日志和 metrics 定位是哪个服务/哪一步慢。
14.9 小结
- 微服务是规模到了的自然选择,不是目的本身。
- 按业务域拆,一个服务一个库,服务间只通过 API/MQ 通信。
- 同步调用必设超时+重试+熔断;非核心路径用异步消息解耦。
- 分布式一致性靠最终一致(本地消息表最实用),别硬上强一致。
- 注册中心 + 网关 + 分布式追踪是微服务治理的三件套。