凌晨两点半,值班电话响了。报障信息写着:订单服务两台实例,一台CPU已经98%,另一台只有11%,请求超时率从0.1%直接飙到30%。我打开监控面板看了一眼,第一反应不是机器不够,而是session又卡在某一台上了。这个场景做后端的朋友应该都不陌生——你以为上了两台机器就是高可用,可实际上高可用从来不是“多买几台机器”这么简单。真正的高可用,是由三件事协同完成的:无状态化、水平扩展、故障转移。这三件事单独拆开看都不复杂,但把它们串成一个体系,让系统在任意一台机器宕机时都能无感切换,才是核心难点。这篇就从这三件事的协同设计讲起,讲清楚为什么顺序不能乱、每一步怎么落地、以及我在实操中踩过的那些坑。适合正在做高可用改造的后端开发、架构师和运维同学。
1. 先捋清楚:高可用不是三件事,而是一条链路
很多团队对高可用的理解停留在“多部署几个实例 + 前面挂个负载均衡”。这个认知在系统规模小的时候勉强够用,一旦流量上来或者真的发生故障,问题就会暴露得非常彻底。
我上面提到的那个报障案例,其实就有三台实例,负载均衡配了,健康检查也开了,按理说一台机器出问题,流量应该自动打到另外两台。但实际情况是:用户登录后,session被绑定在实例A上,负载均衡为了保证用户体验,开启了会话保持(sticky session),于是所有带session的请求都强制路由到A。A被打到CPU 98%,另外两台闲着没事干。这不是典型的“故障转移没生效”,而是无状态化没做,导致水平扩展和故障转移全部失效。
1.1 高可用问题往往不是“机器挂了”,而是“设计没协同”
先给这三个概念一个明确的定位:
- 无状态化:让任意一个实例都能处理任意一个请求。不依赖“上一次请求落在哪台机器上”。
- 水平扩展:通过增加或减少实例数量来应对流量变化。核心前提是实例“可复制”,也就是无状态。
- 故障转移:当某个实例异常时,自动把流量摘掉,由其他实例接续服务。也就是所谓的“集群故障转移”。
三者之间的关系是这样的:
| 能力 | 解决的问题 | 如果缺失会怎样 |
|---|---|---|
| 无状态化 | 请求与实例解耦 | 故障转移后用户被踢下线,或流量倾斜到个别实例 |
| 水平扩展 | 容量随流量弹性变化 | 流量高峰只能硬扛,无法通过加机器缓解 |
| 故障转移 | 单点故障自动恢复 | 实例宕机后需要人工介入,业务中断数分钟甚至更久 |
很多人会把“集群故障转移”单独当成一个运维动作,觉得配好负载均衡的健康检查、故障时自动摘流就算完成了。但这个动作要想真正生效,前面两件事必须已经做完:无状态化保证“别的实例能接得住”,水平扩展保证“有别的实例可以接”。没有这两者,故障转移就是一句空话。
1.2 三者的执行顺序:先无状态化,再谈扩展与转移
我在很多项目里见过一种操作:为了应对大促,先扩容了一堆实例,结果流量一上来,新扩容的实例完全没有用户session,导致大量请求被踢下线,用户骂声一片。这就是顺序没搞对。
正确顺序是:
- 先做无状态化改造,把session、本地缓存、临时文件这些“绑定在单机上”的状态全部外置。
- 再做水平扩展,确保新增实例可以无缝接入集群,承接流量。
- 最后做故障转移,通过健康检查、自动摘流、重试机制,让单点故障对用户不可见。
这个顺序是不可逆的。无状态化是地基,水平扩展是楼层,故障转移是消防通道。你不可能先盖楼再打地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一件事:无状态化,把“这台机器”变成“任意一台机器”
无状态化这个概念被说烂了,但真正理解它的人不多。有人以为“无状态就是服务里不存任何数据”,这太绝对了。服务不可能完全不保存任何东西,关键是看你保存的数据是否与具体实例强绑定。
判断标准很简单:**把任意一台实例直接kill掉,重启一台新的,流量重新分配后,系统是否仍然能正常工作且不丢数据。**如果能,这个服务就是无状态的;如果不能,那它就还有状态没迁干净。
2.1 状态到底藏在哪:四类隐性状态清单
我梳理一下常见的有状态场景,基本逃不出下面这四类:
第一类:会话数据(Session)
这是最常见也最容易被忽视的。Java应用默认的HttpSession存储在JVM内存里,一旦这台机器挂了,所有登录用户的会话全部丢失。负载均衡开启sticky session后,还会导致流量倾斜。
第二类:本地缓存
比如用Caffeine或Guava Cache在进程内做的热点数据缓存。这类缓存在单机模式下很好用,但一旦水平扩展到多实例,每个实例的缓存是独立的,数据不一致、命中率下降都还算小事,更麻烦的是实例重启后缓存冷启动,瞬间流量直接打到数据库。
第三类:本地临时文件与磁盘存储
用户上传的图片、生成的临时报表、导出的Excel,如果直接写在实例的本地磁盘,故障转移时这些文件就找不到了。
第四类:定时任务与分布式锁
这是一个非常隐蔽的状态。很多系统里有个定时任务,每天凌晨跑一次报表,部署在多实例后,如果不加锁,每个实例都会执行一遍,导致重复计算和数据错乱。但如果简单地把定时任务固定只跑在一台机器上,那这台机器又成了单点。
2.2 实操改造:会话外置、缓存下沉与任务收敛
针对上面四类状态,我的改造方案如下:
Session外置到Redis
Spring Boot场景下,直接把spring-session-data-redis引进来,session存储从Tomcat内存切换到Redis,代码改动量很小:
yaml复制spring:
session:
store-type: redis
timeout: 30m
redis:
host: redis-cluster.example.com
port: 6379
code复制
这样改完之后,任何一个实例都能验证用户的session,不再依赖sticky session。
**本地缓存改多级缓存**
本地缓存不是要彻底去掉,而是要分层。我的做法是:**一级用Caffeine本地缓存,二级用Redis分布式缓存**。
```java
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build();
}
}
本地缓存命中则直接返回,未命中则查Redis,Redis未命中才查数据库,并回填两级缓存。这里特别注意缓存失效通知:当某个key被更新时,需要通过Redis Pub/Sub通知所有实例删除本地缓存,否则会出现数据不一致。
临时文件转移到对象存储
上传的文件直接写入OSS或S3,应用实例本地不再落盘。生成报表的场景,先写到对象存储临时目录,处理完成后再转存到正式路径。
定时任务用分布式锁收敛
我推荐直接引入分布式调度框架,比如ElasticJob或XXL-Job,把定时任务收敛到调度平台统一管理,由调度平台决定哪个实例执行。如果不想引入额外框架,也可以用Redis分布式锁手动实现:
java复制try {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("job:report:lock", "1", Duration.ofMinutes(30));
if (!locked) {
return; // 其他实例已在执行
}
// 执行业务逻辑
} finally {
redisTemplate.delete("job:report:lock");
}
这个方法简单有效,但要注意锁的过期时间要大于任务执行时间,否则锁提前释放会导致重复执行。
注意:无状态化改造做完后,Redis本身就变成了全局依赖,Redis的可用性必须同步跟上。我见过不少团队把session迁到Redis,但Redis是单机,Redis一挂,整个系统比之前崩得更彻底。这个问题的解法是Redis主从加哨兵,或直接上Redis Cluster。
3. 第二件事:水平扩展,让容量跟着流量走
无状态化完成后,真正的“复制粘贴式扩容”才成为可能。但水平扩展不是简单的“加机器”,它包含容量评估、自动伸缩、优雅上下线三个环节。
3.1 扩容前先定位:瓶颈到底在哪一层
这是很多团队容易忽略的。看到流量涨了就直接扩应用实例,结果发现瓶颈在数据库连接池,扩再多应用实例也没用,数据库先被打爆。
扩容之前先看监控,判断瓶颈分层:
| 瓶颈层 | 关键监控指标 | 典型表现 |
|---|---|---|
| 入口网关 | 连接数、请求QPS | 网关CPU高,大量连接排队 |
| 应用层 | CPU、线程池活跃数、RT | 应用CPU 70%以上,线程池满 |
| 数据层 | 数据库连接数、慢SQL、QPS | 慢SQL增多,连接池耗尽 |
只有当瓶颈确实在应用层时,水平扩展应用实例才有意义。如果瓶颈在数据层,你需要优先考虑的是缓存、读写分离或分库分表,而不是盲目加实例。
3.2 手动扩容到自动伸缩:三阶段演进
我建议团队按三个步骤逐步演进:
阶段一:手动扩容
这是最基本的能力。在负载均衡或注册中心后台,手动添加/删除实例节点。这个阶段唯一要做的就是把操作流程文档化,避免上线高峰期手忙脚乱。
阶段二:基于监控的自动伸缩
以Kubernetes的HPA为例,最简单的配置如下:
yaml复制apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 600
关键点有两个:一是指标的选择,CPU使用率是最常见的,但更推荐结合“请求QPS”或“线程池活跃度”来判断,CPU在某些场景下对负载的反映不够及时。二是缩容冷却时间,我设置的是10分钟,防止流量抖动导致频繁扩缩容。
阶段三:预测式伸缩
如果你的业务有明显的波峰波谷,比如每天早上10点和晚上8点流量高峰,可以结合定时伸缩策略,在高峰到来前提前扩容。这个需要结合业务数据积累,属于进阶玩法,初期不用追求。
3.3 新节点上线时的优雅引流
这是我反复踩过的一个坑。新节点刚启动,进程起来了,但JVM还没完成类加载、连接池还没建好连接、缓存还是空的,这时候直接把流量打进来,新节点的响应时间会非常难看,甚至直接被打死。
正确做法是分阶段引流,让新节点“先热身,再接客”。
拿Nginx负载均衡举例,权重可以做为预热开关。新节点加入时先给一个很小的权重,比如1%,让少量健康流量预热两分钟,确认各项指标正常后再逐步上调到正常权重。
K8s环境下更简单,配置好readinessProbe就够:
yaml复制readinessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
readiness探针在应用真正就绪前不会放流量进来,比TCP探活靠谱得多。
4. 第三件事:集群故障转移,把“意外”变成“日常”
故障转移要设计的目标,不是“出了故障能恢复”,而是“出了故障用户无感知”。这才是集群故障转移这个词的真正含义。
4.1 健康检查怎么设计才算“真实”
绝大多数系统的健康检查配置的是TCP端口探测:进程在,端口通,就是健康的。但这个检查维度太粗糙了。进程活着,不代表能正常处理业务。我见过端口正常但线程池满了、JVM疯狂Full GC、连接池耗尽的情况,健康检查依然是绿的。
真正可靠的健康检查应该是业务探活。提供一个专门的接口,比如/actuator/health,在这个接口内部去校验依赖的关键下游是否可用:数据库连接是否正常、Redis是否可读写、消息队列是否可达。只有当这些都正常时,才返回200 OK。
java复制@GetMapping("/health")
public ResponseEntity<Map<String, Object>> health() {
Map<String, Object> status = new HashMap<>();
boolean dbOk = checkDatabase();
boolean redisOk = checkRedis();
if (dbOk && redisOk) {
status.put("status", "UP");
return ResponseEntity.ok(status);
}
status.put("status", "DOWN");
return ResponseEntity.status(503).body(status);
}
探活参数的设置也有讲究。我常用的配置是:每5秒探测一次,连续3次失败判定实例不健康,连续2次成功判定恢复。这个频率和阈值不是固定的,要根据服务的响应速度和故障容忍度去调,原则是故障发现不要太慢,但又不能因为网络抖动误杀实例。
4.2 故障转移完整流程:探活-摘流-重试-恢复
一个完整的集群故障转移流程应该是这样的:
- T+0秒:实例A的JVM发生OOM,进程僵死。TCP端口仍通,但业务探活接口超时。
- T+5秒:健康检查连续3次失败,负载均衡将A标记为不可用,摘除流量。
- T+6秒:用户下一个请求被转发到B实例。由于无状态化改造已完成,B实例不依赖A的本地数据,直接处理成功。
- T+10秒:A进程被运维系统检测到异常,自动拉起新实例。
- T+40秒:新实例启动完成,readiness探针通过,注册到负载均衡。
- T+60秒:新实例以较低权重开始接收少量流量,2分钟后权重恢复正常。
这个流程看起来简单,但每一步都可能出问题。最典型的是第3步——请求被转发到B后,如果B处理的是“扣款”这类写操作,而用户在A上的请求实际上已经成功了,这时候如果B直接再执行一次,就会发生重复扣款。
所以,故障转移必须和幂等设计配合。我的做法是:客户端在发起请求时生成一个全局唯一的requestId,服务端在处理请求时先检查这个requestId是否已经处理过,处理过就直接返回上次的结果,不再重复执行。
java复制public Order createOrder(OrderCreateRequest request) {
String requestId = request.getRequestId();
Boolean firstRequest = redisTemplate.opsForValue()
.setIfAbsent("idempotent:" + requestId, "1", Duration.ofMinutes(10));
if (!firstRequest) {
// 重复请求,直接返回之前的结果
return getFromCache(requestId);
}
// 正常处理业务逻辑
Order order = doCreateOrder(request);
// 缓存处理结果
cacheResult(requestId, order);
return order;
}
4.3 故障转移的边界:哪些场景不能自动转
这个我一定要单独说。不是所有故障都适合通过“摘流量”来解决。
场景一:下游数据库故障
如果是数据库本身挂了,所有实例的探活接口都会变成DOWN,负载均衡会把所有实例都摘掉,结果就是整个集群对外表现为不可用。此时更适合的方案是熔断降级——比如改为读缓存、返回兜底数据,而不是让故障转移的“摘流”逻辑把所有实例都干掉。
场景二:依赖的第三方服务变慢
第三方服务变慢,会导致实例线程池被占满,如果此时健康检查执行到“检查第三方依赖”这步并判定实例不健康,会引发连锁摘流。这里需要区分“实例本身有故障”和“依赖的服务有故障”。
场景三:消息队列消费重复
故障转移导致实例A消费了消息但还没提交offset,A挂了,消息被B重新消费,业务上就出现了重复处理。这个问题需要消费端配合做消息去重。
出现这类场景,我的建议是把熔断降级和故障转移分开设计。故障转移针对的是“实例挂掉”这一种异常,而“依赖异常”应该走熔断器或线程池隔离方案,给系统留出降级处理的余地。
5. 协同落地:三件事怎么串成一条流水线
说了这么多原理,拿一个真实的业务场景完整走一遍。假设有一个订单服务,包含用户登录、下单、上传凭证图片、每日凌晨跑一个汇总报表这几个功能。
改造前是这样的:
- Tomcat部署,session存在本地内存,负载均衡开启sticky session。
- 用户上传的图片存在实例本地磁盘。
- 每天凌晨2点的报表任务,通过crontab指定只在某一台机器上执行。
- 集群一共2台实例,一台宕机后,另一台无法接管session,用户全部掉线,报表任务也无人执行。
改造后是这样:
- session迁到Redis,实例完全无状态。
- 图片上传后直接写入对象存储。
- 定时报表任务交给分布式调度平台,任意实例都可执行,Redis分布式锁兜底防止重复。
- 应用容器化部署在K8s上,HPA根据CPU和QPS自动伸缩,实例数范围3~10。
- 业务探活接口检查数据库和Redis的连通性,健康检查失败自动摘流并拉起新实例。
| 阶段 | 故障恢复时间 | 用户影响 |
|---|---|---|
| 改造前 | 人工发现-登录服务器-排查-重启,约10~30分钟 | 用户掉线,现场不可用 |
| 改造后 | 探活失败5秒摘流,新实例40秒拉起 | 无感知,个别请求因重试多等1~2秒 |
这个过程中,最核心的链路就是:**无状态化让B能处理A的请求,水平扩展提供足够的B,故障转移让流量自动切换到B。**三件事的协同,在这一条流水线上体现得淋漓尽致。
我特别想强调一个点:无状态化改造不是“做一次就完了”,它需要在后续每次新增功能时都检查一遍。比如团队新来一个同学,开发了一个新接口,为了图方便在进程内存里存了个Map,这就是把状态又带回来了。所以无状态化不是技术问题,是规范问题,建议把代码评审里加一条检查项:新增的数据是否绑定了实例本地资源。
6. 常见问题与排查技巧实录
最后整理几个我在高可用改造中真正遇到过的典型问题,每一个都是拿线上事故换来的经验,希望能帮大家避坑。
6.1 Redis成为新的单点
问题现象:session迁到Redis后,Redis实例CPU经常飙到90%,请求大面积超时。
排查过程:检查Redis慢日志,发现大量的GET和SET操作,key集中在sessionKey上。进一步分析,原来是session过期时间设置得太长(2小时),加上业务高峰期活跃用户多,Redis内存压力巨大。
解决思路:一是缩短session过期时间,改成30分钟;二是Redis集群化,把压力分散到多个节点;三是给Redis增加内存淘汰策略,防止内存写满。
6.2 健康检查“全绿”,但流量依然超时
问题现象:负载均衡显示所有实例都是UP,但用户反馈请求经常超时。
排查过程:登录实例看日志,发现实例的数据库连接池已经满了,所有请求都在排队等连接。但健康检查接口只检查了端口通不通,没有检查连接池状态,所以显示UP。
解决思路:健康检查接口增加连接池状态检查,把“连接池使用率超过80%”也视为不健康。同时检查了一下数据库的max_connections,发现配置偏低,顺手做了优化。
6.3 扩容后新实例瞬间被打死
问题现象:流量高峰触发自动扩容,新增了2台实例,但这两台实例启动后不到5分钟就被打挂了一台。
排查过程:看启动日志,新实例启动后直接涌入大量请求,但JVM还没来得及完成JIT编译、连接池还在建立连接的过程中,大量请求累积导致线程池耗尽,最终OOM。
解决思路:给新实例加预热机制。当时我们的处理是在负载均衡层面配置新实例的上线权重,初始权重5%,每5秒增加5%,1分钟后达到100%。这样能有效避免新实例被瞬时流量打死。
6.4 故障转移后出现重复扣款
问题现象:某次故障演练中,把A实例直接kill,用户正好在提交订单。故障转移后,用户收到了两条扣款短信。
排查过程:A实例处理用户请求时,已经完成了扣款动作,但还没来得及返回响应给客户端,A就挂了。负载均衡把请求重新转发给B,B又执行了一次扣款。
解决思路:引入幂等机制,前端生成requestId,后端在Redis中做幂等校验,重复请求直接返回第一次处理的结果。现在我把幂等校验作为所有写接口的强制要求,已经有了一套标准的幂等组件,新接口直接接入。
6.5 排查心法:从“看单机”转向“看集群”
最后分享一个排查习惯的转变。以前排查问题都是挨个看单机日志,费时费力。后来我们集成了日志集中采集和链路追踪,所有实例的日志汇聚到一个平台,通过一个requestId就能串联起整个请求在多个实例间的完整调用链。
这个转变对高可用排查来说几乎是必须的:**故障转移意味着请求会在不同实例间跳转,单机日志永远只能看到片段,只有全局视角才能看到全貌。**这也是很多团队高可用改造做得“形似而神不似”的根源——工具链没有跟上架构的进化。
6.6 问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 实例CPU高,流量倾斜 | sticky session导致session挂在单实例 | session外置Redis,关闭sticky |
| 健康检查全绿但超时 | 探活只检查端口未检查依赖 | 业务探活检查数据库、Redis、连接池 |
| 新实例启动后被击垮 | 无预热,瞬时流量灌入 | 权重渐变、readiness探针、预热时间 |
| 故障转移后重复扣款 | 写操作未做幂等 | requestId+Redis幂等校验 |
| 定时任务重复执行 | 多实例未加锁 | 分布式锁或分布式调度平台 |
| Redis挂了,全局瘫痪 | 状态外置后Redis成为单点 | Redis主从+哨兵或Cluster |
我在实际运维中体会最深的一点是:高可用改造最怕“看起来都做了,实际没串起来”。做一次完整的故障演练,挑一个业务低峰期,直接把一台实例kill掉,观察系统反应,你会发现一定会有意外。开始几次肯定会有惊喜——某个服务重启后注册不上、某个缓存穿透把数据库打死、某个下游调用没有设置超时时间导致线程池耗尽……这些问题在演练中爆出来,比在线上爆出来好一万倍。建议把故障演练变成季度例行活动,让故障转移这条链路始终处于“可用”状态,而不是只在PPT里可用。
