可靠性模式
这一课你将能做到
- 设计本站的三层健康检查(LB 探活 / K8s 探针 / 业务深度检查),并说清 liveness 与 readiness 的差异
- 用 Redis SET NX 实现 Leader 选举,保证定时预热、单分区消费等任务全局单实例执行
- 手写 Redis + Lua 令牌桶限流,并对固定窗口、滑动窗口、漏桶、令牌桶四种算法张口就来
为什么要学
可用性不是口号,是四件事的乘积:探活发现故障 + 自动摘除/切换 + 单写防脑裂 + 限流保护自己。少任何一件,故障时都会出现经典灾难场景:挂了的实例还在接流量、两个实例同时抢着重建缓存、故障流量把救火的 DB 也压垮。
面试必考:「限流算法有哪些?令牌桶和漏桶的区别?」「怎么保证定时任务集群里只跑一份?」「liveness 和 readiness 有什么区别?」——这课把答案写到能背能写的程度。
人话:可靠性设计像医院急诊——分诊台先看谁能救(健康检查)、同一台手术只能一个主刀(Leader 选举)、挂号处控制放号速度防止候诊大厅挤爆(限流)、每个科室有备用电源(冗余与降级)。
先建立图像
可靠性组件全景(对照本站):
Nginx 探活 ──→ 摘除坏实例 (第 4 课 max_fails)
K8s 探针 ──→ 重启/摘流量 (liveness vs readiness)
业务探针 ──→ 发现「活着但残疾」 (能连 DB?能连 Redis?)
Leader ──→ 单写任务不重复 (缓存预热/对账/schema 迁移)
限流 ──→ 保护自己不被压垮 (网关令牌桶)
RTO/RPO ──→ 故障的可接受边界 (多久恢复 / 丢多少数据)
三层健康检查的分工:
| 层 | 检查什么 | 失败动作 | 本站实现 |
|---|
| LB 探活 | 进程可响应 | 摘除转发 | Nginx max_fails / /actuator/health |
| K8s liveness | 进程是否死锁 | 重启容器 | 简单 /health/liveness |
| K8s readiness | 是否能接流量 | 摘出 Service 端点 | 含依赖检查 /health/readiness |
| 业务深度探针 | 核心链路是否通 | 告警 + 降级 | ping MySQL、Redis、Kafka |
两个 9 的措辞(RTO/RPO):
RTO(Recovery Time Objective):故障到恢复多久 —— 只读服务 5 分钟内恢复
RPO(Recovery Point Objective):最多丢多少数据 —— 提交记录 0 丢失(主库半同步)
设计文档里写「高可用」没用,写 RTO/RPO 才是承诺——这是评审老手的标志。
跟着做
步骤 1:分层健康检查落地
management:
endpoint:
health:
show-details: when_authorized
group:
liveness:
include: ping # 只查进程本身:快、无依赖
readiness:
include: db,redis,kafka # 含依赖:决定接不接流量
health:
kafka:
enabled: true
关键区别(面试原题):liveness 失败 → K8s 重启容器;readiness 失败 → 只摘流量不重启。把重量级依赖检查放进 liveness 是灾难配方——Redis 抖动 30 秒,全站容器被反复重启,小故障滚成大事故。
步骤 2:Leader 选举——Redis SET NX 实现
定时预热热门题目缓存、每天对账、schema 迁移——这些任务多实例同时跑会重复执行甚至互相破坏。选主方案:
@Component
public class RedisLeaderElector {
private final StringRedisTemplate redis;
private static final String KEY = "leader:cache-warmup";
private static final String INSTANCE_ID = UUID.randomUUID().toString();
/** 每 10 秒尝试续期/竞选,拿到锁的才是 leader */
@Scheduled(fixedDelay = 10_000)
public void elect() {
Boolean ok = redis.opsForValue().setIfAbsent(KEY, INSTANCE_ID, Duration.ofSeconds(30));
if (Boolean.TRUE.equals(ok)) return; // 新当选
String holder = redis.opsForValue().get(KEY);
if (INSTANCE_ID.equals(holder)) {
redis.expire(KEY, Duration.ofSeconds(30)); // 自己的锁:续期
}
}
public boolean isLeader() {
return INSTANCE_ID.equals(redis.opsForValue().get(KEY));
}
}
// 使用:只有 leader 干活
@Scheduled(cron = "0 5 3 * * ?")
public void warmupHotProblems() {
if (!elector.isLeader()) return; // 其余实例直接跳过
cacheWarmupService.warmTop2000();
}
三个细节决定正确性:锁要带 TTL(leader 崩了锁能自动过期);只有持锁者自己续期(防止两实例互相续);业务容忍短暂双主(leader 刚过期、新 leader 刚当选的窗口,任务要幂等——衔接第 3 课)。生产更严谨可用 Redisson 看门狗或 Curator(ZooKeeper)。
步骤 3:限流算法四连(面试必背)
固定窗口:1 分钟一个计数器,简单,但窗口边界可突刺 2 倍
滑动窗口:把窗口切成小格统计,平滑,成本略高
漏桶:请求进桶、恒定速率流出,绝对平滑但无法应对合理突发
令牌桶:恒定速率放令牌、桶有容量,允许「积攒的突发」——互联网默认选择
Redis + Lua 令牌桶(原子性靠 Lua 单线程执行保证):
-- KEYS[1]=限流key ARGV: capacity, refill_per_sec, now_ms, requested
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3]) / 1000
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or capacity
local last = tonumber(bucket[2]) or now
tokens = math.min(capacity, tokens + (now - last) * rate) -- 补充令牌
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, 60)
return 1 -- 放行
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, 60)
return 0 -- 拒绝
public boolean allow(long userId) {
Long r = redis.execute(script, List.of("rate:user:" + userId),
"50", "5", String.valueOf(System.currentTimeMillis()), "1");
return r != null && r == 1L; // 50 桶容量、每秒 5 令牌:突发可扛 50,稳态 5 QPS/人
}
步骤 4:多可用区部署 + Deployment Stamp 思想
AZ-1: Nginx ×1 + API ×3 + MySQL 主 + Redis 主
AZ-2: Nginx ×1 + API ×3 + MySQL 从(可提升) + Redis 从
↑ 云 LB 跨 AZ 转发,任一 AZ 故障自动切另一 AZ
「部署戳(Deployment Stamp)」更进一步:同一套代码多份完全独立的部署(国内/海外、公有云/私有化),全局流量入口按地域分发,故障与发布互不扩散。本站出海时判题沙箱必须本地化(代码不能出境),stamp 就是最自然的形态。
讲清楚要点
- 健康检查三层缺一不可:LB 探活决定流量、K8s 探针决定容器生命周期、业务探针决定告警。liveness 轻(进程级)、readiness 重(含依赖),放反了会把小抖动放大成重启风暴。
- 单写原则靠选主实现:缓存预热、对账、迁移这类任务多实例重复执行会造成双倍写与数据竞争。Redis SET NX + TTL + 持锁续期是最轻方案,幂等兜底短暂双主。
- 限流是保护自己的最后防线:上游限流(网关按用户/API key)、下游自适应(检测 DB 连接等待自动收紧)、压测校准阈值。没有限流的系统等于没有防爆胎的车。
- 四种限流算法按「是否容忍突发」选:要突发用令牌桶(配桶容量),要绝对平滑用漏桶,简单场景固定窗口够用,精度要求高的用滑动窗口。
- RTO/RPO 写进设计文档:高可用不是形容词,是「故障后 5 分钟内恢复只读、提交数据零丢失」这种可验收的句子。每个核心链路都要标注。
你可能会懵的地方
-
readiness 探针包含依赖检查,依赖挂了不重启只摘流量,那什么时候恢复?
依赖恢复后 readiness 自动转绿,流量自动回来——这正是设计意图:依赖故障靠依赖方修复,应用自己别添乱(不重启、不重连风暴)。重连要配指数退避。
-
Redis 锁 30 秒过期,任务要跑 5 分钟怎么办?
看门狗模式:持锁线程后台每 10 秒自动续期,任务结束释放。注意续期失败(Redis 抖动)时要停止任务而不是继续跑——宁可任务断,不可双主跑。Redisson 的 watchdog 就是这个实现。
-
限流被拒的请求怎么处理最好?
返回 429 + Retry-After 头,让客户端按提示重试;判题提交场景可返回「排队位置」。绝不能无限堆积在内存队列里「慢慢处理」——那等于把限流变成了慢性自杀的蓄水池。
-
主从切换期间 RPO=0 怎么保证?
MySQL 半同步复制(rpl_semi_sync_master_wait_for_slave_count=1)保证事务提交前至少一个从库已收到 binlog;更严用 MGR/GTID 严格模式。代价是提交延迟略增——又一个 CAP 式取舍。
-
混沌工程是什么,需要吗?
主动注入故障验证可靠性设计(杀实例、断网络、打满 DB)。本站最小实践:每月演练一次「kill 一个 API 实例 + kill 主库切换」,观察探活摘除和自动切换是否符合 RTO。设计没经过演练验证都算没做。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 把 DB/Redis 检查放进 liveness | 依赖抖动引发重启风暴 | liveness 只查进程,readiness 查依赖 |
| 定时任务每实例都跑 | 缓存被重复刷、对账翻倍 | Leader 选举 + 任务幂等 |
| 限流阈值拍脑袋(QPS 上限=直觉) | 要么误伤要么形同虚设 | 压测容量 × 70% 定阈值,线上观察回调 |
| 固定窗口限流用于秒杀入口 | 窗口边界 2 倍突刺压垮下游 | 令牌桶(容量限突发)或滑动窗口 |
| 设计文档写「高可用」三个字 | 无法评审、无法验收 | 写明 RTO/RPO 与故障时的具体行为 |
卡点急救
- 实例健康但 K8s 一直重启它:liveness 探针太重(挂了依赖检查)或太敏感(initialDelay 小于启动耗时)。修复:liveness 只留 ping,
initialDelaySeconds 覆盖慢启动,依赖检查全挪 readiness。
- 两个实例同时执行预热任务:检查三点——锁有没有 TTL(没 TTL 宕机后死锁)、续期逻辑是否只允许持锁者续(互相续期 = 双主)、预热是否幂等(按 key SET 不做 INCR)。临时止血可在任务里加 DB advisory lock 双保险。
- 限流把正常用户误伤:阈值回查压测数据;确认限流维度是否太粗(按 IP 误伤公司出口 NAT,改按 userId);给付费/比赛场景单独 quota。误伤率是限流的第二监控指标(429 比例 <0.1% 为宜)。
面试常问
-
令牌桶和漏桶的区别?
一句话:漏桶恒定速率出、完全平滑、不容突发;令牌桶恒定速率放令牌、桶容量允许积攒突发。选型:API 计费/下游保护用漏桶(下游按 QPS 计价或容量刚性),面向用户的入口用令牌桶(容忍正常突发,桶容量=可容忍的瞬时峰值)。能补一句「分布式场景用 Redis+Lua 保证原子」就是完整答案。
-
怎么保证集群里定时任务只执行一次?
方案递进:① 分布式锁(Redis SET NX + TTL + 看门狗续期,最常用);② ZooKeeper/etcd 临时节点选主(强一致,会话断自动释放);③ 数据库 advisory lock(MySQL GET_LOCK,简单可靠);④ K8s CronJob(由平台保证单实例)。补一句「选主失败窗口靠任务幂等兜底」体现深度。
-
描述你设计过/评估过的一个高可用方案,RTO/RPO 是多少?
用本站答:只读链路(题目/排行榜)多 AZ 部署,RTO 5 分钟(LB 摘除 + 多实例冗余,验证靠月度演练);提交链路 RPO 0(MySQL 半同步,主库故障切换不丢已确认提交)、RTO 60 秒(MGR/哨兵自动切换);Redis 挂掉接受降级直查 DB(缓存不是承诺可用性的组件)。
自测三问
- liveness 和 readiness 失败时 K8s 分别做什么?把 DB 检查放错层会发生什么连锁反应?
- 写出 Redis 令牌桶 Lua 脚本的三步逻辑,说明为什么必须用 Lua。
- 本站只读链路和提交链路的 RTO/RPO 分别应该怎么定?为什么不同?
小结一句话
可靠性 = 三层探活 + 单写选主 + 限流自保 + RTO/RPO 落笔——「高可用」三个字没有意义,可验收的故障承诺才有。
延伸