Skip to content

高并发场景题(中):分布式环境下,怎么保证"只一次、只那么多"? ​

属于 S5 高并发系统设计 · 第三篇 上一篇:场景题(上) 下一篇:场景题(下)

上一篇处理"流量洪峰",这一篇处理另一个维度的难题——分布式环境下的正确性:多台机器一起跑,怎么保证"同一时刻只有一个执行""同一请求只生效一次""消息不丢不重"。这四个场景,本质都是在回答"分布式下如何维持正确性",而正确性在"某个组件挂了"的瞬间最容易被打破——所以每个场景的故障边界都要讲透(底层逻辑见一致性与故障边界)。

分布式锁:多实例怎么互斥 ​

单机 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 流、点赞计数、排行榜、短链、海量数据,这些"看起来简单、扛起量来极难"的场景。

持续学习,持续构建。