Appearance
微服务演进
绝大多数系统起步的时候都是单体——登录、用户、订单、报表、后台管理、定时任务,全都塞在一个代码仓库和一个进程里,部署一个包,日志看一处,数据库事务也在同一个库里搞定。
这种形态听起来好像"落后",其实一点也不。内部系统、早期业务、小团队项目,用单体往往更清楚、更高效。问题出在代码、团队规模、流量、发布节奏一起变大之后——单体就开始撑不住了,才会考虑拆服务。所以微服务不是"更高级"的架构,是"撑不住时的选择",拆得早不一定是好事。
一、单体应用
单体的典型形态是这样:一个后端进程里塞着所有模块,共享一个数据库:
代码都在一个仓库里的时候,很多事情确实好处理。一次请求在一个进程里走完,模块之间直接调函数,数据库事务能轻松覆盖多张表。排查问题也简单——从入口日志找到应用日志,再看数据库慢日志,链路短,问题在哪基本一眼能看出来。
但单体变重之后,症状会越来越明显:
| 现象 | 实际影响 |
|---|---|
| 启动越来越慢 | 本地开发、测试环境、发布都变慢,改一行代码要等几分钟 |
| 模块互相引用 | 改订单逻辑时,发现牵出来用户、库存、报表一堆代码 |
| 发布范围大 | 一个小改动也要发布整个应用,风险和工作量都不小 |
| 资源互相抢 | 报表跑个慢查询,把整个应用的线程池占满,登录都登不上 |
| 测试范围扩大 | 改一处要回归很多无关功能,测试工作量爆炸 |
单体还能维护的时候,不要急着拆。拆服务解决的是运行时边界、团队协作、独立发布这些问题,不是给代码换个更高级的名字。代码本身写得乱,拆成微服务只会更乱——只不过乱的位置从模块之间换成了服务之间。
二、服务拆分
微服务把一个大应用拆成多个独立进程。用户、订单、支付、消息、报表,各自有独立的接口、独立的部署、独立的数据库:
拆开之后好处确实多:订单服务压力大,可以单独扩订单服务;报表接口慢,可以单独给报表服务限流,不让它影响其他服务;用户服务要改代码,不需要重新发布支付服务。这种"按服务独立扩展、独立发布、独立故障"的能力,是单体做不到的。
代价是复杂度上升——原来进程内的一次函数调用,现在变成 HTTP、gRPC 或者消息调用,要走网络;原来一个事务能覆盖几张表,现在表分散在不同服务的数据里,事务保证变得困难;原来一份应用日志就能看完整请求,现在要靠 trace id 把网关、订单、支付、库存几段日志串起来。微服务把"单体内部的复杂度"换成了"服务之间的复杂度",总量没减少,只是搬家了。
三、服务发现
服务拆开之后,调用方怎么知道被调用方在哪里?最 naive 的做法是写死 IP——订单服务代码里写 10.0.1.23:8080 是用户服务。但用户服务一扩容、一重启、一迁移,这个 IP 就失效了,订单服务就得跟着改代码、重新发布。
服务发现机制解决的就是这个问题,本质上是"服务名到实例地址的动态映射":
| 概念 | 干什么 |
|---|---|
| 服务名 | 比如叫 user-service |
| 实例 | 服务实际跑在哪些 IP:Port 上 |
| 注册中心 | 保存服务名和实例列表(Nacos、Consul、Eureka) |
| 健康检查 | 定期判断实例是否还活着 |
工作流程大概是:服务启动时把自己的地址注册到注册中心,调用方通过服务名从注册中心拿到一组健康实例,然后选其中一个发请求。实例挂了或者新增了,注册中心都会动态更新,调用方自动感知,完全不需要改代码。
Kubernetes 里的 Service 也在做类似的事——调用方访问 user-service.default.svc.cluster.local,DNS 解析到 Service 这个虚拟对象,Service 再把请求转到后面的某个 Pod 上。Pod IP 一天可能变好几次,调用方完全不用关心。
四、配置中心
服务一多,配置文件就乱套了。数据库地址、Redis 地址、功能开关、限流阈值、第三方接口地址,如果散在每台机器的本地配置文件里,改一个配置要登录 N 台机器、改 N 个文件、回滚也要 N 次,审计更是无从谈起。
配置中心解决的就是这个问题——把所有服务的配置集中存放,服务启动时拉取,运行时也能动态推送更新。Nacos、Apollo、Spring Cloud Config 都属于这类方案。
配置中心常见概念:
| 对象 | 干什么 |
|---|---|
| namespace / 环境 | 区分开发、测试、生产环境 |
| data id / 配置名 | 一份配置的唯一标识 |
| group / 应用 | 区分不同服务或者业务线 |
| 版本记录 | 谁在什么时候改了什么,可以回滚 |
| 灰度发布 | 只让部分实例拿到新配置,验证没问题再全量 |
配置中心一旦接入生产,配置变更就和代码发布一样敏感,要走同样的审批和审计流程。举个真实的坑:把 payment.timeout 从 3s 改成 30s,短期看接口不报超时了,但每个请求占着线程 30 秒,几小时之后线程池被打满,整个支付服务直接雪崩。所以配置发布记录里必须能查到变更人、时间、旧值、新值,出问题的时候能立刻定位和回滚。
五、调用保护
微服务里有个特别讨厌的特性:故障会沿着调用链扩散。订单服务等支付服务返回,支付服务等银行接口返回,银行接口一变慢,上游所有服务的线程池就开始排队,最后整条链路一起死。这就是所谓的"雪崩"。
针对不同的问题有不同的应对手段:
| 问题 | 怎么应对 |
|---|---|
| 下游变慢 | 设置超时,不让一个慢请求一直占着线程 |
| 下游短暂失败 | 控制重试次数和退避间隔(避免重试风暴) |
| 接口字段变化 | 版本管理、契约文档、字段兼容 |
| 链路太长 | trace id 把所有日志串起来、链路追踪可视化 |
| 错误难定位 | 统一错误码、结构化日志 |
超时一定要写清楚——这是最基本的保护。没有超时的远程调用,可能一直占着线程等下去,几个这种调用就能把线程池耗光。重试也要克制,不能下游一失败就疯狂重试,那样会把下游彻底打爆,这叫"重试风暴"。
支付、下单、发券这种接口还要考虑幂等——同一个请求被执行多次,结果跟执行一次一样。否则一次网络抖动导致客户端重试,服务端就重复扣款或者重复发券,这是严重生产事故。一般做法是客户端生成一个唯一 ID,服务端用这个 ID 做去重。
熔断是更激进的保护:发现某个下游连续失败到达阈值,就直接"断开",上游调用直接快速失败,不再发请求给下游。这样既保护了下游(给它喘息恢复的时间),也保护了上游(不再被慢请求拖垮)。用户看到的现象可能是"支付通道繁忙,请稍后再试"或者"报表正在生成,稍后查看",比整个系统卡死要可控得多。
六、分布式事务
服务边界通常和数据边界绑定:用户服务管用户数据,订单服务管订单数据,支付服务管支付记录,各自对自己的数据负责,其他服务要通过接口访问。
这带来一个棘手的问题——分布式事务。一次完整的下单操作,可能要写订单、扣库存、创建支付单、发消息通知。在单体里,这些动作可以放在一个数据库事务里,要么全成功要么全回滚。但拆成微服务之后,这些动作分散在不同服务的不同数据库里,根本没法用一个事务包起来。
业界主流的处理方式是把流程拆成一系列状态变化,每个服务负责自己那段状态,通过消息驱动整个流程往前走:
| 步骤 | 状态 |
|---|---|
| 创建订单 | created |
| 锁定库存 | stock_locked |
| 创建支付单 | paying |
| 支付成功 | paid |
| 发货 / 取消 | shipped / cancelled |
这套机制涉及到的组件就多了:状态机管理、消息队列(Kafka、RocketMQ)、补偿任务(定时扫描异常状态)、幂等键(防止重复处理)、对账系统(发现状态不一致)。运维排查问题的时候,也不再是看"事务是否提交"这么简单,而是要看某个业务对象当前停在哪个状态、消息队列有没有积压、补偿任务有没有正常执行、有没有跨服务的状态不一致。
这就是微服务的真实代价——灵活性上去了,一致性、可观测性、运维复杂度全下来了。所以微服务不是越多越好,业务规模和团队能力没到那个程度,单体反而是更合理的选择。