CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
系统设计入门/课时/可靠性模式
RawBlame
课时目录12 节

系统设计入门

  1. 01系统设计是什么
  2. 02性能与扩展基础
  3. 03一致性与 CAP
  4. 04负载均衡
  5. 05缓存模式
  6. 06异步与消息
  7. 07数据架构
  8. 08微服务与网关
  9. 09云设计模式
  10. 10可靠性模式
  11. 11性能反模式
  12. 12综合练习

可靠性模式

健康检查、leader election、deployment stamps。

30 分钟

可靠性模式

这一课你将能做到

  1. 设计本站的三层健康检查(LB 探活 / K8s 探针 / 业务深度检查),并说清 liveness 与 readiness 的差异
  2. 用 Redis SET NX 实现 Leader 选举,保证定时预热、单分区消费等任务全局单实例执行
  3. 手写 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 就是最自然的形态。

讲清楚要点

  1. 健康检查三层缺一不可:LB 探活决定流量、K8s 探针决定容器生命周期、业务探针决定告警。liveness 轻(进程级)、readiness 重(含依赖),放反了会把小抖动放大成重启风暴。
  2. 单写原则靠选主实现:缓存预热、对账、迁移这类任务多实例重复执行会造成双倍写与数据竞争。Redis SET NX + TTL + 持锁续期是最轻方案,幂等兜底短暂双主。
  3. 限流是保护自己的最后防线:上游限流(网关按用户/API key)、下游自适应(检测 DB 连接等待自动收紧)、压测校准阈值。没有限流的系统等于没有防爆胎的车。
  4. 四种限流算法按「是否容忍突发」选:要突发用令牌桶(配桶容量),要绝对平滑用漏桶,简单场景固定窗口够用,精度要求高的用滑动窗口。
  5. 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 与故障时的具体行为

卡点急救

  1. 实例健康但 K8s 一直重启它:liveness 探针太重(挂了依赖检查)或太敏感(initialDelay 小于启动耗时)。修复:liveness 只留 ping,initialDelaySeconds 覆盖慢启动,依赖检查全挪 readiness。
  2. 两个实例同时执行预热任务:检查三点——锁有没有 TTL(没 TTL 宕机后死锁)、续期逻辑是否只允许持锁者续(互相续期 = 双主)、预热是否幂等(按 key SET 不做 INCR)。临时止血可在任务里加 DB advisory lock 双保险。
  3. 限流把正常用户误伤:阈值回查压测数据;确认限流维度是否太粗(按 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(缓存不是承诺可用性的组件)。

自测三问

  1. liveness 和 readiness 失败时 K8s 分别做什么?把 DB 检查放错层会发生什么连锁反应?
  2. 写出 Redis 令牌桶 Lua 脚本的三步逻辑,说明为什么必须用 Lua。
  3. 本站只读链路和提交链路的 RTO/RPO 分别应该怎么定?为什么不同?

小结一句话

可靠性 = 三层探活 + 单写选主 + 限流自保 + RTO/RPO 落笔——「高可用」三个字没有意义,可验收的故障承诺才有。

延伸

  • Spring Boot Actuator — Health Groups:liveness/readiness 分组的官方实现
  • Google SRE Book — Handling Overload:限流与过载处理的权威实践
  • Redisson 分布式锁文档:看门狗续期的生产级实现
  • 下一课:性能反模式——把「能跑但会死」的写法一次列全,学完这课再回头审计前 10 课的设计。
加载本节练习…
← 上一课云设计模式下一课 →性能反模式