高并发场景题(中):分布式环境下,怎么保证"只一次、只那么多"?
上一篇处理"流量洪峰",这一篇处理另一个维度的难题——分布式环境下的正确性:多台机器一起跑,怎么保证"同一时刻只有一个执行""同一请求只生效一次""消息不丢不重"。这四个场景,本质都是在回答"分布式下如何维持正确性",而正确性在"某个组件挂了"的瞬间最容易被打破——所以每个场景的故障边界都要讲透(底层逻辑见一致性与故障边界)。
分布式锁:多实例怎么互斥
单机 sync.Mutex 跨机器就失效了,需要分布式锁。Redis 是常用手段:SET key value NX EX 原子加锁 + Lua 原子解锁 + watchdog 续期。
但选型要有判断力:Redis 锁是性能优先、容忍极小概率并发(主从异步复制可能丢锁,Redlock 又依赖时钟有争议);对强一致场景(金融、选主、任务调度去重),用基于共识的 etcd / ZooKeeper。面试能把"Redis 锁的边界"讲清楚,比会写代码更出彩。
故障与一致性边界:锁的失效场景(面试深挖最狠,必背)
① 主从切换丢锁(Redis):
- 流程:A 拿到锁(写进 master)→ master 挂 → 从提升(没有 A 的锁)→ B 拿到锁 → A、B 同时"持锁",互斥被破坏;
- 这是异步复制丢写在锁上的具体表现(见一致性与故障边界 4.6);
- Redlock(多节点仲裁)试图解决,但依赖时钟同步假设,有争议——不能作为"绝对互斥"的保证。
② 锁过期但业务没跑完(任何锁都有):
- 锁 TTL 到了 → 第二个客户端拿到锁 → 双执行;
- 缓解:watchdog 看门狗续租(业务没结束就续租)——只缓解,不能根治(续租请求也可能晚到/网络分区);
- 根治:Fencing Token——锁服务每次发锁附带递增 token,写数据时带 token,存储端拒绝旧 token 的写。这才是"旧锁持有者不能写数据"的真正防线,Redis 锁没这个能力,etcd 锁可以自己实现;
- 面试金句:"续租解决'锁提前过期',fencing token 解决'过期后旧持有者继续写'——后者才是安全性的关键。"
③ 锁的 AP/CP 定性:
| 实现 | 分区/故障时 | 定性 |
|---|---|---|
| Redis SETNX(主从异步) | 主挂丢锁 → 双持锁 | AP:可用性优先,互斥可能失效 |
| Redlock | 多数派节点确认,但依赖时钟 | 争议中:时钟漂移下依旧可能双持锁 |
| etcd/ZK 锁(租约 + CAS + 多数派) | 锁不丢(多数派确认);少数派不可用 | CP:互斥绝对可靠,代价是 etcd 不可用时拿不到锁 |
选型结论:防击穿、防重复提交(失效了重试一次就行)→ Redis 锁;扣款、选主、调度去重(失效 = 资损/事故)→ etcd/ZK 锁。
限流:怎么控制"只允许这么多"
后端不能被瞬间流量打垮,限流在入口控制请求量。四种算法:固定窗口(简单但有边界突刺)、滑动窗口(平滑,统计最近 N 秒)、令牌桶(匀速放令牌,允许突发,最常用)、漏桶(严格匀速,不能突发)。
单机用 Go 的 rate 包(令牌桶),分布式用 Redis + Lua 集中计数。追问"QPS 翻 10 倍":多级限流(网关 + 服务 + 本地)层层拦截。
故障与一致性边界:限流器挂了
| 场景 | 现象 | 处理 |
|---|---|---|
| Redis 限流器挂 | 拿不到计数器,请求放行判断失败 | 降级为本地限流(每实例内存令牌桶,总量 = 分布式配额 / 实例数)+ 超阈值直接拒绝——限流器"宁可多拦、不可漏拦"(限流过头是体验问题,漏拦是雪崩问题) |
| 限流误杀(误判) | 正常流量被拦(如计数器 key 过期边界、时钟不同步) | 固定窗口的"边界突刺" → 滑动窗口;分布式计数用原子 Lua 避免竞态;关键接口降级放行而不是拒绝 |
| 限流计数与真实流量不一致 | 多级限流叠加导致过严/过松 | 每级配额独立设计(网关拦大流量、服务拦细粒度),总量按最严一级兜底 |
深挖点:分布式限流为什么不用 DB 计数?→ DB 扛不住高频写;Redis 单命令/Lua 原子。→ 限流是 AP 还是 CP?→ 本质是 AP(宁可多拦/漏拦一点,绝不让限流器本身成为单点雪崩源)——限流器挂了的策略是"拒绝服务"而非"放行到死"。
接口幂等:怎么保证"只执行一次"
网络重试、用户重复点击,都会让同一请求执行多次(重复下单、重复扣款)。幂等就是"同一请求执行多次,结果和执行一次一样"。
三种手段:唯一键(业务唯一 ID 落库加唯一索引,重复插入报错)、Token 机制(先申请 token,提交时校验并删除,一次性)、状态机(订单只能从"待支付"变"已支付"一次)。其中唯一键是最可靠的兜底。
故障与一致性边界:幂等本身也会挂
| 场景 | 现象 | 处理 |
|---|---|---|
| 幂等表/唯一索引所在 DB 挂 | 无法判重 → 可能重复下单 | 唯一约束是强一致锚点:DB 挂只能"拒绝写"(降级),不能"先放行后对账"——但可以"放行到 MQ + 消费端幂等去重"(把判重推迟到异步段) |
| Token 机制中 Redis 挂 | token 无法校验/删除 | token 校验失败拒绝请求(宁可拒,不可重);token 删除失败用 Lua 原子删 |
| 状态机并发 | 两个请求同时把"待支付"改成"已支付" | 当前读 + 条件更新(UPDATE ... WHERE status='pending' 影响行数=1 才算成功);DB 行锁兜底 |
| 重复回调(Kafka at-least-once) | 同一条消息消费多次 | 消费端唯一键 + 状态机幂等:先查状态,已终态直接跳过(见场景题(下) 的消费幂等落地) |
深挖链:幂等键存哪?→ DB 唯一索引(强一致判重)。→ 为什么不用 Redis SETNX 判重?→ Redis 可能丢写/主从切换,判重结果不可靠;DB 唯一约束是最终防线,Redis 可做前置加速。→ 那性能怎么办?→ 前置 Redis 挡 99%,DB 唯一索引兜底 1%。
消息可靠性:怎么保证"不丢不重"
这个和 S3 Kafka 呼应:不丢靠三段设防(生产 acks=-1 + 副本 + 手动提交);不重靠消费端幂等(唯一键/去重表);顺序靠同 key 同分区 + 分区串行。综合起来就是一条可靠的异步链路。
故障与一致性边界:异步链路每一段挂了(必背的"四段防线")
| 段 | 挂的场景 | 处理 |
|---|---|---|
| 生产端:producer 发消息失败 / broker 挂 | 消息没进 Kafka | 本地消息表(业务 + 消息同事务,后台扫表补发);或事务消息(RocketMQ 半消息)——保证"业务成功 ⇒ 消息一定发出" |
| Broker:副本挂 | leader 挂丢消息 | acks=all + min.insync.replicas≥2(ISR 少于 2 拒绝写,用可用性换不丢,见一致性与故障边界 4.4) |
| 消费端:consumer 处理中崩溃 | offset 未提交 → 重放 → 重复消费 | 消费幂等:唯一键 + 状态机,终态跳过;这是 at-least-once 的收敛手段 |
| 对账:链路静默丢消息 | 没人发现丢了 | 对账任务:定时比对"DB 应处理数 vs 实际处理数",发现差异 → 重投/补偿(见一致性与故障边界 4.7 的四件套) |
深挖链:Kafka 消息丢了怎么发现?→ 对账(业务侧统计比对),Kafka 自身不感知业务丢。→ 对账怎么对?→ 以"DB 已落库的业务单号"为基准,查消费端处理表,缺失的补投。→ 补投会不会重复?→ 消费幂等去重。
串起来
这四个场景看似独立,其实是同一个问题:分布式下,原本单机"天然正确"的事(互斥、限流、幂等、可靠)都失效了,要用中间件和协议重新建立正确性。而"正确性"的敌人不是性能,是故障——每个方案都要回答"对应的组件挂了,正确性还保不保得住、怎么保"。理解了这条主线,遇到任何"多实例下怎么保证 X"的问题,都能套用"锁/限流/幂等/消息 + 故障边界"这套工具箱。
下一篇讲读多写少的大流量场景——Feed 流、点赞计数、排行榜、短链、海量数据,这些"看起来简单、扛起量来极难"的场景。