系统架构 · 通用机制知识图谱

Kafka / K8s / etcd / Redis / ZooKeeper / MySQL 扒开看都是同几招: 心跳→选举→租约 · 分片→复制→Quorum · 重试→幂等 · 限流→熔断 — 八大核心问题一张图

系统架构通用机制 · 八大核心问题 × 20 个基础机制 × 六大中间件配方 每个问题是一个机制族, 箭头是机制间的依赖接力 触发选举 新主顶上 熔断限流 再复制 Quorum 补偿 共识 = 选举 + 日志复制 (Raft) 有重试 ⇒ 必配幂等 MQ 削峰填谷 ② 谁还活着? Heartbeat · Failure Detector · Gossip Heartbeat 心跳互拍 + 超时判定 K8s Liveness/Readiness 探针 Phi Accrual 累积怀疑度 Gossip 流言扩散成员表 ① 谁当主? Election · Consensus · Lease · Fencing Leader Election 多数派选主 Raft 选举 + 日志复制 Lease 租约 = 带过期的锁 Fencing Token 单调令牌防双主 ⑥ 挂了怎么办? Failover · Resilience Failover 备位顶上 Timeout 超时止损 Retry 退避 + 重试预算 Circuit Breaker 熔断 + 舱壁 ⑦ 流量太大? Traffic Governance LB 轮询 / 最少连接 / 一致哈希 令牌桶 · 滑动窗口 限流 Backpressure 背压反压 Load Shedding 过载弃卒 ③ 数据放哪? Partitioning · Sharding · Routing Hash / Range / 目录分片 一致性哈希 + 虚拟节点 路由层找数据 + 热点治理 Rebalance 扩缩容迁移 ④ 怎么备份? Replication · WAL · Snapshot Primary-Replica 主从复制 同步 / 异步 / 半同步 WAL·binlog 先日志后数据 Snapshot + Checkpoint ⑤ 数据信谁? Consistency · Quorum · CAP 线性一致 → 最终一致 谱系 Quorum: w + r > n CAP / PACELC 取舍表 冲突合并 LWW · CRDT ⑧ 多服务怎么协作? Messaging · Saga · Outbox MQ At-least-once 投递 幂等键 + 去重表 Saga 拆事务 + 补偿 Outbox / Inbox 双簿记 横向支撑 · 服务发现 A 找 B: Consul/Nacos/K8s SVC 横向支撑 · 缓存 Cache Aside · 穿透/击穿/雪崩 横向支撑 · 多租户 DB/Schema/行级 · Noisy Neighbor 横向支撑 · 逻辑时钟 Lamport/Vector/HLC · 时钟不可信 横向支撑 · 可观测 Metrics/Tracing/Log · SLI/SLO 扒开皮, 都是同一副骨架 — 六个中间件的机制配方 Kafka Partition 分片 · ISR 副本集合 · Controller 选举 Consumer Group 重平衡 · High Watermark 截断 Kubernetes Desired State 期望状态 · Controller 调谐循环 Lease 选主 · Liveness/Readiness 探针自愈 etcd Raft 共识 · WAL 预写日志 · Snapshot 快照 Lease TTL 租约 · Watch 变更推送 ZooKeeper ZAB 原子广播(类 Raft) · 临时节点 = 会话租约 Watch 一次性触发 · 多数派才可写 MySQL binlog 复制 · GTID 全局序 · 半同步降级 读写分离 · 分库分表 (ShardingSphere) Redis Cluster 16384 slot 哈希槽 · 主从复制 + Sentinel Gossip 成员协议 · MOVED 重定向 ● 事故链: 下游变慢 → 无超时 + 盲目重试×3 → QPS×3 → ERROR 1040 (HY000): Too many connections → 线程池耗尽 → 级联故障全站 503 ● 破局链: 超时沿调用链递减 · 重试预算 ≤10% + 指数退避 + 抖动 · 熔断快速失败 · 幂等键兜底 — 挂而不倒 读法: 横向箭头 = 问题的接力 · 竖向箭头 = 机制同源 · rose 虚线 = 事故路径 · 一切从"谁还活着"开始

机制视角 — 一张地图

  • • 8 大核心问题: 谁当主 / 谁活着 / 数据放哪 / 怎么备份 / 信谁 / 挂了咋办 / 流量大了 / 多服务协作
  • • 20 个基础机制是"积木", 中间件是"成品"
  • • 控制面(协调+探活) · 数据面(分片+复制+一致) · 流量面(LB+限流+背压)
  • • 横向支撑: 服务发现 / 缓存 / 多租户隔离 / 逻辑时钟 / 可观测

行为视角 — 因果链

  • • 心跳判死 ≠ 真死: GC 停顿 / 网络抖动都会伪装成死亡
  • • 有复制就有不一致 → Quorum 出来当裁判
  • • 有重试就必须幂等, 这两个机制永远成对出现
  • • 限流熔断是"系统免疫": 平时白养, 病时保命

生产价值 — 看骨架

  • • 10 分钟看穿新系统: "这货 = 选举 + 日志复制 + 分片 + 租约, 换了皮"
  • • 故障顺因果链找: 探活误判 → 选举 → Failover → 脑裂
  • • 面试八股串成网: CAP / Raft / 幂等不再孤立
  • • 选型即配比: 强一致多贵, 最终一致多险

💡 一句话理解

把 Kafka、Kubernetes、etcd、Redis 想成不同菜系, 后厨却只有二十口锅: 选举、心跳、租约、分片、复制、共识、限流、熔断、幂等……单机时代一个问题一个函数, 分布式时代要先接受三条公理——机器随时会没、网络会丢会乱序、没有统一的"现在"。

八大核心问题就是这套世界观的目录: 从"谁还活着"出发, 一路推出选主、数据分布、复制、一致性、容错、流量治理和多服务协作。看懂这 20 个基础机制, 再遇到任何新中间件都是"换皮重认"——哦, 这货本质就是选举 + 日志复制 + 分片 + 租约——而不是从零再学一套名词。

🧠 必知必会 必考 & 必会

Leader Election 选主
一群节点选出唯一 leader, 由它做写入/调度/任务分配。要点: 选主由多数派承认才算数, 不是先到先得; 依赖故障检测(②)触发。
// etcd 选主: 所有副本竞争同一个前缀 key, 谁被多数派接受谁是主
e := concurrency.NewElection(sess, "batch/settle/leader")
e.Campaign(ctx)           // → 阻塞直到当选; 会话结束自动让位
Heartbeat 心跳
周期性"我还活着"信号。心跳超时只代表怀疑: 网络堵、GC 停 20s、CPU 打满都会伪装成死亡, 所以要连续 N 次失败或用累积怀疑度再下结论。
for range ticker.C {
    if err := ping(ctx); err != nil { misses++ }   // 关键: 记次数, 不一次定死
}  // → misses >= 3 才标记 suspect, 进选举流程
Lease 租约
"资源未来 30s 归我, 到点自动失效"——和锁的本质区别: 锁依赖持有者善终(主动释放), 租约不依赖(服务器暴毙也能自动回收), 所以分布式系统爱用它。
// etcd: 30s TTL 的租约 + 心跳续期
lease, _ := cli.Grant(ctx, 30)
cli.KeepAlive(ctx, lease.Id)
// 关键: 进程暴毙, 租约到点自动释放, 不会死锁
分布式锁三件套
正确的分布式锁 = NX(互斥) + PX(过期) + owner 值(身份), 三件缺一: 没过期会死锁, 没 owner 会删别人的锁。
SET lock:job w1 NX PX 30000    // → OK; 拿到锁且 30s 自动过期
// 关键: value 存 owner 身份, 释放时校验后再删
Fencing Token 防护令牌
每次发锁/租约附带一个单调递增令牌, 存储层拒绝旧令牌的写。它是脑裂的最后一道闸: GC 醒来的旧主拿着 token=6, 存储只认 token=7 的新主。
if req.Token < store.LastFencing() {
    return ErrFenced   // → 旧主的写被拒: "token 6 < 7"
}
Quorum 多数派
N 份副本, 写成功 w 份、读询 r 份, 只要 w + r > n 读写集合必有交集, 读就能见到最新值。代价: w、r 越大延迟越高、可用节点数要求越高。
n, w, r := 3, 2, 2          // 关键: w + r > n → 读写交集非空
// → 3 副本挂 1 台仍满足多数派, 服务继续
一致性哈希 + 虚拟节点
把哈希空间做成环, 数据顺时针找第一个节点; 扩缩容只迁移 1/N 数据(mod-N 要迁大半)。虚拟节点把每台机器拆成 100~200 个环上点, 抹平机器异构导致的数据倾斜。
h := crc32(key)
i := sort.Search(vnodes, func(i int) bool { return vnodes[i].hash >= h })
// 关键: 顺时针找第一个 >= h 的虚拟节点; 扩容只动 1/N
复制三模式
同步(全部副本落盘才返回: 慢但安全)、异步(主落盘即返回: 快但切主可能丢)、半同步(至少 K 个副本收到才返回: 折中, MySQL 半同步/ Kafka ISR 本质都是它)。
-- MySQL 半同步: 至少 1 从库收到 binlog 才向客户端确认
SET GLOBAL rpl_semi_sync_master_enabled = 1;
-- 关键: 异步复制切主丢数据的解药, 不是备份
CAP 与 PACELC
CAP 正确读法: 只有分区(P)发生时才在 C 与 A 之间二选一; 平时网络正常, 真正的取舍是 PACELC 的 Else 分支——延迟(L)还是一致(C)。
// P 发生:  取 C(拒绝服务) 或 A(容忍旧数据), 二选一
// Else 正常: 取 L(低延迟)  或 C(强一致), 仍要选
// 关键: CAP 不是"三选二", 平时 CA 是伪命题
幂等 Idempotency
同一请求执行一次和执行 N 次结果相同。网络重试是常态(at-least-once), 所以幂等不是优化是底线; 最后的闸门永远是数据库唯一索引, 不是内存判断。
CREATE TABLE pay_idem (
  idem_key VARCHAR(64) NOT NULL,
  PRIMARY KEY (idem_key)   -- 关键: 唯一索引是幂等最后的闸门
); -- → 重复 INSERT 报 1062 Duplicate entry, 捕获后直接返回上次结果
令牌桶 Token Bucket
桶以恒定速率放令牌, 请求拿到令牌才放行: 允许突发(攒的令牌), 又限长期平均速率。相对的漏桶强制恒速, 固定窗口有临界双倍突刺。
tokens := math.Min(cap, tokens + rate*dt)   // 关键: 匀速补充, 上限 cap
if tokens >= 1 { tokens--; allow() } else { reject() }
熔断器三态
Closed(正常放行) → 错误率超阈值 → Open(直接快速失败, 不打下游) → 冷却后 → Half-Open(放少量探测请求, 成功则闭合)。本质: 快速失败保护自己也保护下游。
Closed --错误率>50%--> Open --冷却30s--> Half-Open --探测过--> Closed
// 关键: Open 期间请求要走兜底, 不是报错给用户
Gossip 流言协议
每个节点每秒随机挑一个伙伴互传成员表, 像传八卦一样指数扩散, O(log N) 轮全集群收敛。去中心化、抗单点, 代价是最终一致——成员视图有短暂分叉。
peers[rand.Intn(len(peers))].Exchange(state)
// 关键: 无中心也能同步; Redis Cluster / Consul 都用它
逻辑时钟 Lamport Clock
物理时钟会回拨、会漂移, 不能给分布式事件排序; 逻辑时钟只维护规则: 本地事件 +1, 收到消息取 max 再 +1——有因果关系的两个事件必有全序。
lc = max(lc, msg.clock) + 1   // 收到消息时推进
// 关键: 物理时钟会说谎(回拨), 因果序不会

🏭 生产实战 real world

场景 1 · 结算任务 10 个副本, 每天 0 点发了 10 份工资单

定时任务多副本部署却没有选主, 谁的 cron 先到谁执行 → 数据重复处理。用 etcd 租约 + Election 选出唯一执行者。

// 结算任务部署 10 副本, 只允许 1 个执行: etcd lease + election
sess, err := concurrency.NewSession(cli, concurrency.WithTTL(15))
if err != nil { log.Fatal(err) }
e := concurrency.NewElection(sess, "batch/settle/leader")

go func() {
    for {
        if err := e.Campaign(ctx); err != nil { continue } // 竞选, 阻塞到当选
        runDailySettle(ctx)                                // 当选: 唯一执行批任务
        e.Resign(ctx)                                      // 主动让位, 发布时平滑换主
    }
}()
<-ctx.Done()  // 关键: 进程死→会话死→lease 过期, 其他副本 15s 内自动补位

上线后 10 副本每天只有 leader 跑批, 主宕机切换窗口 = 会话 TTL 15 秒。

场景 2 · Leader 一次 Full GC 停 20 秒, 双主双写坏数据

心跳 5s 超时即切主 → 旧主 GC 结束醒来继续写, 新旧两主并存。修法: client-go 的 lease 选举参数放宽 + 存储层校验 fencing。

lock := &resourcelock.LeaseLock{
    LeaseClient: coordClient,
    LeaseMeta:   metav1.ObjectMeta{Namespace: "prod", Name: "batch-runner"},
    Identity:    id,
}
leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
    Lock:          lock,
    LeaseDuration: 15 * time.Second, // 15s 没续约才判死 — 别设 3s, 一次 GC 就误切
    RenewDeadline: 10 * time.Second,
    RetryPeriod:   2 * time.Second,
    Callbacks: leaderelection.LeaderCallbacks{
        OnStartedLeading: runSettle,
        OnStoppedLeading: func() { log.Fatal("lost leadership, exit") }, // 失主即自杀防双主
    },
})
// 关键: 下游存储仍要校验 fencing token — GC 20s 的旧主写进来直接 409 拒绝

场景 3 · 订单表 4 亿行, 4 库扩到 8 库不停机

hash(orderID) % 4 改成 % 8, 3/4 的行路由全变, 停机迁移要 3 天。换成一致性哈希环, 只有新旧路由不一致的行才搬。

type Ring struct{ vnodes []vnode }  // 每个物理库挂 200 个虚拟节点

func (r *Ring) Get(key string) *DB {
    h := crc32.ChecksumIEEE([]byte(key))
    i := sort.Search(len(r.vnodes), func(i int) bool { return r.vnodes[i].hash >= h })
    return r.vnodes[i%len(r.vnodes)].db // 关键: 顺时针第一个 >= h 的 vnode
}

// 迁移期双写: 新旧库都写, 读走旧库, 后台只搬 route 变化的行, 校验后切读
for _, order := range backlog {
    if ring8.Get(order.ID) == ring4.Get(order.ID) { continue }
    migrate(ctx, order)
}
// 收益: 4 亿行只搬约 1/8, 迁移窗口从 3 天缩到 4 小时

场景 4 · 用户改完头像刷新还是旧图 — 主从延迟 3 秒

写主库、读从库, 复制延迟 3s 内用户看到旧数据, 工单刷屏。做"读己之写": 记住写操作的 GTID, 从库追上才读, 追不上读主。

// 写: 记录本次写入的全局事务序
db.Exec("UPDATE users SET avatar=? WHERE id=?", avatar, uid)
gtid, _ := queryScalar(db, "SELECT @@GLOBAL.gtid_executed")
session.Set("w_gtid", gtid)

// 读: 从库先追上这个 GTID 才服务本次读, 1s 追不上降级读主
err := replica.QueryRow(
    "SELECT WAIT_FOR_EXECUTED_GTID_SET(?, 1)", gtid).Err()
if err != nil {
    row = master.Query("SELECT avatar FROM users WHERE id=?", uid)
}
// 关键: 只对"写后立刻读"的请求付出这个代价, 其余照走从库

场景 5 · Kafka 消费组 5 分钟一次重平衡, 堆积 2000 万条

单条消息处理慢, 两次 poll 间隔超过 max.poll.interval.ms → 被踢出组 → 全组停止消费重新分配 → 雪崩式堆积。调参 + 换增量重平衡。

# consumer.properties — 重平衡风暴的三板斧
session.timeout.ms=30000          # 30s 没心跳判死 (broker 侧判定)
heartbeat.interval.ms=3000        # 心跳间隔 ≈ session/3
max.poll.interval.ms=600000       # 两次 poll 上限: 给慢处理留足时间
max.poll.records=500              # 关键: 少拉快提, 别一次 5000 条噎死自己
partition.assignment.strategy=CooperativeStickyAssignor
# 增量重平衡 (2.4+): 只挪动受影响的分区, 其余消费者不停

重平衡从每天 288 次降到 0 次(只在新消费者加入时增量发生), 消费延迟稳定在 3 万条以内。

场景 6 · 库存服务一抖, 整条下单链路陪葬

无超时无熔断: 下游 P99 3s, 上游线程池 200 被慢慢占满, 最后全站 503。三件套: 超时递减 + gobreaker 熔断 + 兜底返回。

cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name:     "inventory",
    Interval: 10 * time.Second,  // Closed 态错误统计窗口
    Timeout:  30 * time.Second,  // Open 冷却 30s 后进 Half-Open
    ReadyToTrip: func(c gobreaker.Counts) bool {
        return c.Requests >= 50 && float64(c.TotalFailures)/float64(c.Requests) > 0.5
    },
})
resp, err := cb.Execute(func() (any, error) {
    ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond) // 上游剩 1s, 我只花 0.8s
    defer cancel()
    return invClient.Reserve(ctx, req)
})
if errors.Is(err, gobreaker.ErrOpenState) {
    return fallbackEstimate(req) // 关键: 熔断走兜底, 不是把错误抛给用户
}

场景 7 · 秒杀 5 万 QPS, 数据库连接池先阵亡

限流做在应用内存里, 20 台机器 = 20 份配额互不知晓; 改成 Redis + Lua 把"判定+扣减"做成原子操作, 集群级精确限流。

-- KEYS[1]=令牌桶  ARGV: rate 令/秒, capacity 桶容量, now(ms), 本次请求 n
local rate, cap = tonumber(ARGV[1]), tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = math.min(cap, (tonumber(b[1]) or cap) +
    (now - (tonumber(b[2]) or now)) / 1000 * rate)
local allowed = 0
if tokens >= tonumber(ARGV[4]) then
    tokens = tokens - tonumber(ARGV[4]); allowed = 1
end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 60000)
return allowed  -- 关键: 判定+扣减一个原子脚本, 多机也不会放超

场景 8 · 支付回调重复推送, 用户被扣两次钱

第三方支付 at-least-once 重试回调是常态, 处理函数不幂等就会重复加钱。模板: 唯一索引挡重, 重复回调直接回成功止住对方重试。

func HandlePayCallback(req PayNotify) error {
    // 幂等闸门: 回调带 order_no, 唯一索引挡重
    res, err := db.Exec(
        `INSERT IGNORE INTO pay_callback(order_no, amount, status)
         VALUES (?, ?, 'paid')`,
        req.OrderNo, req.Amount)
    if err != nil { return err }
    if n, _ := res.RowsAffected(); n == 0 {
        return nil  // 关键: 重复回调当成功返回, 上游就不再重试
    }
    return creditUser(req)  // 只有首个回调走到真加钱
}

场景 9 · 订单写成功、库存没扣 — 双写不一致

先写订单库再发 MQ, 中间服务崩溃就永久丢事件。Outbox 模式: 业务数据与事件同事务落库, relay 异步投递, 用至少一次 + 消费幂等换最终一致。

func CreateOrder(o Order) error {
    tx, _ := db.Beginx()
    defer tx.Rollback()
    tx.Exec("INSERT INTO orders(order_no, sku, qty) VALUES(?,?,?)", o.No, o.Sku, o.Qty)
    tx.Exec("INSERT INTO outbox(topic, payload) VALUES('order.created', ?)",
        toJSON(o))  // 关键: 业务表和事件同生共死, 同一事务提交
    return tx.Commit()
}
// relay 轮询投递 MQ (至少一次), 消费端用去重表兜住重复:
// SELECT * FROM outbox WHERE sent=0 ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED

场景 10 · JVM 服务 K8s 发版, 滚动更新雪崩 5 分钟

大堆 JVM 启动要 4 分钟, 默认探针 30s 判死 → Pod 被反复杀 → 越杀越慢。三层探针各司其职: startup 给启动预算, readiness 摘流量, liveness 只查进程自身。

# deployment.yaml — 启动慢的应用必须配 startupProbe
startupProbe:
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 5
  failureThreshold: 60      # 5s × 60 = 300s 启动预算, 期间不杀不摘
readinessProbe:
  httpGet: { path: /ready, port: 8080 }
  periodSeconds: 5
  failureThreshold: 3       # 未就绪只摘流量不重启 — 扛住预热窗口
livenessProbe:
  httpGet: { path: /live, port: 8080 }
  periodSeconds: 10          # 关键: 只查"自己还能动吗", 不碰 DB/下游

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 锁忘设过期时间, 服务一崩全站排队 — 症状: 持有锁的节点 OOM 后, 所有等待者永远等不到释放. 原因: SET key val 无 TTL, 锁成了"永生锁". 正解: SET ... NX PX 30000 三件套。
// 错: SET lock:job worker-1      → 持有者崩溃, 全集群永久阻塞
// 对: SET lock:job worker-1 NX PX 30000   → OK, 30s 后自动可抢
坑 2 · 任务没跑完锁先过期, 两个实例同时跑 — 症状: 夜间跑批重复扣库存. 原因: TTL 短于任务时长, 第二实例在锁过期后抢到. 正解: 看门狗续期(Redisson watchdog / etcd KeepAlive)。
// 错: PX 30000 但任务要跑 90s → 第 30s 第二实例抢到, 双跑
// 对: lease,_ := Grant(ctx,30); KeepAlive(ctx, lease.Id) → 自动续期
坑 3 · A 把 B 的锁删了 — 症状: 锁"失效", 互斥被打破. 原因: 释放时不校验身份, 超时后 A 回来 DEL 掉了 B 刚抢到的锁. 正解: Lua 比对 owner 再删。
// 错: DEL lock:job → 删掉的是 B 的锁
// 对: if redis.call('GET',KEYS[1])==ARGV[1] then redis.call('DEL',KEYS[1]) end
坑 4 · 心跳一超时就切主, GC 一停就双主 — 症状: 双主双写, 数据错乱. 原因: 没收到心跳 ≠ 死了, Full GC/网络抖动都会伪装成死亡. 正解: 连续 N 次失败或 phi 累积怀疑度 + fencing token。
// 错: if time.Since(last) > 5*time.Second { elect() } → 一次 STW 就误切
// 对: suspicion 达阈值才选举, 且存储层拒 token < last 的旧主写
坑 5 · 重试不加退避, 故障被放大 3 倍 — 症状: 下游刚抖一下, 瞬间 QPS 翻三倍直接打趴. 原因: 立即重试在故障窗口内叠加流量. 正解: 指数退避 + 随机抖动。
// 错: for { if err != nil { retry() } }        → 重试风暴
// 对: backoff = min(base * 2^n, 10s) + rand(0, base) → 错峰衰减
坑 6 · 对非幂等接口盲重试, 银行渠道重复扣款 — 症状: 超时重试后用户账户扣两次. 原因: 超时的请求可能已成功, 重试变成第二笔. 正解: 先幂等键后重试, 二者成对上线。
// 错: client.Do(req) 超时 → 原样重发 → 扣款 ×2
// 对: req.Header.Set("Idempotency-Key", orderNo) → 服务端唯一索引挡重
坑 7 · 每一层都重试, 27 倍流量砸向故障点 — 症状: 网关、服务、客户端各自重试 3 次, 故障时放大 3×3×3. 原因: 重试策略没有全局视角. 正解: 重试预算(≤10%)且只在同一层重试一次。
// 错: 网关×3 + 服务×3 + SDK×3 = 27 倍写放大
// 对: retryBudget: 10%  # 超过预算直接失败, 链路只留一层重试
坑 8 · 超时不递减, deadline 用完还在调下游 — 症状: 入口已超时返回, 链路深处还在烧 CPU. 原因: 每层各自设固定 3s, 累计远超用户等待. 正解: 传递 deadline, 子调用预算 ≤ 父剩余。
// 错: 每层 WithTimeout(3s) → 5 层链路用户等 15s
// 对: ctx 从入口传入, 子预算 = 剩余 deadline × 0.8
坑 9 · livenessProbe 检查数据库, DB 抖动全线重启 — 症状: 数据库慢 30s, 全部 Pod 被 kubelet 杀掉重启, 雪上加霜. 原因: liveness 语义是"进程死没死", 依赖检查放错了探针. 正解: 依赖健康只影响 readiness。
# 错: livenessProbe httpGet /healthz  → /healthz 内部连 DB
# 对: liveness 查 /live(纯进程); DB 检查放 readiness /ready
坑 10 · mod-N 分片, 扩容迁走 75% 数据 — 症状: 4 库扩 8 库, 停机三天. 原因: 模数一变, 大部分 key 路由全变. 正解: 一致性哈希 / 成倍扩容 + 双写迁移。
// 错: db := hash(uid) % 4  →  % 8 后 3/4 的行要搬家
// 对: 一致性哈希环: 只迁移新旧路由不一致的 ~1/8 行
坑 11 · 行级隔离漏加 tenant_id, 租户数据串号 — 症状: A 租户翻到了 B 租户的订单, SaaS 重大事故. 原因: 行级多租户下某条 SQL 忘带租户条件. 正解: 查询强制带 tenant_id + ORM 拦截器/RLS 兜底。
// 错: SELECT * FROM orders WHERE id=1001 → 可能查出别家租户的
// 对: SELECT ... WHERE id=1001 AND tenant_id='t_a' → 串号被挡
坑 12 · 热点 key 打爆单分片 — 症状: 明星离婚事件, 存该 key 的分片 CPU 100%, 其他分片闲着. 原因: 流量按 key 哈希, 大 V/秒杀商品天然集中. 正解: 本地缓存 + key 加随机后缀拆散 + 多副本读。
// 错: 所有读都打 product:9527 → 单分片 50w QPS
// 对: product:9527:{0..15} 随机后缀拆 16 份 + 本地缓存 100ms
坑 13 · 异步复制切主, 已确认的订单蒸发 — 症状: 客户端收到"下单成功", 切主后订单消失. 原因: 主库异步落从, ACK 的写没到达旧从, 新主没有这份数据. 正解: 半同步复制, 切主前比对 GTID。
-- 错: 默认异步复制 → 主挂时最新 2s 的写可能没到从库
-- 对: SET GLOBAL rpl_semi_sync_master_enabled=1; 切主先验 GTID
坑 14 · 脑裂双主各写各的 — 症状: 网络分区恢复后两边数据都对不上. 原因: 旧主失联期间没被隔离, 照常接受写. 正解: quorum 隔离(够不着多数派就自降) + fencing token。
// 错: 旧主分区期间继续写 → 双份数据
// 对: if !haveQuorum() { stepDown() }; 写带 token, 旧 token 被拒
坑 15 · 读写分离, 用户改昵称"没保存" — 症状: 写后立刻读, 读到旧值; 过几秒又"恢复正常". 原因: 读从库撞上复制延迟, 违反读己之写. 正解: 写后短窗口读主, 或按 GTID 等从库追平。
// 错: UPDATE 后 SELECT 走从库 → 延迟窗口内读到旧昵称
// 对: session 写后 5s 内强制读主 / WAIT_FOR_EXECUTED_GTID_SET
坑 16 · 缓存穿透: 恶意查不存在的 id 直达数据库 — 症状: 大量 id=-1 请求, 缓存永远 miss, DB 每秒 5 万次空查询. 原因: 不存在的数据没有缓存价值但照样穿库. 正解: Bloom Filter 拦截 + 空值短 TTL 缓存。
# 错: GET user:-1 miss → SELECT ... WHERE id=-1 → 打爆 DB
# 对: 布隆过滤器拦截 + SET user:-1 "" EX 60  # 空值缓存 60s
坑 17 · 缓存击穿: 热点 key 过期瞬间万级请求砸库 — 症状: 大促开抢时刻 DB 连接池瞬间耗尽. 原因: 单个热点 key 失效, 并发全部回源. 正解: singleflight/互斥重建, 只放一个请求回源。
// 错: 1w 个请求同时 miss → 同时 SELECT → 连接池爆
// 对: g.singleflight(key, loadFromDB) → 1 个回源, 其余等结果
坑 18 · 缓存雪崩: 同批 TTL 同时到期 — 症状: 凌晨批量预热的数据同一秒全部失效, DB 被整波打穿. 原因: 统一写入统一 TTL, 过期时间完全对齐. 正解: TTL 加随机抖动 + 多级缓存。
// 错: SET k v EX 3600  → 1 小时后同一秒集体失效
// 对: EX (3600 + rand(0,600))  → 过期点错开, DB 压力摊平
坑 19 · 先删缓存再更新库, 旧值被并发读灌回 — 症状: 刚更新的价格又"弹回"旧值. 原因: 删缓存→更新库的间隙, 读请求把旧库值写回缓存. 正解: Cache Aside 标准序: 先更新库, 再删缓存。
# 错: DEL cache; UPDATE db → 间隙读把旧值 SET 回缓存
# 对: UPDATE db; DEL cache → 后续读 miss 回源拿到新值
坑 20 · MQ 重复投递, 消费者发货两次 — 症状: 重平衡/重试后同一订单发两件货. 原因: at-least-once 是 MQ 的常态语义, 消费端却按 exactly-once 编程. 正解: 消费端去重表(inbox)唯一索引。
// 错: 处理成功但 offset 提交失败 → 重投 → 又发一件货
// 对: INSERT INTO inbox(msg_id) 唯一键 → 冲突即跳过, 只处理一次