Skip to content

微服务演进

绝大多数系统起步的时候都是单体——登录、用户、订单、报表、后台管理、定时任务,全都塞在一个代码仓库和一个进程里,部署一个包,日志看一处,数据库事务也在同一个库里搞定。

这种形态听起来好像"落后",其实一点也不。内部系统、早期业务、小团队项目,用单体往往更清楚、更高效。问题出在代码、团队规模、流量、发布节奏一起变大之后——单体就开始撑不住了,才会考虑拆服务。所以微服务不是"更高级"的架构,是"撑不住时的选择",拆得早不一定是好事。

一、单体应用

单体的典型形态是这样:一个后端进程里塞着所有模块,共享一个数据库:

代码都在一个仓库里的时候,很多事情确实好处理。一次请求在一个进程里走完,模块之间直接调函数,数据库事务能轻松覆盖多张表。排查问题也简单——从入口日志找到应用日志,再看数据库慢日志,链路短,问题在哪基本一眼能看出来。

但单体变重之后,症状会越来越明显:

现象实际影响
启动越来越慢本地开发、测试环境、发布都变慢,改一行代码要等几分钟
模块互相引用改订单逻辑时,发现牵出来用户、库存、报表一堆代码
发布范围大一个小改动也要发布整个应用,风险和工作量都不小
资源互相抢报表跑个慢查询,把整个应用的线程池占满,登录都登不上
测试范围扩大改一处要回归很多无关功能,测试工作量爆炸

单体还能维护的时候,不要急着拆。拆服务解决的是运行时边界、团队协作、独立发布这些问题,不是给代码换个更高级的名字。代码本身写得乱,拆成微服务只会更乱——只不过乱的位置从模块之间换成了服务之间。

二、服务拆分

微服务把一个大应用拆成多个独立进程。用户、订单、支付、消息、报表,各自有独立的接口、独立的部署、独立的数据库:

拆开之后好处确实多:订单服务压力大,可以单独扩订单服务;报表接口慢,可以单独给报表服务限流,不让它影响其他服务;用户服务要改代码,不需要重新发布支付服务。这种"按服务独立扩展、独立发布、独立故障"的能力,是单体做不到的。

代价是复杂度上升——原来进程内的一次函数调用,现在变成 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.timeout3s 改成 30s,短期看接口不报超时了,但每个请求占着线程 30 秒,几小时之后线程池被打满,整个支付服务直接雪崩。所以配置发布记录里必须能查到变更人、时间、旧值、新值,出问题的时候能立刻定位和回滚。

五、调用保护

微服务里有个特别讨厌的特性:故障会沿着调用链扩散。订单服务等支付服务返回,支付服务等银行接口返回,银行接口一变慢,上游所有服务的线程池就开始排队,最后整条链路一起死。这就是所谓的"雪崩"。

针对不同的问题有不同的应对手段:

问题怎么应对
下游变慢设置超时,不让一个慢请求一直占着线程
下游短暂失败控制重试次数和退避间隔(避免重试风暴)
接口字段变化版本管理、契约文档、字段兼容
链路太长trace id 把所有日志串起来、链路追踪可视化
错误难定位统一错误码、结构化日志

超时一定要写清楚——这是最基本的保护。没有超时的远程调用,可能一直占着线程等下去,几个这种调用就能把线程池耗光。重试也要克制,不能下游一失败就疯狂重试,那样会把下游彻底打爆,这叫"重试风暴"。

支付、下单、发券这种接口还要考虑幂等——同一个请求被执行多次,结果跟执行一次一样。否则一次网络抖动导致客户端重试,服务端就重复扣款或者重复发券,这是严重生产事故。一般做法是客户端生成一个唯一 ID,服务端用这个 ID 做去重。

熔断是更激进的保护:发现某个下游连续失败到达阈值,就直接"断开",上游调用直接快速失败,不再发请求给下游。这样既保护了下游(给它喘息恢复的时间),也保护了上游(不再被慢请求拖垮)。用户看到的现象可能是"支付通道繁忙,请稍后再试"或者"报表正在生成,稍后查看",比整个系统卡死要可控得多。

六、分布式事务

服务边界通常和数据边界绑定:用户服务管用户数据,订单服务管订单数据,支付服务管支付记录,各自对自己的数据负责,其他服务要通过接口访问。

这带来一个棘手的问题——分布式事务。一次完整的下单操作,可能要写订单、扣库存、创建支付单、发消息通知。在单体里,这些动作可以放在一个数据库事务里,要么全成功要么全回滚。但拆成微服务之后,这些动作分散在不同服务的不同数据库里,根本没法用一个事务包起来。

业界主流的处理方式是把流程拆成一系列状态变化,每个服务负责自己那段状态,通过消息驱动整个流程往前走:

步骤状态
创建订单created
锁定库存stock_locked
创建支付单paying
支付成功paid
发货 / 取消shipped / cancelled

这套机制涉及到的组件就多了:状态机管理、消息队列(Kafka、RocketMQ)、补偿任务(定时扫描异常状态)、幂等键(防止重复处理)、对账系统(发现状态不一致)。运维排查问题的时候,也不再是看"事务是否提交"这么简单,而是要看某个业务对象当前停在哪个状态、消息队列有没有积压、补偿任务有没有正常执行、有没有跨服务的状态不一致。

这就是微服务的真实代价——灵活性上去了,一致性、可观测性、运维复杂度全下来了。所以微服务不是越多越好,业务规模和团队能力没到那个程度,单体反而是更合理的选择。