🔥个人主页:代码不加冰(欢迎来访)
🎬作者简介:java后端学习者
❄️个人专栏:LeetCode刷题日记 , 苍穹外卖日记,SSM框架深入,JavaWeb
✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:
大家好我是代码不加冰,继学完黑马点评之后,我们算是正式入门了后端,但是如果仅仅是这两个项目,那是完全不够看的,因此我这个暑假准备学两个新的项目写在简历里,然后再搞一下开源,为简历做准备,算是同步进行,算法和八股也不能落下。因此在这里开设一个新的专栏来分享一些新项目的重要技术,以及我自己的一些收获,也可以从中学到怎么入手一个新的项目。
摘要:
这里给大家分享一个技术,也不能说是技术,算是一种模式,在此之前我都没听过,不过学完之后感觉还是很有用的,一章肯定是讲解不完的,欢迎大家持续关注。
在暑期简历备战之际,本文为你解锁一个大厂高并发架构中的核心模式——Single-flight(单飞机制)。当热点数据导致缓存击穿时,Single-flight 能通过请求分组与状态跟踪,让成百上千个并发线程只由“第一个幸运儿”去冲击底层资源,其余线程原地阻塞等待并直接“共享结果”,将 DB 压力骤降至O(1)。
文章不仅深度剖析了其核心原理与应用场景,还直击痛点,揭示了单 JVM 环境下线程枯竭、OOM 以及异常共享等核心缺陷与补救措施。最后,结合分布式架构演进,类比分布式锁,引入基于 CAP 理论的“全局锁、状态机、心跳续期及 L1 降级”等分布式单飞核心设计,助你全方位攻克高并发流量穿透难题,打造差异化简历亮点!
Single-flight(单飞机制)
是一种并发控制模式,它的核心思想是:
对于同一个资源的多个并发请求,只允许第一个请求真正执行,其他请求等待第一个请求完成,然后共享同一个结果。
它的目的就是避免重复计算、重复访问数据库或远程服务,防止瞬时高并发把后端打爆。
举个例子
假设你的系统有一个接口:
GET /user/1001正常情况下:
1000 个用户同时访问 │ ▼ 1000 次查询数据库数据库:
SELECT * FROM user WHERE id = 1001如果数据库一次查询需要:
100ms那么1000个相同的SQL,实际上完全是重复劳动。
使用 Single-flight 后
流程变成:
1000 个请求 │ ▼ 发现 key = user:1001 已有人在查询 │ ├──────────────┐ │ │ 第一个请求 后面999个请求等待 │ │ ▼ │ 查询数据库 │ │ │ ▼ │ 得到结果 │ │ │ 通知所有等待者 ◄──────┘ │ ▼ 1000 个请求一起返回数据库只查询1次
为什么叫 Single-flight
这个名字来自 Go singleflight package。
意思类似:
同一趟航班(Flight)只飞一次。
例如:
很多人都想去:
北京 → 上海没有 Single-flight:
1000 架飞机有 Single-flight:
1 架飞机 1000 人一起坐请求也是一样:
同一个 key user:1001 大家共享一次执行结果Single-flight解决什么问题
主要解决:
① 缓存击穿(最经典)
例如Redis:
user:1001突然过期,瞬间
5000 个请求全部发现:
Redis 没有于是:
5000 次数据库查询数据库直接压力暴增,加入 Single-flight
Redis miss ↓ 只有一个请求查数据库 ↓ 其他4999个等待 ↓ 数据库返回 ↓ 全部共享结果数据库压力从:
5000 次 ↓ 1 次② 防止重复调用远程接口
例如调用 AI:
GPT或者支付查询,订单查询
如果:
100 个线程都请求:
order123其实只需要查一次即可。
③ 防止重复计算
例如:
生成 PDF,生成一次需要10秒
如果100个请求同时生成100次,浪费 CPU。
Single-flight:
只生成一次 其他请求等待 共享生成结果核心原理
Single-flight 的原理非常简单直观:
请求分组:使用一个唯一的 key 来标识相同的请求(如缓存 key、接口参数哈希)
状态跟踪:内部维护一个 map,key 是请求标识,value 是正在执行的请求状态
结果共享:当新请求到来时,先检查 map 中是否已有相同 key 的请求在执行
如果有:直接等待该请求的结果
如果没有:创建一个新的请求并执行,同时将其加入 map
结果广播:当请求执行完成后,将结果返回给所有等待的请求,并从 map 中移除该请求
可以用一个生活中的例子来理解:多个人同时想点同一家外卖,与其每个人都打开 APP 下单,不如大家凑在一起,由一个人下单,然后大家共享这份外卖。这就是 Single-flight 的工作方式。
单 JVM 环境下的核心缺陷与隐患
虽然单 JVM 避免了分布式网络 I/O,但由于所有线程都在同一个内存堆(Heap)中,它的缺陷会直接反映在 线程模型 和 内存管理 上:
1. 线程大面积阻塞与线程池耗尽(Thread Starvation)
隐患:在单 JVM 中,Tomcat/Jetty 等 Web 服务器的业务线程池(如 200 个线程)是有限的。
后果:如果底层数据库(DB)变慢,执行单飞任务的那个线程迟迟不返回,导致后续所有请求该 Key 的 JVM 业务线程全部进入
WAITING或TIMED_WAITING状态。如果热点 Key 较多,整个 JVM 的线程池会在瞬间被榨干,导致整个服务无法响应其他任何请求。后果还是很严重的。
2. 内存突增风险(OOM 隐患)
隐患:虽然多个线程共享了一个结果对象,但在等待期间,每个停留在
future.get()或锁等待处的线程,都会占用一定的JVM 栈内存(Thread Stack)(通常每个线程默认 1MB)。后果:高并发下,大量线程积压会导致 JVM 线程栈内存开销激增。如果单飞返回的数据量非常大(例如一个巨大的 JSON 字符串或大列表),虽然对象只有一份,但如果引用的上下文中处理不当,可能会导致对象在堆中存活时间过长,频繁引发小范围的 GC 抖动。
3. 异常共享(一错皆错)与缓存空穿透
隐患:如果那个幸运的 JVM 线程在执行
loader.get()时,遭遇了临时的数据库连接超时,抛出了RuntimeException。后果:单 JVM 内所有等待这个
Future的线程都会同时收到ExecutionException。这种“错误共享”会导致本该只有 1 个用户报错的偶发问题,瞬间扩散到所有并发访问的用户。
4. 无法应对集群扩展
隐患:单 JVM 的单飞机制只在当前进程内有效。
后果:如果你的服务未来为了抗流,从单 JVM 扩展到了 10 台机器的集群。在同一时刻,10 台机器上会分别有一个线程去冲击数据库。虽然每台机器做到了单飞,但对于后端 DB 来说,依然承受了 $10 \times 1$ 的并发压力,因此就要引出分布式的单飞机制。
单 JVM 下的最佳实践与补救
为了在单 JVM 下安全地使用单飞,建议采取以下防范措施:
严格设置 Future 的 Get 超时时间:
绝对不要使用无限等待的
future.get(),而是使用future.get(500, TimeUnit.MILLISECONDS)。一旦超时,快速降级(返回熔断数据或旧缓存),防止耗尽 JVM 线程池。使用 Guava / Caffeine 內建的单飞:
Java 中不建议自己手写单飞,Caffeine 缓存的
cache.get(key, k -> loadFromDB(k))原生在底层就实现了单 JVM 内的单飞机制(基于ConcurrentHashMap的锁分段和Node锁),并且针对垃圾回收和并发性能做到了极致优化。结合异步刷新(Refresh After Write):
让老数据先返回给用户,后台启动一个异步线程去单飞加载新数据。这样用户线程永远不会阻塞,彻底根治线程木僵和超时问题
分布式Single-flight
其实学这个单体和分布式,和我们在点评中学的锁机制感觉有异曲同工之妙,单体锁和分布式锁,都是为了应对负载均衡,集群部署的问题
单机能力 集群后为什么失效 演进方案 synchronized锁只能作用于当前 JVM 分布式锁 ReentrantLock只能锁本机线程 分布式锁 Single-flight 只能去重本机请求 分布式 Single-flight 根本原因都是:
负载均衡后,请求被分发到了不同的 JVM,本地内存状态无法共享。
唯一的区别是,它们扩展的是不同的能力:
- 锁扩展的是互斥能力(整个集群只能有一个执行者)。
- Single-flight扩展的是请求去重能力(整个集群相同请求只执行一次,其他节点共享结果)。
所以,演进原因相同,扩展目标不同。
因此明白了上面的类比,我们再学习分布式的Single-Flight就很简单了
用一句话来说,就是为了解决在多台机器组成的集群环境下,防止热点缓存失效时高并发流量穿透到后端,确保整个集群在同一时刻仅有唯一一个请求去冲击数据库,从而彻底避免数据库被冲垮,其实就是通过共享那一次的查询结果,来避免数据库被冲垮,因果关系。
再深入一下:
分布式系统的CAP理论:
在分布式系统中,一致性、可用性和分区容错性三者不能同时完全满足,系统设计必须在其中做出权衡。
C (Consistency - 一致性):每次读取要么获得最新写入的数据,要么报错。在分布式系统中,这意味着所有节点在同一时间的数据必须完全一致,就像访问单机单台数据库一样。
A (Availability - 可用性):每次请求都能获得一个(非错误)的响应,但不保证获取的是最新写入的数据。系统必须一直处于“可提供服务”的状态。
P (Partition Tolerance - 分区容错性):尽管网络会丢失或延迟节点之间的任意数量的消息(即发生网络分区),系统仍能继续运行。
所以,为了实现在分布式环境下,系统能够实现高可用,基于CAP理论,我们的分布式Single-flight分布式做了如下的设计
核心设计理念与 CAP 取舍:
全局锁与状态机(偏向 CP):使用 Redis Lua 脚本保证争抢执行权的原子性。全网多个实例同时发起相同请求时,系统强制保证只有一个 Leader 节点突围。此时为了防止“僵尸节点”脑裂,引入Fencing Token(防护令牌)机制,一旦原 Leader 假死超时,立即剥夺其回写结果的权利。保证了在并发控制上对强一致性(C)。
心跳续期与主动接管(保证 A):如果 Leader 节点真宕机了,其他处于等待共享状态的 Follower 节点不能被永远阻塞。通过 Owner Heartbeat 机制,一旦 Leader 心跳丢失,Follower 迅速超时并重新发起选举接管请求。这保证了哪怕发生单点故障,整个面试链路的可用性(A)依然不受影响。
本地 L1 Cache 终极降级(AP 兜底):当遭遇极端网络故障(严重的 P),Redis 集群整体不可用时,框架自动退化回本地单机 Single-flight 模式。此时系统主动放弃了全局一致的请求合并(C),但死保住了用户的核心面试流程不中断(A)。
至于什么是状态机,heartbeat机制等等,我们放在后面研究。
结语:
这里仅仅是简单的带大家了解了一下这种单飞机制,下面我会继续深入的讲解这些底层原理,以及一些其他的技术。