大家想象一下,你开了一家小餐馆,刚开始只有你一个人,买菜、炒菜、收银、洗碗全干。这时候生意不错,但也没到忙不过来的程度,这就是单体架构。后来生意火了,每天几百桌,你一个人肯定炸了,这时候你得把后厨分开,有人专门切菜,有人专门炒菜,有人专门洗碗,这就是微服务架构。
我2019年接手过一个电商平台的重构项目,当时系统就是典型的单体应用,用Spring Boot 1.5写的,所有功能——用户、商品、订单、支付、库存——全在一个war包里。刚开始日活几千的时候还行,直到2020年双11,流量峰值一来,整个系统直接瘫痪。
那次事故我记得特别清楚。凌晨0点刚过,QPS冲到800的时候,订单接口响应时间从平均200ms飙升到5秒以上,紧接着Tomcat线程池满了,整个应用假死。我们紧急重启,但流量一进来又挂。最后排查发现,订单模块里有一段同步调用库存系统的逻辑,库存系统因为数据库死锁卡住了,订单系统的线程全被拖死,连带着用户登录都失败了。
这就是单体架构最要命的地方:一颗老鼠屎坏了一锅粥。一个模块出问题,整个系统跟着遭殃。
痛定思痛,我们决定拆分。第一步不是直接上微服务全家桶,而是先做了逻辑分离。我们把订单相关的表和业务逻辑从主库和主代码里抽出来,单独建立了一个order-service模块,但还跑在同一个进程里。这是为了验证业务边界对不对,避免一上来就分布式反而增加复杂度。
验证没问题后,我们开始物理拆分。当时选了Spring Boot 2.3(后来迁移到3.2.0,因为要支持Java 21的虚拟线程),用gRPC做服务间通信。拆分后,订单服务独立部署,有自己的数据库连接池。
拆分后的效果立竿见影。2021年双11,我们做了压测对比:
为什么提升这么大?因为订单服务是IO密集型,以前和CPU密集型的推荐算法挤在一起,资源争抢严重。拆开后,订单服务可以专门优化JVM参数,用更多的线程处理请求,而不用担心影响其他业务。
这里给大家看一段我们当时订单服务调用库存服务的核心代码,用的是gRPC 1.60版本,这个版本对HTTP/2的流量控制做了优化,特别适合高并发场景:
这段代码里有个细节,我设置了withDeadlineAfter(500ms)。这是血泪教训。有一次线上接口突然变慢,排查下来发现库存服务因为慢SQL导致响应变慢,订单服务因为没有超时控制,线程全卡在等待gRPC响应上,最后又导致了类似单体的雪崩。加上这个超时后,即使下游慢,订单服务也能快速失败,不影响其他请求。
很多团队一上来就把微服务拆得特别细,结果变成了“分布式单体”,服务间调用满天飞,运维比单体还难。我见过一个项目,把用户服务拆成了用户基本信息、用户积分、用户地址三个服务,结果查个用户详情要调三次接口,延迟直接翻倍。
怎么拆才合理?我这几年实践下来,最靠谱的方法是领域驱动设计(DDD)。别被这个名字吓到,其实很简单,就是按业务边界拆,而不是按技术层拆。
我们用DDD里的限界上下文(Bounded Context)来划分。拿电商来说,订单和支付虽然关系紧密,但它们属于不同的业务域。订单关注的是“买什么、买多少、送到哪”,支付关注的是“怎么付、付没付、退不退”。这两个上下文的模型是不一样的,强行放在一起,代码就会变成一团浆糊。
我通常会画一张上下文映射图(Context Map)。比如我们那个电商系统,核心域是订单和交易,支撑子域是库存和物流,通用子域是用户和权限。
在拆分时,我们面临过一次选择:订单和支付要不要拆开?
当时团队有两种声音。一种说拆,因为支付对安全性要求高,独立部署更稳;另一种说不拆,因为订单和支付强一致,拆了会有分布式事务问题。
我最后拍板:先拆,但用最终一致性。为什么?因为支付服务的调用量只有订单的1/10,如果绑在一起,大促时订单流量上来,支付服务也得跟着扩容,浪费资源。拆开后,支付服务可以用很小的实例跑,省钱。
这里有个具体的场景。有一次支付服务升级,需要停机维护10分钟。如果是单体,整个下单流程全挂。但因为拆了,我们只是在订单服务里做了个降级逻辑:如果支付服务不可用,订单先标记为“待支付”,等支付服务恢复后,通过消息队列补偿。用户感知到的只是支付稍微慢了一点,而不是下不了单。
关于服务拆分,我总结了一个“三个是否”的判断标准,不用去背教科书:
下面是我们当时做服务拆分决策时的一个简单评估代码,用来模拟不同拆分策略下的调用链路耗时:
运行这段代码你会发现,单体架构的耗时是累加的(20+50+30+100=200ms左右),而微服务架构通过异步化,主流程只需要关心核心的库存和订单(20+30+40=90ms左右),支付这种耗时长的操作可以扔到后面去。这就是拆分的威力,但前提是你的业务允许这种最终一致性。
2023年底,Spring Boot 3.2.0发布了,最大的亮点是支持Java 21的虚拟线程,还优化了GraalVM原生镜像(Native Image)。我当时正好在负责一个SaaS后台的改造,那个服务启动特别慢,K8s里滚动发布一次要3分钟,Pod冷启动要40多秒,经常在大促扩容时跟不上节奏。
我决定试水GraalVM。目标很明确:把启动时间从40秒降到5秒以内。
过程比我想象的要痛苦。GraalVM原生镜像的原理是在编译期就把代码、依赖库、JDK全部编译成机器码,所以运行时不需要JIT预热,启动极快。但这也带来了一个大问题:它不支持运行时的动态类加载和反射,除非你提前告诉它。
我们那个项目用了MyBatis和Druid连接池,这两个库大量使用了反射。刚开始编译就报了一堆错,说找不到类。
我遇到的第一个实际问题就是反射配置缺失。编译出来的原生镜像一启动就报错:java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。
排查过程是这样的:
pom.xml,依赖肯定在。reflect-config.json文件,用来注册那些需要在运行时通过反射访问的类。解决方法是使用Spring Boot 3.2提供的自动探测功能。Spring Boot 3.2在spring-boot-starter-parent里集成了GraalVM的Maven插件,它能自动分析代码里的反射调用。但我那个老项目里有些地方用了Class.forName()动态加载,插件没扫到。
我最后不得不手动补充配置。下面是我们最终reflect-config.json里关于数据库的一部分,这种细节网上很少直接给全:
除了反射,还有个坑是构建时间。以前打包一个Jar包只要30秒,编译GraalVM原生镜像要5分钟。这在CI/CD流水线里很要命。我后来调整了策略,只在发布到生产环境时才打原生镜像,开发环境还是用普通的JVM模式,这样开发体验不会下降。
优化后的数据非常惊人:
这里有一段我们pom.xml里关于GraalVM的配置,用的是Spring Boot 3.2.0的插件,大家可以直接参考:
现在回过头看,GraalVM并不是银弹。如果你的应用启动不频繁,或者用了很多动态特性(比如动态代理、字节码增强),强行上原生镜像会让你痛不欲生。但对于我们这种需要快速扩缩容、Serverless化的微服务场景,Spring Boot 3.2配合GraalVM确实是未来的趋势。特别是现在Istio 1.20也在推Ambient Mesh,数据平面更轻量了,再加上Wasm这种轻量级运行时,整个微服务生态都在往“快、轻、省”的方向走。
去年我们接手了一个百万日活社区的后端重构,当时系统跑在 Kubernetes 1.28 上,服务网格用的是 Istio 1.18 的 Sidecar 模式。起初大家觉得 Sidecar 挺好用,每个 Pod 里塞一个 istio-proxy 容器,流量治理、mTLS 加密都自动搞定。但问题很快暴露出来:一次大版本发布后,节点资源水位突然飙升,原本 2C4G 的节点跑 30 个 Pod 绰绰有余,现在只能跑 20 个,排查下来发现每个 istio-proxy 平均占用 45MB 内存,部分高流量服务甚至跑到 120MB,再加上 Sidecar 带来的网络跳转延迟,接口 P99 耗时从 220ms 涨到了 380ms。
那时候我们开始关注 Istio 1.20(2023年12月发布)主推的 Ambient Mesh 模式。它把数据平面从 Pod 里抽离出来,拆成两层:同节点共享的 ztunnel 处理基础四层流量和 mTLS,需要七层治理能力(比如超时、重试、熔断)的服务再按需挂载 Waypoint Proxy。我一开始也犯嘀咕:这不就是又回到节点级代理的老路了吗?直到我们拿社区的内容推荐服务做了灰度验证。
内容推荐服务平时 QPS 在 8000 左右,大促时峰值能到 2.5 万。之前用 Sidecar 时,每次扩缩容都要等 Sidecar 初始化,冷启动时间平均 12 秒。换成 Ambient Mesh 后,ztunnel 以 DaemonSet 形式跑在节点上,新的 Pod 一调度过来就能直接接入流量,冷启动缩短到 3 秒以内。我们对比了两种模式下的资源开销:Sidecar 模式下,100 个推荐服务 Pod 对应 100 个 istio-proxy,总内存占用约 4.8GB;Ambient 模式下,10 个节点总共 10 个 ztunnel,每个占用 80MB,总内存才 800MB,省了 83% 的内存。
但 Ambient Mesh 也不是万能的。我们当时遇到一个场景:社区的私信服务需要基于 HTTP Header 里的 X-User-Id 做流量染色,把特定用户的请求路由到新版本。这时候 ztunnel 的四层能力就不够用了,必须上 Waypoint Proxy。我们给私信服务配置了如下的 PeerAuthentication 和 Waypoint:
配置完之后,Waypoint Proxy 会作为服务的独立代理运行,处理七层路由规则。我们测试发现,加了 Waypoint 后,单请求延迟比 Sidecar 模式还低了 15ms 左右——因为 Sidecar 是 Pod 内的两个容器通信,要走 localhost 回环,而 Waypoint 是独立 Pod,走 Kubernetes Service 的 ClusterIP 转发,少了一次本地 socket 开销。
不过 Ambient Mesh 的运维复杂度确实比 Sidecar 高。比如 ztunnel 如果挂了,节点上所有没挂 Waypoint 的服务都会直接断流,而 Sidecar 模式下只会影响单个 Pod。我们后来给 ztunnel 配置了 Pod 反亲和性,确保同一节点的 ztunnel 不会同时重启,还加了 ztunnel 的 Prometheus 监控指标,当 ztunnel_up 指标掉零时立刻触发告警。现在社区 80% 的服务已经迁到 Ambient Mesh,只有少数需要复杂七层治理的支付相关服务还保留 Sidecar 模式,毕竟 Istio 1.20 的 Ambient Mesh 对七层能力的支持还不如 Sidecar 成熟,我们不想为了追新而牺牲稳定性。
今年 3 月的一个凌晨,我正睡得香,运维同学的电话直接打过来:“订单服务的 Pod 全部 OOM 了,重启了三次又挂了,现在用户下单全部失败,QPS 已经跌到 0 了。”我瞬间清醒,打开电脑连 VPN,先看了 Kubernetes 的事件日志,发现所有订单 Pod 的 Last State 都是 OOMKilled,内存限制设的是 2GB,实际占用已经冲到 2.1GB。
订单服务是我们用 Spring Boot 3.2.0(2023年11月发布,支持 Java 21 虚拟线程)写的,平时内存占用稳定在 1.2GB 左右,为什么突然涨这么多?我先拉了 Prometheus 的监控,发现订单服务调用库存服务的接口耗时从平均 50ms 涨到了 8 秒,错误率从 0.1% 飙升到 92%,同时订单服务的线程数从 200 个涨到了 800 个——Java 21 的虚拟线程虽然轻量,但 800 个虚拟线程的栈内存加起来也不是小数目,再加上请求堆积,内存自然就爆了。
接下来要查为什么库存服务变慢。我们系统接了 Jaeger 做链路追踪,我打开 Jaeger 界面,输入 order-service 的服务名,筛选最近 10 分钟的失败请求,找到一个 Trace ID 点进去,链路显示:用户下单请求到订单服务后,订单服务调用库存服务的 deductStock 接口,这个接口又调用了 Redis 和 MySQL,其中 MySQL 的查询耗时占了 7.8 秒。再看库存服务的日志,发现大量 MySQL connection timeout 的错误——原来大促前 DBA 调整了 MySQL 的最大连接数,从 200 降到了 100,而库存服务有 50 个 Pod,每个 Pod 的连接池最大是 20,总连接数需求是 1000,直接把 MySQL 连接池打满了。
问题找到了,但怎么快速止血?如果等 DBA 调回连接数,至少要 20 分钟,用户已经投诉到客服了。我想起之前给订单服务加过 Resilience4j 的熔断配置,只是当时没开强制熔断。Resilience4j 是我们在 Spring Boot 3.2.0 项目里用的熔断库,比老牌的 Hystrix 更轻量,支持函数式编程。我立刻改了订单服务的配置,把熔断的失败率阈值从 50% 降到 20%,等待时间从 10 秒降到 5 秒,同时加了降级逻辑:当库存服务熔断时,先把订单状态设为“待确认库存”,异步重试扣减库存。
改完配置后,我立刻用 kubectl apply 更新了 ConfigMap,Spring Boot 3.2.0 的热更新功能让配置 10 秒就生效了。再看监控,订单服务的线程数开始下降,内存占用也慢慢回到 1.3GB,QPS 逐渐恢复到 5000 左右。下面是当时用的 Resilience4j 配置和代码示例:
事后我们复盘,发现几个问题:一是库存服务的连接池没做动态配置,不能根据 MySQL 的连接数自动调整;二是之前熔断阈值设得太高,没起到提前防护的作用;三是链路追踪的告警没配,要是 Jaeger 能检测到库存服务耗时超过 1 秒就告警,我们就能提前处理,不用等到 OOM。后来我们把熔断的失败率阈值固定为 30%,加了基于 Jaeger 链路的告警规则,还把库存服务的连接池改成了动态配置,通过 Spring Boot 3.2.0 的 spring.config.import 从配置中心实时拉取,再也没出现过类似的 OOM 问题。
上个月我们团队做了 2024 年的技术规划,讨论最激烈的就是要不要把部分微服务迁到 Serverless 架构,同时引入 Dapr 做多运行时抽象。起因是我们有一个活动报名服务,平时 QPS 只有 100 左右,但每次社区办线下活动时,QPS 会突然冲到 5 万,之前用固定 Pod 部署,平时资源利用率只有 5%,活动时要扩到 100 个 Pod,活动结束又得缩回来,运维成本很高。
Serverless 其实就是事件驱动+FaaS 的组合,我们不需要一直跑着服务,只有当用户报名的事件触发时,才启动函数处理,处理完就释放资源。我们拿这个服务做了测试,用阿里云的 Function Compute 部署,函数用的是 Java 21 运行时,结合 Spring Boot 3.2.0 的 GraalVM 原生镜像(2023年11月发布的 Spring Boot 3.2.0 优化了 GraalVM 原生镜像的构建),冷启动时间从原来的 3 秒降到了 280ms,完全可以接受。但问题来了:原来的报名服务要调用 Redis 存报名信息,还要发消息到 Kafka 通知用户,这些基础设施的连接信息之前都是写在配置文件里的,Serverless 函数每次启动都要重新建立连接,冷启动时间反而变长了。
这时候我们想到了 Dapr(Distributed Application Runtime),它是微软开源的多运行时微服务框架,把服务调用、状态管理、发布订阅这些分布式能力抽象成 HTTP/gRPC 接口,服务不用直接依赖 Redis、Kafka 的 SDK,只要调用 Dapr 的 sidecar 就行。我们给报名函数配了 Dapr 的 state management 和 pub/sub 组件,配置如下:
然后在 Java 函数里调用 Dapr 的 API 存状态和发消息,代码变成了这样:
这么改之后,函数不用直接依赖 Redis 和 Kafka 的 SDK,Dapr sidecar 会帮我们管理连接池,冷启动时间进一步降到了 150ms。而且 Dapr 支持跨云,我们后来把部分函数迁到 AWS Lambda 上,只要改一下 Dapr 的组件配置指向 AWS 的 ElastiCache 和 MSK,代码一行都不用改,这正好契合了 2024-2026 年多运行时微服务的发展趋势——进一步抽象基础设施,实现跨云可移植性。
不过 Serverless 和 Dapr 结合也不是没有挑战。我们测试时发现,Dapr sidecar 的启动时间要 200ms 左右,加上函数的 150ms 冷启动,总冷启动时间 350ms,对于要求 100ms 以内响应的支付服务来说还是太慢。这时候 WebAssembly(Wasm)就派上用场了,2024 年的趋势里提到 Wasm 作为轻量级运行时替代部分容器,我们试了用 Wasm 跑 Dapr sidecar,启动时间只要 10ms,总冷启动时间降到了 160ms,但还是比支付服务的要求高,所以目前我们只把报名、消息推送这类对延迟不敏感的服务迁到了 Serverless+Dapr 架构。
另外 AI 驱动的服务治理也是 2024 年的热点,我们最近在测试基于 LLM 的日志分析工具,把 Jaeger 的链路日志、Prometheus 的监控指标喂给 LLM,它能自动定位异常根因,比如上次库存服务的连接池问题,LLM 看了 5 分钟的日志就指出是 MySQL 连接数不足,比我们人工排查快了 10 倍。不过现在 LLM 的误判率还有 15% 左右,不能完全替代人工,只能作为辅助工具。
总的来说,Serverless 和 Dapr 不是要替代现有的微服务架构,而是给特定场景提供更好的选择——像我们这种流量波动大的轻量服务,用 Serverless+Dapr 能把资源成本降低 70%,运维工作量减少 80%;但核心的、低延迟的服务,还是得用传统的微服务部署,结合 Istio 做流量治理,毕竟没有一种架构能适配所有场景。
去年帮一个做跨境电商的朋友改造系统,他们原来的单体应用已经撑不住黑五的流量,订单表都快两千万行了。我接手后第一件事就是把订单、库存、支付这三个核心域拆开。
当时选了 Spring Boot 2.7 搭配 Nacos 做注册发现,网关用的 Spring Cloud Gateway。拆分过程倒挺顺,但上线第一周就出了怪事:每天凌晨两点左右,订单服务的实例就会莫名其妙挂掉,重启后又正常。
我盯着监控看了两个晚上,发现是库存服务在跑定时对账任务,会批量调用订单接口。拆库后没有做读写分离,直接把订单主库打满了。后来我在订单服务前面加了一层 Resilience4j 的信号量隔离,把库存服务的调用限流在 20 个并发以内,同时把对账任务迁到了从库。
微服务这东西真不是银弹。
* 适合上的场景:业务线多、团队超过 10 人、核心模块需要独立扩缩容。比如那个电商系统,订单和推荐系统的负载完全不一样,拆开确实省资源。
* 没必要碰的场景:小团队、业务逻辑简单的内部系统。我见过一个五人团队非要上 Istio,结果光是维护那个 Sidecar 配置就占用了半个运维的人力,最后开发效率反而降了。
大家在学微服务时,别一上来就追 Dapr 或者 Ambient Mesh 这种新概念。先把 Spring Boot 的自动配置原理搞透,把 Docker 和 Kubernetes 的基础网络弄明白。架构是为了解决问题而生的,不是为了炫技。如果你现在的系统单体跑得好好的,千万别为了跟风而拆分,那是给自己挖坑。