几年前的一个凌晨三点,手机响了。我看了一眼屏幕------是监控系统发来的电话报警,这是一级生产事故,手机短信更是几十条。
这是这个月第三次。
我打开笔记本,ssh连到服务器:
- CPU使用率:98%
- 内存:16G用了15.8G
- MySQL连接数:1521/1500(爆了)
- PHP-FPM进程:全部卡在waiting状态
凌晨三点半,我在键盘上敲着重启命令。凌晨四点,系统恢复。凌晨四点半,我躺回床上。
但我知道,这只是权宜之计。
第二天早上的会议室
当时我们的系统是一个典型的PHP单体应用,运行了5年,代码量30万行。整个架构长这样:
200个进程] B --> C[(MySQL主库)] B --> D[(MySQL从库)] B --> E[Redis] B --> F[文件存储] style B fill:#ffcccc style C fill:#ffcccc
所有功能都塞在这一个项目里:用户管理、内容发布、评论系统、消息通知、数据统计、后台管理...改一行代码要重新部署整个系统,测试要测所有功能。
CTO拍了拍桌子:"必须重构了,改成微服务。"
我心里是拒绝的。
网上那些微服务的文章我都看过。服务拆分、网关、注册中心、配置中心、分布式事务...光是看着就头大。而且我们团队只有8个人,产品经理每周都在催新功能,哪有时间搞这些?
但架不住系统真的撑不住了。
第一步:别想着一次性重写
我们技术团队一共8个人。产品经理每周都在催新功能,根本不可能停下来搞三个月大重构。
所以定了个原则:老系统继续跑,新功能用新服务开发,慢慢蚕食老系统。
第一刀:砍掉短信服务
为什么先选短信?因为它最独立:
- 只有发送短信一个功能
- 其他模块只是调用,不关心内部实现
- 挂了也不影响核心业务(大不了短信晚点发)
我用Spring Boot写了一个短信微服务,技术栈选了Spring Cloud Alibaba。为什么选这套?主要是:
- 团队之前用过Spring,上手快
- Nacos既能做注册中心又能做配置中心,省事
- 中文文档多,遇到问题好搜
部署流程是这样的:
Spring Boot] --> A C[PHP老系统] -.HTTP调用.-> B D[Gateway网关] --> B style B fill:#ccffcc style C fill:#ffffcc
关键配置:
yaml
# application.yml
spring:
application:
name: sms-service
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
server:
port: 8081
关键点在于:
- 短信服务启动时自动注册到Nacos
- PHP老系统里,原来的发短信代码改成HTTP调用新服务
- 如果新服务挂了,降级回老的短信发送逻辑
代码改动很小:
php
// 老代码
function sendSMS($phone, $content) {
// 直接调用短信SDK
$smsClient->send($phone, $content);
}
// 新代码
function sendSMS($phone, $content) {
try {
// 先尝试调用新服务
$response = $httpClient->post('http://sms-service/send', [
'phone' => $phone,
'content' => $content
]);
return $response;
} catch (Exception $e) {
// 降级:新服务挂了就用老方式
$smsClient->send($phone, $content);
}
}
上线第一周,新短信服务处理了10%的流量。观察了一周没问题,逐步放量到100%。
这个过程中学到的第一个教训:千万别一上来就把流量全切过去。 我们同事负责的另一个项目,直接全量切换,结果新服务有个bug没测出来,导致一天内5万条短信发送失败。
第二步:Spring Cloud Gateway替代Kong
短信服务拆出去后,接着拆了消息推送服务、文件上传服务。问题来了:
- 手机App要调三个服务的接口,每个服务地址都硬编码在App里
- 每个服务都要做一遍JWT验证,重复代码到处都是
- 前端同学抱怨:以前调一次接口就行,现在要调三次,体验变差了
这时候我们引入了Spring Cloud Gateway作为API网关。为什么不用Kong?因为我们已经选了Spring全家桶,Gateway跟Nacos集成更方便。
Gateway的配置:
yaml
spring:
cloud:
gateway:
routes:
- id: sms-service
uri: lb://sms-service # lb表示从Nacos负载均衡
predicates:
- Path=/api/sms/**
filters:
- StripPrefix=1
- id: push-service
uri: lb://push-service
predicates:
- Path=/api/push/**
filters:
- StripPrefix=1
网关做的事情:
- 统一鉴权:JWT验证写一次,所有服务复用
- 动态路由:从Nacos自动发现服务,不用写死IP地址
- 负载均衡:Ribbon自动在多个服务实例间分配请求
- 限流保护:用Sentinel做限流,某个用户请求太频繁直接拦截
上线网关后,遇到的第一个坑:Gateway成了单点故障。
有一次Gateway挂了,所有请求都502。后来我们部署了3个Gateway实例,前面用Nginx做负载均衡:
服务发现] C --> E D --> E E --> F[后端微服务]
第三步:数据库拆分的噩梦
到2023年初,我们已经拆出来了6个微服务。但有个大问题一直没解决:所有服务还在共用一个MySQL数据库。
这违背了微服务的核心原则。但我们不敢拆,因为:
- 用户表被10个地方引用
- 订单表和评论表有外键关联
- 到处都是JOIN查询
直到有一天,DBA说MySQL主库快撑不住了,必须拆。
第一次尝试:直接拆库
我们先拿评论服务开刀,给它分配了独立的数据库。
新建)] style F fill:#ccffcc
问题马上来了:评论列表要显示用户昵称,但用户数据在另一个库里。
错误方案一:评论服务直连用户数据库查询
- 违背了微服务原则,服务间耦合
错误方案二:每次都调用用户服务接口查询
- 性能太差,展示100条评论要调100次用户服务
最终方案:数据冗余
- 评论表里加一个
user_name字段,发评论时就存进去 - 用户改昵称时,发消息通知评论服务更新
sql
-- 评论表设计
CREATE TABLE comments (
id INT PRIMARY KEY,
user_id INT,
user_name VARCHAR(50), -- 冗余字段
content TEXT,
created_at TIMESTAMP
);
很多人觉得这样不"优雅",数据重复存储了。但这就是微服务的代价:为了服务独立,必须接受一定程度的数据冗余。
分布式事务:能避免就避免
最头疼的是发布内容的流程:
- 内容服务:创建内容记录
- 用户服务:用户积分+10
- 消息服务:给粉丝发通知
三个操作要么都成功,要么都失败。以前在一个数据库里,一个事务就搞定。现在跨服务了,怎么办?
网上看到的方案:
- 两阶段提交(2PC):太慢,而且协调者挂了就全卡住
- TCC:要写三倍的代码,复杂度爆炸
- Seata的AT模式:听起来不错,但我们团队没人用过,不敢上生产
我们的实际做法:RocketMQ + 补偿
写入补偿任务表 RocketMQ->>消息服务: 消费消息 消息服务->>消息服务: 发通知给粉丝 Note over 用户服务: 定时任务每分钟扫描
补偿任务表,重试失败的操作
内容服务的代码:
java
@Service
public class ContentService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void publishContent(Content content) {
// 1. 保存内容到数据库
contentMapper.insert(content);
// 2. 发送MQ消息
ContentEvent event = new ContentEvent();
event.setContentId(content.getId());
event.setUserId(content.getUserId());
rocketMQTemplate.convertAndSend("content-topic", event);
}
}
用户服务消费消息:
java
@Service
@RocketMQMessageListener(
topic = "content-topic",
consumerGroup = "user-service-group"
)
public class ContentEventListener implements RocketMQListener<ContentEvent> {
@Override
public void onMessage(ContentEvent event) {
try {
// 增加用户积分
userMapper.addPoints(event.getUserId(), 10);
} catch (Exception e) {
// 失败了写入补偿任务表
compensationMapper.insert(new CompensationTask(
"add_points",
event.getUserId(),
10
));
}
}
}
这个方案的核心:
- 内容创建成功后,立即返回给用户
- 后续操作异步进行,允许短暂的不一致
- 失败了用补偿机制兜底,最终会一致
有一次用户服务挂了2小时,积分没加上。但重启后,补偿任务把这2小时的积分都补上了,用户无感知。
第四步:监控体系搭建
6个微服务上线后,排查问题变成了噩梦。
用户投诉:"我发布内容失败了。"
我要查什么?
- 先看Gateway日志,看请求有没有进来
- 再看内容服务日志,看有没有报错
- 如果内容服务调用了用户服务,还要看用户服务日志
- 还要看RocketMQ有没有消息堆积
一个问题要翻5个地方的日志,还要对着时间戳猜哪条日志是同一个请求的。
ELK日志聚合:别再登服务器
第一步是把所有日志收集到一起。我们用的是ELK技术栈:
- Elasticsearch:存储日志
- Logstash:收集和处理日志
- Kibana:查询界面
架构是这样的:
存储日志] F --> G[Kibana
查询界面]
每个Spring Boot服务的配置:
xml
<!-- pom.xml -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.2</version>
</dependency>
xml
<!-- logback-spring.xml -->
<configuration>
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>logstash-server:5000</destination>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"service":"content-service"}</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="LOGSTASH"/>
</root>
</configuration>
好处是什么?以前要ssh登录6台服务器看日志,现在在Kibana里搜索关键字,所有服务的相关日志都出来了。
SkyWalking链路追踪:找到慢在哪
但光有日志还不够,我需要知道一个请求经过了哪些服务,每一步花了多长时间。
这时候我们引入了SkyWalking。它是Apache的开源项目,跟Spring Cloud Alibaba集成特别好。
部署很简单,每个服务启动时加上agent:
bash
java -javaagent:/path/to/skywalking-agent.jar \
-Dskywalking.agent.service_name=content-service \
-Dskywalking.collector.backend_service=skywalking-server:11800 \
-jar content-service.jar
SkyWalking会自动:
- 给每个请求分配一个TraceID
- 记录请求经过的所有服务
- 记录每个服务的处理时间
- 记录数据库查询、HTTP调用的耗时
TraceID: abc123
10ms] --> B[内容服务
TraceID: abc123
50ms] B --> C[用户服务
TraceID: abc123
200ms] B --> D[RocketMQ
TraceID: abc123
5ms] style C fill:#ffcccc
有一次用户说"发布内容很慢",我在SkyWalking界面一看,发现用户服务的一个SQL查询花了180ms。原来是忘了加索引。
排查问题从以前的半天,缩短到现在的5分钟。
第五步:Sentinel熔断降级
2023年6月,遇到了一次严重故障。
消息推送服务调用了第三方推送平台的API,那天第三方平台挂了。结果是:
- 消息服务疯狂重试,把自己的CPU打满
- 因为消息服务慢,调用它的内容服务也开始超时
- 内容服务慢,导致Gateway的请求队列堆积
- 最终整个系统都不可用
一个第三方服务的故障,把我们整个系统拖垮了。
这次之后,我们在所有服务间调用上都加了Sentinel做熔断降级。为什么选Sentinel不选Hystrix?因为:
- Hystrix已经停止维护了
- Sentinel是阿里开源的,跟Spring Cloud Alibaba原生集成
- Sentinel有可视化的控制台,规则可以动态修改
不调用下游 note right of 半开状态: 放一个请求试探
看下游是否恢复
内容服务调用消息服务的代码:
java
@Service
public class ContentService {
@Autowired
private MessageServiceClient messageServiceClient;
@SentinelResource(
value = "publishContent",
blockHandler = "publishContentBlockHandler",
fallback = "publishContentFallback"
)
public void publishContent(Content content) {
// 1. 保存内容
contentMapper.insert(content);
// 2. 调用消息服务通知粉丝
messageServiceClient.notifyFollowers(content.getUserId());
}
// 降级处理:消息服务挂了,先不通知,后台补偿
public void publishContentFallback(Content content, Throwable e) {
contentMapper.insert(content);
// 写入补偿任务表,定时任务后续会补发通知
compensationMapper.insert(new NotifyTask(content.getId()));
log.warn("消息服务降级,写入补偿任务");
}
// 限流处理:请求太多直接拒绝
public void publishContentBlockHandler(Content content, BlockException e) {
throw new BusinessException("系统繁忙,请稍后重试");
}
}
Sentinel的规则配置:
java
@Configuration
public class SentinelConfig {
@PostConstruct
public void initRules() {
// 熔断规则
List<DegradeRule> rules = new ArrayList<>();
DegradeRule rule = new DegradeRule();
rule.setResource("publishContent");
rule.setGrade(CircuitBreakerStrategy.ERROR_RATIO.getType());
rule.setCount(0.5); // 错误率超过50%
rule.setTimeWindow(10); // 熔断10秒
rule.setMinRequestAmount(5); // 最少5个请求才统计
rules.add(rule);
DegradeRuleManager.loadRules(rules);
}
}
实际效果:第三方推送平台再次出问题时,Sentinel在1秒内熔断了对消息服务的调用,内容发布功能正常工作,只是粉丝通知延迟了。等第三方平台恢复后,补偿任务把延迟的通知都补发了。
第六步:配置中心和服务发现的实战
到2023年底,我们已经有12个微服务在运行。这时候新问题来了:
场景一:修改一个配置要重启所有服务
比如修改Redis的连接地址,要改12个服务的配置文件,然后重启12个服务。一次配置调整要花半小时。
场景二:服务实例动态扩容
业务高峰期,我们要临时启动几个服务实例。但前面的Nginx配置是写死的IP地址,每次扩容都要手动改Nginx配置。
这两个问题,Nacos都能解决。
Nacos配置中心:一处修改,全局生效
我们把所有配置都迁移到了Nacos配置中心:
实例1] -.动态拉取.-> A C[内容服务
实例2] -.动态拉取.-> A D[用户服务] -.动态拉取.-> A E[消息服务] -.动态拉取.-> A style A fill:#e1f5ff
服务的配置文件简化成这样:
yaml
# bootstrap.yml (每个服务都一样)
spring:
application:
name: content-service
cloud:
nacos:
config:
server-addr: 127.0.0.1:8848
file-extension: yaml
group: DEFAULT_GROUP
真正的配置都在Nacos里:
yaml
# Nacos中的content-service.yaml
spring:
datasource:
url: jdbc:mysql://192.168.1.100:3306/content_db
username: root
password: xxx
redis:
host: 192.168.1.101
port: 6379
rocketmq:
name-server: 192.168.1.102:9876
最爽的地方:在Nacos界面修改配置后,所有服务不用重启,30秒内自动生效。
java
@RestController
@RefreshScope // 关键注解:支持配置动态刷新
public class ContentController {
@Value("${content.max-size}")
private int maxSize; // 配置改了,这个值会自动更新
@PostMapping("/publish")
public void publish(@RequestBody Content content) {
if (content.getLength() > maxSize) {
throw new BusinessException("内容超长");
}
// ...
}
}
有一次线上出了个bug,是因为配置值写错了。以前要重新打包、发版、重启,至少要20分钟。现在直接在Nacos改配置,1分钟搞定。
Nacos服务发现:自动感知实例变化
之前我们的Nginx配置是这样的:
nginx
upstream content-service {
server 192.168.1.10:8081; # 写死的IP
server 192.168.1.11:8081;
}
这意味着每次扩容或缩容,都要改Nginx配置,然后reload。
现在有了Nacos服务发现:
IP: 192.168.1.15
端口: 8081 Nacos-->>新实例: 注册成功 Note over Nacos: 服务列表更新
content-service: 3个实例 Gateway->>Nacos: 定期拉取服务列表 Nacos-->>Gateway: 返回最新实例列表
[10, 11, 15] Gateway->>Gateway: 更新路由表 Note over Gateway: 现在3个实例都会收到请求
实际效果:晚上8点业务高峰,我在服务器上执行:
bash
java -jar content-service.jar --server.port=8082 &
java -jar content-service.jar --server.port=8083 &
30秒内,这两个新实例就自动加入了负载均衡,开始分担流量。完全不用改任何配置文件。
现状:两年后的架构
现在是2024年12月,距离开始重构已经过去了两年半。我们的架构变成了这样:
集群x3] C --> D[Nacos
注册&配置中心] D --> E[用户服务] D --> F[内容服务] D --> G[评论服务] D --> H[消息服务] D --> I[推送服务] D --> J[搜索服务] D --> K[统计服务] D --> L[支付服务] E --> M[(用户库)] F --> N[(内容库)] G --> O[(评论库)] E --> P[Redis集群] F --> P G --> P F --> Q[RocketMQ] H --> Q I --> Q R[ELK] -.收集日志.-> E R -.收集日志.-> F R -.收集日志.-> G S[SkyWalking] -.链路追踪.-> C S -.链路追踪.-> E S -.链路追踪.-> F style C fill:#ccffcc style D fill:#e1f5ff style P fill:#fff4cc style Q fill:#ffe6cc
关键数据对比:
| 指标 | 2022年(单体) | 2024年(微服务) |
|---|---|---|
| 服务数量 | 1个 | 15个 |
| 代码库 | 1个30万行 | 15个(平均1-2万行) |
| 部署时间 | 15分钟(全量) | 3分钟(单个服务) |
| 数据库 | 1个MySQL | 8个独立库 |
| 平均响应时间 | 450ms | 180ms |
| 日请求量 | 100万 | 500万 |
| 半夜被叫醒次数 | 月均3次 | 月均0.5次 |
血泪教训:我们踩过的坑
坑1:过度拆分
一开始我们把服务拆得太细了。比如"文件上传服务"、"文件下载服务"、"文件删除服务"分成了三个服务。
结果:
- 维护成本剧增:三个代码库、三套部署、三套监控
- 调用链路变长:上传一个文件要调三个服务
- 没有任何性能提升
后来我们把它们合并成一个"文件服务"。
教训:拆分的粒度要以业务能力为准,不是功能越细越好。
坑2:忽略了网络延迟
单体应用里,调用一个方法就是内存操作,几乎没有延迟。
微服务里,每次调用都是HTTP请求:
- 建立连接:5ms
- 序列化数据:2ms
- 网络传输:10ms
- 反序列化:2ms
- 总耗时:约20ms
一个页面展示如果要调用5个服务,延迟就是100ms。
我们的解决方案:
- 能批量查询的就批量查询
- 前端合理聚合请求,减少接口调用次数
- 关键路径用异步处理
教训:微服务不是银弹,会引入网络延迟。要权衡利弊。
坑3:没有做好监控就上线
消息服务最开始没有接入SkyWalking,结果出了问题完全不知道是哪个环节慢。
后来有一次故障,光排查问题就花了2小时。
教训:监控和告警必须在服务上线前就配置好,不是出问题了再补。
坑4:分布式事务滥用
看到网上的文章说要用Seata,我们就尝试引入。结果:
- 团队没人真正理解Seata的原理
- 调试困难,出问题不知道怎么排查
- 反而增加了系统复杂度
后来我们把大部分"分布式事务"改成了最终一致性方案,反而更稳定了。
教训:不要为了用新技术而用新技术。如果业务可以接受最终一致性,就不要强上分布式事务。
给想做微服务改造的团队的建议
1. 先问自己:真的需要微服务吗?
如果你的系统:
- 日请求量<10万
- 团队<5人
- 开发速度比性能重要
建议:先别拆。 把单体应用优化好,加缓存、做数据库读写分离、优化SQL,性能提升可能比微服务还好。
微服务的代价:
- 开发成本:调试困难,本地联调复杂
- 运维成本:要维护注册中心、配置中心、网关、监控系统
- 学习成本:团队要学新技术栈
如果团队压力大、人员紧张,贸然上微服务可能雪上加霜。
2. 渐进式改造,不要推倒重来
我见过太多失败的案例:
- 产品说等你们三个月,三个月后新系统上线
- 结果开发发现低估了复杂度,三个月变成了六个月
- 老系统还要继续改bug,新系统进度延期
- 最后新系统草草上线,问题一堆
正确做法:
- 老系统继续跑,满足日常需求
- 新功能用微服务开发
- 老系统的模块,找到边界清晰的先拆出来
- 一个一个慢慢蚕食,用1-2年时间完成迁移
3. 技术选型:够用就好
我们选Spring Cloud Alibaba,不是因为它最好,而是因为:
- 团队熟悉Spring
- 中文文档多,出问题能搜到答案
- 社区活跃,有问题能找到人问
相反,如果选了某个特别新、特别酷的技术,出了问题可能连Google都搜不到答案。
4. 监控比你想象的重要100倍
不夸张地说,监控系统救了我们无数次。
必须要有的监控:
- 日志聚合(ELK):所有日志集中查看
- 链路追踪(SkyWalking):知道请求经过了哪些服务
- 指标监控(Prometheus):CPU、内存、QPS实时监控
- 业务监控:支付成功率、注册转化率等关键指标
这些监控系统,建议在第一个微服务上线前就搭建好。
5. 文档和规范:省不了
微服务多了以后,如果没有统一的规范,会一团乱:
- 有的服务用RESTful,有的用RPC
- 有的返回驼峰,有的返回下划线
- 异常处理方式五花八门
我们后来定了几个规范:
- 统一的错误码设计
- 统一的日志格式
- 统一的接口返回结构
- 每个服务必须有接口文档(用Swagger)
虽然前期会觉得麻烦,但长远看能省很多沟通成本。
写在最后
从那个凌晨电话,到现在系统稳定运行,这两年半我们学到了很多。
微服务不是银弹,它解决了我们的扩展性问题,但也带来了复杂度。如果重新选择,我可能会:
- 更晚才开始拆分,把单体优化到极限
- 初期拆得更保守,只拆最必要的部分
- 在拆分前先把监控体系搭建好
但不管怎样,对于我们当时的场景,微服务改造是正确的选择。系统稳定了,我也终于可以睡个安稳觉了。
如果你的团队也在考虑微服务改造,希望我们的经验能帮到你。记住:没有完美的架构,只有合适的架构。
2025年12月记于上海
作者在某互联网公司任后端架构师,负责过多个系统的微服务改造。本文所有技术细节均来自真实项目经验,部分业务数据做了脱敏处理。