通用的高并发架构设计
2.1 高并发架构设计的要点
高并发不是单纯“QPS 高”。一个系统能否支撑高并发,要同时看三类能力:
1 | 高并发系统 |
这三者是后续所有方案的基础:
读写分离、缓存、CQRS、分库分表、异步处理等,本质都是围绕这三点做架构取舍。
2.1.1 形成高并发系统的必要条件
形成高并发系统主要需要三个条件:
1. 高性能
高性能指系统处理请求的速度足够快。
如果系统响应时间太长,即使没有宕机,用户体验也会很差,所以高并发首先要解决“请求处理得够不够快”。
2. 高可用性
高可用指系统能够长期、稳定、正确地提供服务。
高并发系统不能只在正常情况下跑得快,还要在机器故障、服务异常、流量波动时尽量不中断服务。
也就是说,系统不能经常宕机、崩溃,或者出现大面积不可用。
3. 可扩展性
可扩展性指系统可以通过扩容来支撑不断增长的请求量。
理想情况下,当请求量上升时,可以通过增加机器、增加节点等方式水平扩展系统能力。
如果系统只能依赖单机性能,一旦流量超过单机极限,就很难继续支撑。
2.1.2 高并发系统的衡量指标
高并发系统常见的衡量指标主要有三类:高性能指标、高可用性指标、可扩展性指标。
1. 高性能指标
性能通常用“响应时间”来衡量,也就是从请求发出到系统返回结果所花的时间。
但只看平均响应时间不够,因为平均值会掩盖慢请求。例如大部分请求很快,少量请求特别慢,平均值可能仍然好看,但那些慢请求对应的用户体验很差。
所以实际更常看 PCT 指标。
PCT 指标
PCT 可以理解为“百分位响应时间”。
例如 PCT99 = 800ms 表示:在所有请求中,99% 的请求响应时间都在 800ms 以内。
其他常见指标:
1 | PCT50:50% 请求的响应时间 |
个人理解:
1 | 平均响应时间:看整体大概水平 |
**高并发系统不能只追求平均值好看,还要控制长尾延迟。**因为少量比例的慢请求在大流量下也会影响大量用户。
2. 高可用性指标
可用性一般用系统正常运行时间占总时间的比例表示:
可用性 = 正常运行时间 / 总运行时间
常见说法是几个“9”:
| 可用性 | 俗称 | 一年不可用时间 | 一天不可用时间 |
|---|---|---|---|
| 90% | 1 个 9 | 36.5 天 | 2.4 小时 |
| 99% | 2 个 9 | 3.65 天 | 14.4 分钟 |
| 99.9% | 3 个 9 | 约 8 小时 | 1.44 分钟 |
| 99.99% | 4 个 9 | 约 52 分钟 | 8.6 秒 |
| 99.999% | 5 个 9 | 约 5 分钟 | 0.86 秒 |
重点记忆:
1 | 3 个 9:99.9% |
很多互联网系统会要求 3 个 9 或 4 个 9。
可用性越高,系统设计越复杂,需要监控、告警、限流、降级、故障转移、容灾等能力配合。
3. 可扩展性指标
可扩展性主要看系统面对流量增长时,能不能通过增加机器来提升整体处理能力。
理想情况:
1 | 增加 N 台机器,系统能力提升 N 倍 |
但实际中通常做不到线性增长,因为节点变多后会带来额外成本,例如:数据同步、负载均衡、网络通信、一致性处理。
可扩展性 = 吞吐量提升比例 / 集群节点增加比例
所以可扩展性不是简单“加机器”,而是系统架构本身要支持横向扩展。
书中提到,一般来说,系统扩展性达到 70%~80%,基本就可以认为能够满足可扩展性要求。
2.1.3 高并发场景分类
计算机系统中的业务操作最终主要体现在两类数据操作上:读 和 写。所以高并发场景也可以围绕“读”和“写”来划分。
1. 高并发读场景
高并发读场景的核心问题是:大量请求同时读取数据,数据库容易成为瓶颈。
常见优化方向包括:读写分离、本地缓存、分布式缓存
这些方案的目标都是减少数据库直接承受的读压力,提高查询响应速度。
对应后续章节:
1 | 2.2 数据库读写分离 |
2. 高并发写场景
高并发写场景的核心问题是:大量请求同时修改数据,系统既要扛住写入压力,又要处理数据一致性问题。
写请求比读请求更复杂,因为写操作通常涉及:数据变更、事务、锁竞争、热点数据、一致性、扩容后的数据分布
对应后续章节:
1 | 2.6 数据分片 |
3. CQRS 作为读写场景的总结
CQRS 是一种把“读”和“写”职责分离的架构思想。
- Command:写操作,负责修改数据
- Query:读操作,负责查询数据
它可以把读模型和写模型拆开,让读、写分别用更适合自己的方式优化。
对应后续章节:
1 | 2.5 CQRS |
本节总结
1 | 高并发系统不能只看 QPS。 |
性能指标不要只看平均响应时间,要重点关注:
1 | PCT99 |
因为它们能反映长尾请求问题。
高并发场景可以先按读写拆分:
1 | 读高并发:读写分离、本地缓存、分布式缓存 |
2.2 高并发读场景方案 1:数据库读/写分离
大部分互联网应用都是读多写少,例如浏览帖子、浏览商品的请求通常远多于发帖、下单请求。
因此数据库的高并发压力,很多时候主要来自读请求。
数据库读/写分离的核心思路是:
1 | 写请求 → 写库 |
这样可以把大量读请求从主数据库中分离出去,降低数据库访问压力,并缩短请求响应时间。
2.2.1 读/写分离架构
读/写分离通常依赖数据库的主从复制实现。
1 | Master:主库,负责写请求 |
常见结构是:
1 | 写请求 |
一个 Master 可以连接多个 Slave。
写请求先落到 Master,再由 Master 把数据复制到 Slave;读请求则尽量从 Slave 查询。
这样做的核心收益是:主库专注处理写、从库分担大量读、整体数据库读压力下降
但它也带来一个新问题:主从复制不是瞬间完成的,所以可能出现主从延迟。
2.2.2 读/写请求路由方式
读/写分离后,系统必须判断: 这个请求应该去 Master,还是去 Slave?
也就是要做读/写请求路由,有两种较为常见的方式:
1. 基于数据库 Proxy 代理的方式
这种方式是在业务服务和数据库之间增加一个数据库代理层。
1 | 业务服务 → Proxy → Master / Slave |
Proxy 负责识别 SQL 类型:
1 | insert / delete / update → Master |
例如:
1 | 写操作:insert、delete、update |
常见实现包括:
1 | MySQL-Proxy |
这种方式的特点是:
业务服务不直接关心读写分离逻辑,而是把请求交给 Proxy,由 Proxy 统一转发。
- 优点:对业务代码侵入较小,读写路由集中管理
- 缺点:多了一层 Proxy,Proxy 本身也要考虑性能和可用性
2. 基于应用内嵌的方式
这种方式不单独引入 Proxy,而是在业务服务进程内部完成读写路由。
1 | 业务服务内部判断读写请求 |
常见框架或工具包括:gorm、shardingjdbc
- 优点:少一层 Proxy,链路更短
- 缺点:读写分离逻辑会进入应用侧,业务系统需要接入相关框架
2.2.3 主从延迟与解决方案
数据库读/写分离依赖主从复制,但主从复制存在一个典型问题:主从延迟。
也就是:
1 | 数据已经写入 Master |
这会导致短时间内:
1 | Master 有新数据 |
针对主从延迟,有三种常见解决方案。
1. 同步数据复制
默认情况下,很多数据库主从复制是异步模式:Master 写入成功后立即返回,不等待 Slave 是否收到数据
同步数据复制的思路是:Master 写完数据后,必须等所有 Slave 都收到数据,再返回写入成功
这样可以保证:只要写请求返回成功,Master 和 Slave 都能读到最新数据
但代价也很明显:写请求响应时间变长、数据库吞吐量下降
所以这种方案虽然一致性强,但实用价值较低,一般只适合低并发场景。
2. 强制读主
不同业务对主从延迟的容忍度不同。
例如:用户刚发布一条状态,他自己刷新主页时,应该立刻看到这条状态。
这种场景对延迟不太能容忍,应该强制读 Master。
但如果是:好友浏览这个用户主页,暂时看不到刚发布的状态
这种场景通常可以容忍短暂延迟,可以继续读 Slave。
所以可以按业务场景区分:
1 | 能容忍延迟 → 正常读 Slave |
注意:强制读主不是所有读都读主,而是关键场景读主,否则 Master 压力会重新变大
3. 会话分离
会话分离可以理解为:
如果某个用户会话刚刚执行过写操作,那么在接下来很短的一段时间内,该会话的读请求暂时走 Master。
例如:
1 | 用户 A 发布状态 |
这个时间一般设置为略高于主从复制延迟。
这样做的目的:
1 | 保证用户自己的写操作能立刻对自己可见 |
总结:
1 | 强制读主:按业务场景判断 |
本节总结
数据库读/写分离解决的是高并发读场景下的数据库读压力问题。
核心结构:
1 | Master 负责写 |
读写请求路由有两种方式:
- Proxy 代理路由
- 应用内嵌路由
读/写分离最大的副作用是:主从延迟
主从延迟的三种解决方案:
- 同步数据复制:一致性强,但写性能差
- 强制读主:关键场景直接读 Master
- 会话分离:用户写完后,短时间内自己的读请求走 Master
2.3 高并发读场景方案 2:本地缓存
缓存的核心作用是:把访问频繁的数据放到更快的位置,减少对慢存储的访问。
在高并发读场景中,如果每次请求都访问数据库,数据库很容易成为瓶颈。
本地缓存就是把热点数据缓存在应用进程内存中:
1 | 请求 → 应用本地缓存 |
本地缓存的优点:访问速度快、不需要网络请求、实现相对简单
本地缓存的缺点:受单机内存限制、多台机器之间缓存不共享、缓存数据可能和数据库不一致
所以本地缓存适合缓存那些:访问频繁、数据量不大、允许短暂不一致、变化不太频繁的数据。
2.3.1 基本的缓存淘汰策略
本地缓存空间有限,不可能无限存数据。
当缓存满了,就需要决定:
1 | 哪些数据留下? |
这就是缓存淘汰策略。
FIFO 策略
FIFO,全称是 First In First Out,即先进先出。最早进入缓存的数据,最先被淘汰
优点是实现简单。
缺点是它不关心数据是否常用,可能会把仍然很热门的数据淘汰掉。
LFU 策略
LFU,全称是 Least Frequently Used,即最不经常使用。访问次数最少的数据,优先被淘汰
它关注的是访问频率。
例如某个数据被访问了很多次,就更可能被保留下来。
但 LFU 的问题是:一些数据以前访问很多,但现在已经不常用了,它仍然可能因为历史访问次数高而长期留在缓存中。
也就是说,LFU 容易受到“历史热点数据”的影响。
LRU 策略
LRU,全称是 Least Recently Used,即最近最少使用。最近最久没有被访问的数据,优先被淘汰
它关注的是访问时间,而不是总访问次数。
LRU 的核心假设是:最近被访问过的数据,接下来更可能继续被访问。
LRU 比 FIFO 更合理,也比 LFU 更能适应热点变化。
但 LRU 也有问题:如果出现一次性的大量数据访问,可能会把真正的热点数据挤出缓存。
2.3.2 W-TinyLFU 策略
W-TinyLFU 策略结合了 LFU 策略和 LRU 策略的优点,并具有高缓存命中率与低内存占用,Redis 和高性能的 Java 本地缓存 Caffeine Cache 组件都使用 W-TinyLFU 策略管理缓存。
虽然 W-TinyLFU 的名字带有 LFU,但它实际上是 LFU 策略和 LRU 策略的结合体。从缓存内存空间的布局来看,W-TinyLFU 将缓存的内存空间划分为两部分,如图所示:

(1)Window LRU 段(对应图中的 LRU) 此内存段使用 LRU 策略缓存数据,其占用的内存空间是总缓存内存空间的 1%。
(2)Segment LRU 段(简称 SLRU) 此内存段使用 SLRU 策略缓存数据,具体是将缓存段进一步划分为 protected 段(保护段)和 probation 段(试用段)。
其中:
- probation 段负责存储最近被访问 1 次的缓存数据;
- protected 段负责存储最近被访问至少 2 次的缓存数据。
Segment LRU 段内存空间的 80% 被分配给 protected 段,剩余 20% 的内存空间被分配给 probation 段。
W-TinyLFU 策略的工作流程如下:
(1)将首次被访问的数据 X 缓存到 Window LRU 段。
(2)当 Window LRU 段的内存空间已满时,使用 LRU 策略将被淘汰的数据移入 Segment LRU 段中的 probation 段,之后数据 X 被访问时,再将其移入 protected 段。
(3)当 protected 段的内存空间已满时,使用 LRU 策略将被淘汰的数据 X 移入 probation 段。
(4)当数据 X 要被移入 probation 段,但是其内存空间已满时,使用 LRU 策略将被淘汰的数据 Y 取出,与数据 X 进行访问频率的对比,将访问频率高的数据留在 probation 段,将访问频率低的数据淘汰。
W-TinyLFU 策略使用 Count-Min Sketch 近似算法来保存每条缓存数据的访问频率,如图所示:

Count-Min Sketch 算法的运行流程如下:
(1)选定 M 个哈希函数,分配一个 M 行 N 列的二维数组作为哈希表。
(2)当某数据的访问频率增加时,对数据 Key 分别使用 M 个哈希函数计算出哈希值,再对 N 取模,然后将二维数组每一行对应列位置的数值加 1,即二维数组中 M 个位置的数值均更新。
(3)当查询某数据的访问频率时,进行同样的哈希计算,将二维数组中 M 个位置的数值读出,选择其中的最小值作为此数据的访问频率。
值得注意的是,二维数组的每个位置仅需 4bit,这是因为 W-TinyLFU 策略并不存储具体的访问计数,而是更希望反映出不同数据的访问频率的区分度。
**此策略认为每条数据的访问频率达到 15 次时已经很高了,于是以 4bit 表示每条缓存数据的访问频率。**不过,如果大量数据均达到 15 次的访问频率,那么会使得访问频率的区分度大大降低。
Count-Min Sketch流程很容易看懂,但如何理解 Count-Min Sketch 的可行性?
你可以把 Count-Min Sketch 理解成:不用给每个 Key 单独建一个计数器,而是让多个 Key 共享一批计数器,通过多次哈希来“近似判断谁更热”。
1. 每个 Key 的真实访问次数,一定会被加到对应位置上
假设 Key =
A被访问一次。它会经过 M 个哈希函数,分别落到 M 个位置上:
1
2
3
4 A -> h1(A) -> 第 1 行某一列 +1
A -> h2(A) -> 第 2 行某一列 +1
A -> h3(A) -> 第 3 行某一列 +1
A -> h4(A) -> 第 4 行某一列 +1所以
A每被访问一次,它对应的 M 个位置都会 +1。因此以后查询
A的访问频率时,这些位置里一定包含了A自己贡献的次数。也就是说:
1 查出来的值 >= A 的真实访问次数它不会低估,只可能高估。
2. 高估来自哈希冲突
问题是:其他 Key 也可能哈希到同一个位置。
比如:
1 A 和 B 在第 1 行落到了同一个位置那么这个位置的值就会变成:
1 A 的访问次数 + B 的访问次数这就导致查询
A时,这一行得到的结果偏大。所以 Count-Min Sketch 的误差来源是:
1 哈希冲突导致别的 Key 的访问次数混进来了
3. 为什么取最小值?
因为每一行使用的是不同哈希函数。
虽然某一行可能冲突严重,但不太可能所有行都和很多热门 Key 冲突。
举个例子,假设
A真实访问了 5 次。查询时读到 4 个位置:
1
2
3
4 第 1 行:5
第 2 行:8
第 3 行:6
第 4 行:20这些值都至少包含
A自己的 5 次。但是:
8是因为混入了别的 Key;6也是有轻微冲突;20是冲突很严重;5可能刚好没有冲突。所以取最小值:
1 min(5, 8, 6, 20) = 5这样可以尽量排除哈希冲突带来的虚高。
所以它叫 Count-Min Sketch:
1
2
3 Count:计数
Min:取最小值
Sketch:草图 / 近似结构
4. 为什么它适合 W-TinyLFU?
因为 W-TinyLFU 并不需要精确知道:
1
2 Key A 访问了 137 次
Key B 访问了 142 次它只需要大概判断: 这个数据是不是比另一个数据更热?
比如 probation 段满了,现在要比较: 新来的 X vs 即将被淘汰的 Y
只要能大致判断:X 的访问频率 > Y 的访问频率 就够了。
所以 Count-Min Sketch 的价值是: 用很小的内存,近似判断数据热度
5. 关键理解
普通精确计数需要这样:
1
2
3
4
5 Key A -> 计数器
Key B -> 计数器
Key C -> 计数器
Key D -> 计数器
...如果 Key 很多,内存占用很大。
Count-Min Sketch 是这样:
1 大量 Key 共享一个二维计数表**代价是: 可能有哈希冲突,所以结果不完全准确 **
**但好处是:内存占用极低,速度快,足够判断冷热 **
一句话说:Count-Min Sketch 之所以可以,是因为它保证查询结果不会低于真实访问次数,再通过多组哈希和取最小值,尽量降低哈希冲突带来的高估误差。
2.3.3 SingleFlight
缓存击穿指的是缓存中一条热门数据在缓存失效的瞬间,对它的并发请求会“击穿”缓存,直接访问数据库,导致数据库被高并发请求击垮。
Golang语言扩展包提供的同步原语 SingleFlight 能很好地解决缓存击穿问题。
假设有 100 个并发请求同时访问同一个 key:
1 | 缓存中没有这个 key |
但实际上,这 100 个请求查的是同一份数据。
理想情况应该是:
1 | 只有 1 个请求真正去查数据库 |
这就是 SingleFlight 的作用。
SingleFlight 的基本流程
1 | 1.多个请求访问同一个 key |
这样可以避免缓存未命中时,大量相同请求同时打到数据库。
SingleFlight 的实现思想
它内部大致维护一个 map:
1 | key → 正在执行的调用 |
当请求进来时:
1 | 1. 如果这个 key 已经有请求在执行 |
底层会用类似 WaitGroup 的机制,让后续请求等待第一个请求完成。
SingleFlight 的价值
SingleFlight 主要用于防止:缓存击穿、热点 key 并发回源、数据库瞬时压力过大
它不是为了提高单个请求速度,而是为了减少重复查询,保护后端存储。
总结:缓存解决“不要频繁查数据库”,SingleFlight 解决“缓存没命中时,不要所有请求一起查数据库”
本节总结
1 | 本地缓存适合高并发读场景, |
常见缓存淘汰策略:
1 | FIFO:先进先出,简单但粗糙 |
SingleFlight 的核心作用:
1 | 同一个 key 并发缓存未命中时, |
一句话总结:本地缓存提升读性能,缓存淘汰策略决定缓存命中率,SingleFlight 用来防止缓存未命中时的并发击穿。
2.4 高并发读场景方案 3:分布式缓存
本地缓存虽然访问速度快,但有几个明显问题:
1 | 1. 无法共享:不同服务进程之间不能共享同一份缓存 |
所以在更大的系统中,通常会使用分布式缓存。
分布式缓存的特点是:
- 缓存独立部署
- 多个服务都可以访问同一份缓存
- 和具体编程语言无关
- 更容易扩容
- 部分组件支持数据持久化
常见代表就是 Redis。
2.4.1 分布式缓存选型
主流分布式缓存主要有两类:Memcached、Redis
二者都可以把数据缓存在内存中,提高读请求性能,但 Redis 功能更丰富。
1. 数据类型
Memcached 主要支持简单的 key-value 字符串数据。
Redis 支持的数据类型更丰富,例如:字符串 String、哈希 Hash、列表 List、集合 Set、有序集合 Sorted Set
所以 Redis 不只是简单缓存,还可以支持更多业务场景。
2. 数据持久化
Memcached 主要是纯内存缓存,不强调持久化。
Redis 支持数据持久化,主要方式包括:RDB 快照、AOF 日志
这意味着 Redis 中的数据在一定程度上可以落盘保存,服务重启后也有机会恢复数据。
3. 高可用
Redis 支持主从复制。
Master 负责写、Slave 负责复制数据、Master 故障后可以进行主从切换
这样可以提高缓存服务的可用性。
4. 分布式能力
Memcached 本身不直接提供完整的分布式能力,通常需要客户端通过一致性哈希等方式实现分布式访问。
Redis 有更成熟的分布式方案,例如:Redis Cluster、Codis、Twemproxy
所以 Redis 在实际工程中更常作为分布式缓存方案使用。
2.4.2 如何使用 Redis 缓存
Redis 缓存的典型使用流程是:
1 | 1. 先查 Redis |
流程可以理解为:
1 | 请求 → Redis |
为什么要设置过期时间?
如果不设置过期时间,数据会一直留在 Redis 中,可能导致:
1 | Redis 内存被大量历史数据占满 |
设置过期时间的作用是:
1 | 控制 Redis 内存占用 |
但引入 Redis 后,还需要重点处理三个问题:
1 | 缓存穿透 |
2.4.3 缓存穿透
定义:请求的数据在缓存和数据库中都不存在(如请求 ID 为 -1 的数据)。导致每次请求都绕过缓存直接冲击数据库。
解决方案一: 缓存空数据
查询返回的数据为空,把这个空结果进行缓存
优点:简单
缺点:消耗内存,可能会发生不一致的问题
解决方案二:布隆过滤器
布隆过滤器是啥?
- 布隆过滤器是**用于快速判断‘一个元素是否在一个集合中’**的一种数据结构,底层由
一个二进制位数组+多个哈希函数构成。
布隆过滤器的插入和查询流程?
添加数据时:用多个哈希函数计算多个位置,把这些位置设置为 1
查询数据时: 再次计算多个位置,如果有任意一个位置是 0,说明一定不存在。如果所有位置都是 1,说明可能存在

布隆过滤器优缺点?
优点:
- 空间效率极高:用位数组存储,1 个元素仅占用 k 个 bit(而非字节)
- 查询速度快:插入和查询均为 O (k) 时间复杂度(k 为哈希函数个数,通常 5-10 个),与数据量无关。
- 场景:缓存穿透防御(如 Redis 前加布隆过滤器),判断不存在的 Key 仅需几次哈希 + 位操作,避免查数据库。
缺点:
- 可能误判:可能将 “不存在的元素” 误判为 “存在”,无法 100% 精确。
- 不支持删除操作:一旦元素插入,无法直接删除(删除某元素的位会影响其他元素的判断,如 A 和 B 都映射到位置 3,删除 A 时置 0 位置 3,会导致 B 被误判为不存在)。
- 场景:动态黑名单(如临时封禁 IP),无法从布隆过滤器中移除 “解封的 IP”,需定期重建过滤器。
- 哈希函数依赖强:若哈希函数分布不均,会导致位碰撞增多,误报率飙升。
- 场景:自定义哈希函数时,若仅用简单取模,会导致大量元素映射到少数位,过滤器快速 “饱和”。
2.4.4 缓存雪崩
定义:大量缓存同时过期,或者缓存服务宕机,导致原本由缓存分担的压力全部转移到数据库上。
解决方案:
- 给过期时间加上随机扰动值,防止集体过期。
- 提高 Redis 可用性。避免 Redis 整体故障,导致大量请求直接访问数据库。 所以需要使用更高可用的 Redis 架构,例如:主从复制、Redis Cluster、故障转移、多节点部署
2.4.5 缓存更新
使用 Redis 缓存后,最难的问题之一是:如何保证 Redis 缓存和数据库数据一致?
当数据发生修改时,需要考虑几个问题:
1 | 先更新数据库,还是先更新缓存? |
书中分析了几种方案。
方案 1:先更新缓存,再更新数据库
问题是并发情况下容易不一致。
例如数据 X 原本是 a:
1 | 请求 A 想把 X 改成 1 |
可能出现:
1 | A 更新缓存为 1 |
最终结果:
1 | 缓存中 X = 2 |
缓存和数据库不一致。
而且如果缓存更新成功,但数据库更新失败,也会造成不一致。
所以这个方案不推荐。
方案 2:先更新数据库,再更新缓存
这个方案看起来更合理,但并发时也可能出问题。
例如:
1 | A 更新数据库为 1 |
最终结果:
1 | 数据库中 X = 2 |
仍然不一致,方案二仍然不推荐。
所以直接“更新缓存”不是最优选择。
方案 3:先删除缓存,再更新数据库
这个方案也有并发风险。
例如:
1 | A 删除缓存 |
最终结果:
1 | 数据库中 X = 2 |
仍然不一致。
这个问题的本质是:删除缓存后,数据库还没更新完成,其他读请求可能把旧数据重新写回缓存。
方案 4:先更新数据库,再删除缓存
这是更常用的方案。
原因是:
1 | 数据库更新成功后 |
这样可以让缓存重新生成,减少旧数据长期存在的风险。
但这个方案也要处理一个问题:
1 | 数据库更新成功了 |
如果删除缓存失败,Redis 中可能仍然保留旧数据。
常见补救方式:
- 删除失败后重试
- 把删除缓存任务放入消息队列异步重试
- 监听数据库 binlog,再异步删除缓存
总结:缓存更新最推荐的不是“更新缓存”,而是“删除缓存”(而且是更新数据库后再删除缓存)
让下一次读请求重新从数据库加载最新数据。
本节总结
1 | 分布式缓存解决的是本地缓存无法共享、扩展性差的问题。 |
三个核心风险及解决思路:
- 缓存穿透:查缓存和数据库都不存在的数据,每次请求都绕过缓存打到数据库
- 解决思路:缓存空值、布隆过滤器
- 缓存雪崩:大量缓存同时失效,Redis宕机
- 解决思路:过期时间加随机值、提高 Redis 可用性
- 缓存更新:数据库和缓存之间可能不一致
- 解决思路:优先采用“先更新数据库,再删除缓存”
2.5 高并发读场景总结:CQRS
前面讲的三种高并发读方案:
1 | 2.2 数据库读/写分离 |
本质上都可以归纳为一种思想:读写分离。
CQRS,全称是:
1 | Command Query Responsibility Segregation |
它的核心思想是:把读取操作和更新操作分开处理。
1 | Query:查询操作,只读数据,不修改数据 |
也就是说,系统不一定要用同一套数据模型同时支撑读和写。
写可以用适合写入的数据存储,读可以用适合查询的数据存储。
2.5.1 CQRS 的简要架构与实现
CQRS 的基本架构可以理解为:
1 | 客户端 |
写数据存储和读数据存储之间,通过一个数据传输通道同步数据。
基本流程
1 | 1. 客户端发起 command 请求,也就是写请求 |
不同场景下的具体实现
CQRS 中的几个概念是抽象的:
1 | 写数据存储 |
在不同方案中,它们对应的具体组件不同。
1. 数据库读/写分离场景
1 | 写数据存储:Master 主库 |
也就是写请求进入主库,读请求进入从库。
2. 分布式缓存场景
1 | 写数据存储:数据库 |
也就是数据库负责保存权威数据,Redis 负责支撑高并发读。
CQRS 的设计取舍
CQRS 的核心取舍是:
1 | 写系统优先保证写入能力和数据正确性 |
为什么要这么做?
因为读和写的优化方向不同:
1 | 写:关注事务、一致性、数据完整性 |
如果强行用同一个模型同时满足读和写,系统会越来越复杂,性能也容易受限。
2.5.2 更多的使用场景
1. 搜索场景
很多应用都需要搜索功能,例如根据关键词搜索用户昵称。
如果用户在微博找人模块中输入关键词: 北京
系统可能要返回:北京日报、北京大学、这里是北京
这就是典型的搜索场景。
但用户账号信息通常存储在数据库中,而数据库并不擅长复杂搜索,尤其是模糊搜索、关键词搜索、大规模搜索。
所以可以使用 CQRS:
1 | 写数据存储:数据库,负责管理账号信息 |
Elasticsearch 是基于倒排索引的分布式搜索系统,更适合搜索类读请求。
数据同步方式可以是:
1 | 数据库数据变化 |
这样做的好处是:
1 | 数据库负责可靠写入 |
读写各自使用更适合自己的存储系统。
2. 多表联查询场景
在业务系统中,经常需要查询复杂业务数据。
如果这些数据分散在多张表里,就可能需要 SQL 多表关联查询,也就是 join。
但 join 有几个问题:
- 数据量大时性能较差
- join 底层执行成本高
- 分库分表后,很多 join 语句无法直接执行
所以在复杂查询场景中,也可以使用 CQRS。
核心做法是:提前把需要多表关联的数据聚合好,存成一张宽表。
1 | 写数据存储:数据库 |
流程如下图所示:
1 | 1. 业务服务写入数据库 |
这样查询时就不需要临时 join 多张表,而是直接读取提前计算好的聚合结果。
个人理解:
1 | 普通 join:查询时临时计算 |
它用“写入后的异步计算”换取“查询时的高性能”。
2.5.3 CQRS 架构的特点
CQRS 架构主要有两个特点。
1. 读写存储可以采用不同系统
CQRS 中,写数据存储和读数据存储可以完全不同。
- 写数据存储:选择写性能高、事务能力强的系统
- 读数据存储:选择读性能高、查询能力强的系统
例如:
1 | 数据库读/写分离:Master 写,Slave 读 |
这就是 CQRS 的最大价值:
读和写不再被迫使用同一套数据结构。
2. 读数据存在延迟
CQRS 通常依赖消息队列、binlog 或定时任务同步数据。
因此写数据存储发生变化后,读数据存储不会立刻同步完成。
也就是说:
1 | 写数据存储中是最新数据 |
所以 CQRS 通常只能保证:最终一致性 。不能保证所有时刻都强一致。
本节总结
CQRS = 命令查询职责分离
核心思想: 写请求和读请求分开处理,写数据存储和读数据存储可以不同
典型对应关系:
1 | 数据库读/写分离: |
一句话总结:
1 | CQRS 用读写分离提升高并发读能力, |
2.6 高并发写场景方案 1:数据分片之数据库分库分表
数据分片,就是把待处理的数据或请求拆成多份,并行处理。
生活中的类似场景:
1 | 医院开多个挂号窗口 |
在互联网系统中,高并发写场景也常用数据分片。
本节重点讲的是数据库层面的分片,也就是:数据库分库分表
它的核心目的是把单库、单表的压力拆散到多个库、多个表中,提升系统的写入能力和扩展能力
2.6.1 分库和分表
分库和分表是两个概念。
分库
分库指的是:
把原来存在一个数据库中的数据,拆分存储到多个数据库中。
1 | 原来:一个数据库承载所有数据 |
分库主要解决的是:
1 | 单个数据库并发能力不足 |
分表
分表指的是:
把原来存在一个表中的数据,拆分到多个表中。
1 | 原来:一张大表 |
分表主要解决的是:
1 | 单表数据量过大 |
以 MySQL 为例,书中提到,如果单表数据量超过 2000 万行,表结构 B+ 树层级会增多,读写磁盘 I/O 次数增加,性能压力会明显变大。
分库和分表的区别
1 | 分表:提升单库内单表的处理效率 |
一般来说:
1 | (单表)数据量大 → 考虑分表 |
数据库拆分方式又可以分为两类:
1 | 垂直拆分:更偏业务维度 |
2.6.2 垂直拆分
垂直拆分包括:
1 | 垂直分库 |
它的核心思想是:
按照业务归属、字段访问频率、字段大小等维度,把数据拆开。
垂直分库
垂直分库指的是按照业务归属将单个数据库中的数据表进行分类,将不同业务相关的数据表拆分到不同的数据库中,其核心是“专库专用”。
其实就是把一个库中的数据表按业务拆开到不同库中。

垂直分库的好处
1 | 1. 不同业务数据解耦 |
它适合业务边界比较清晰的系统。
垂直分表
垂直分表是把一张表按照字段拆成多张表。

垂直分表的好处
- 隔离核心字段和非核心字段
- 高频字段所在表更小,更容易加载到内存
- 查询时读取的数据更少,减少磁盘 I/O
- 提高查询命中率和数据库性能
垂直分表的局限
垂直分表主要适合:数据量不大、字段较多、部分字段很大、字段访问频率差异明显
但它不能解决单表“行数过多”的问题。
因为垂直分表拆的是字段,不是行。
2.6.3 水平拆分
水平拆分也包括:水平分库、水平分表
它的核心思想是:按照某种规则,把同一类数据的不同记录拆到不同库或不同表中。
水平分库
水平分库是把同一个数据库中的数据,按照规则拆到多个数据库中。例如用户库可以拆成:

水平分库的作用:利用多台服务器资源、提升数据库并发处理能力、控制每个库中的数据量。
水平分表
水平分表是在同一个数据库中,把一张表的数据按照规则拆成多张结构相同的表。

水平分表可以解决: 单表数据量过大,单条 SQL 执行效率下降
但它有一个限制:拆分后的表仍然在同一个数据库中,仍然竞争同一台服务器的连接数、CPU、内存、网络带宽
所以如果并发压力也很大,仅做水平分表还不够。
水平分库分表
水平分库分表就是把水平分库和水平分表结合起来。先把数据拆到多个库,每个库中再拆成多个表。
这样既能解决单表数据量过大,也能解决单库并发能力不足

选择规则可以简单记成:
1 | 用户并发量很大,数据量较小 → 水平分库 |
2.6.4 水平拆分规则
水平拆分时,必须解决一个问题:一条数据应该放到哪个库、哪个表?
这个决定数据去向的规则,就是**数据路由算法**。
理想情况下,路由算法应该让每个数据分区:数据量接近,读写请求量接近
如果某个分区数据量明显更多,叫:数据偏斜
如果某个分区读写请求量明显更高,叫:数据热点
优秀的拆分规则要**尽量避免数据偏斜和数据热点。 **
1. 范围分区法
范围分区法是按照某个可排序字段的区间拆分数据。
常见字段:数据库唯一 ID、数据创建时间
例如按照创建时间拆分:
1 | 2020.7 ~ 2020.12 → DB1 |
范围分区法的优点
- 支持范围查询比较方便
- 扩容比较简单
- 增加新分区时,通常只需要增加新的数据范围
范围分区法的问题
范围分区是否均匀,强依赖分区字段。
例如:
1 | 使用自增 ID 分区:比较容易均匀 |
所以范围分区容易出现:数据偏斜、数据热点
2. 哈希分区法
哈希分区法是对某个字段计算哈希值,再根据哈希结果决定数据分区。
最简单的方式是取模:
1 | hash(key) % N |
其中 N 是数据分区数量。
例如有 N 个分区:
1 | hash(key) % N = 0 → 分区 0 |
哈希分区法的优点
- 实现简单
- 不依赖字段是否自增
- 使用好的哈希函数,可以让数据分布更均匀
- 能在较大程度上避免数据偏斜和数据热点
哈希分区法的问题
最大问题是扩容不灵活。
1 | 原来:hash(key) % 3 |
N 一变,很多数据的分区结果都会变化,可能导致大量数据需要重新迁移。
所以实际中可以把哈希值再做范围划分:
1 | 先计算 hash 值 |
这样比单纯取模更方便扩容。
3. 一致性哈希分区法
一致性哈希的核心结构是:哈希环
如图所示,数值 0~2^32^-1 作为 2^32^ 个节点依次排列在哈希环上并首尾相连。

每条数据先计算哈希值,所得到的哈希值与 2^32^ 取模后被映射到哈希环的某个节点。然后映射到哈希环上的某个位置,再沿顺时针方向找到第一个数据分区节点,这个节点就负责存储该数据。
注:一致性哈希对固定哈希空间取模,不是对机器数量取模。但如果哈希函数本身就输出 32 位或 64 位整数,如果哈希函数输出范围就是哈希空间,就不用“取模”。
一致性哈希的优点
一致性哈希最大的优点是:
当增加或删除一个数据分区节点时,只需要迁移相邻范围内的数据。这比普通取模哈希更适合扩容和缩容。
一致性哈希的问题
如果数据分区数量较少,节点在哈希环上可能分布不均匀,导致:某些分区数据很多,某些分区数据很少,也就是数据偏斜。
解决方式是引入:虚拟节点
一个真实分区对应多个虚拟节点。
虚拟节点越多,哈希环上的节点越多,数据就越容易均匀分布。
2.6.5 扩容方案
当某个分库承载的数据量或请求量明显高于其他分库,或者现有分库分表结构已经接近饱和,就需要扩容。
比较推荐的是平滑扩容方案:从库升级法。
它用更复杂的扩容流程,换取线上系统的平滑迁移。
它的目标是:
1 | 尽量不中断服务 |
单个分库扩容
如图所示,假设某数据库被拆分为3个库和6个表,此时分库DB0的数据量和资源压力过大。

拆分完成后, DB0 的压力就被分摊到两个库中

核心步骤:
下面步骤看着复杂,实际上就是:先让 DB0 停止接收写请求,待其从库与完全同步后,将 DB0 的数据范围设为前半段,其从库升为主库然后数据范围设为后半段。最后再用离线范围把冗余数据删除。
1 | 1. 给 DB0 增加 Slave 节点,开始主从复制(一般不用单独做,因为常见的分库分表方案中,本来就会给分库用主从架构来保证分库的高可用) |
翻倍扩容法
如果不是单个分库压力大,而是整个数据库集群都需要扩容,可以对每个分库都执行类似操作。 基本流程和单个分库扩容类似。
2.6.6 其他数据分片形式
1. Kafka 多 Partition
在 Kafka 中:
1 | Topic:逻辑上的消息队列 |
一个 Topic 可以拆成多个 Partition。
每个 Partition 对应一个独立日志文件,可以分布在不同服务器上。
生产者向 Topic 写消息时,实际上是在并行写入多个 Partition。
所以:
1 | Partition 数量越多 |
总结:Kafka 多 Partition 本质也是数据分片,把一个 Topic 的写入压力拆到多个 Partition 上。
2. 秒杀系统分布式锁
秒杀系统中,常用分布式锁保证商品不会超卖。
但如果一个热门商品只有一把锁,大量秒杀请求都会竞争同一把锁,请求会被串行化,性能很差。
假设一次分布式锁操作耗时 20ms,那么 1 秒最多只能处理 1000ms / 20ms = 50 个请求。对热门秒杀商品来说,这远远不够。
优化思路:库存分片 + 锁分片
库存分片 + 锁分片
把商品库存拆成 N 份,每份库存用一把独立的分布式锁保护。
例如有 1000 台 iPhone:
1 | 拆成 20 个库存分段 |
同时创建 20 把分布式锁:
1 | iphone-lock-0 |
秒杀请求到来时,根据用户 ID 取模 userId % 20 = i,然后请求只竞争:
1 | iphone-lock-i |
这样 20 把锁可以并行工作。
吞吐量从:
1 | 50 个请求 / 秒 |
提升到:
1 | 50 × 20 = 1000 个请求 / 秒 |
本质上,这是把一个热点库存拆成多个库存分片,降低锁竞争。
3. ConcurrentHashMap
ConcurrentHashMap 也是数据分片思想的体现。
普通 HashMap 如果整体加锁,线程安全是保证了,但所有读写都会竞争同一把锁,性能很差。
ConcurrentHashMap 的做法是:不锁整个 HashMap,而是把内部数据拆成多个槽,每个槽单独加锁。这样不同线程访问不同槽时,可以并发读写。
它的核心收益:
1 | 减少线程竞争 |
总结:ConcurrentHashMap 的分段锁思想,本质也是把一个大竞争点拆成多个小竞争点。
本节总结
数据库分库分表是高并发写场景中常见的数据分片方案。
核心目的:
1 | 分表:解决单表数据量过大 |
拆分方式:
1 | 垂直拆分:按业务或字段拆 |
水平拆分常见路由算法:
1 | 范围分区:方便范围查询和扩容,但容易热点 |
扩容方案:
1 | 先让 DB0 停止接收写请求,待其从库与完全同步后,将 DB0 的数据范围设为前半段,其从库升为主库然后数据范围设为后半段。最后再用离线范围把冗余数据删除。 |
2.7 高并发写场景方案 2:异步与写聚合
前面的 2.6 数据分片 是把写压力拆散;
本节的 异步与写聚合 则是从另一个角度优化写请求:
1 | 异步:不让用户一直等真正写完 |
它们的共同目标是:减少写请求对系统的瞬时冲击,提高后端系统整体吞吐量
2.7.1 异步写
异步写是一个比较泛化的概念,不限于某一种具体实现。
它的核心思想是把写请求的交互流程从:
1 | 用户发起写请求 → 系统同步处理完成 → 返回结果 |
改成:
1 | 用户提交写请求 → 系统先快速接收并返回 → 后台异步真正执行写操作 |
一般来说,异步写有几个特点:
1 | 1. 先把用户写请求快速保存到一个数据池中 |
这里的“数据池”可以是消息队列、Redis、数据库临时表等。
异步写适合的场景是:
1 | 写请求量很大 |
它的本质是:
削峰填谷。请求高峰时先排队,后端按照自己的处理能力慢慢消费。
1. 跨公网调用
跨公网调用是异步写的典型场景。
例如电商系统接入微信支付、支付宝等第三方支付平台。
这些支付平台不属于本产品的后台服务,因此需要跨公网调用第三方接口。
跨公网调用的问题是:
1 | 网络耗时不可控 |
如果支付请求都同步调用第三方接口,那么用户请求可能长时间等待,系统吞吐量也会下降。
所以可以使用异步写方案:
1 | 用户支付请求 |
这种方案牺牲了“立即知道最终结果”,换来了更好的系统吞吐量和稳定性。
2. 秒杀系统异步化
秒杀活动中,短时间内会有大量用户同时点击“抢购”。
如果每个请求都直接访问数据库,执行:
1 | 扣减库存 |
数据库会承受巨大的瞬时压力。
所以秒杀系统通常会做异步化:
1 | 用户点击抢购 |
这种设计的价值是:
1 | 前端请求快速返回 |
需要注意:
用户看到的不是“立即成功”,而是“抢购中”,最终结果要通过查询获得。
2.7.2 写聚合
写聚合是指:
把多个写请求聚合成一个写请求,减少写请求数量。
1 | 多个小写请求 → 合并成一个批量写请求 |
它的优点是方案简单、容易理解,很多系统中都有应用。
1. Kafka Producer 批量生产
Kafka 为了提升 Producer 的消息发送性能,提供了 Micro-Batch 机制。
核心组件是:RecordAccumulator
它的作用是:
1 | 先把 Producer 要发送的消息暂存在内存中 |
这样做可以减少网络请求次数,提高消息发送吞吐量。
总结:
1 | 不聚合:来一条消息发一次 |
代价是:
消息可能会在内存里短暂停留一会儿,所以会牺牲一点点实时性,换取更高吞吐量。
2. AliSQL 热点数据优化
数据库中处理热点数据更新时,经常会遇到行锁竞争问题。
例如很多请求同时更新同一行数据:
1 | update table set count = count + 1 where id = ? |
这些请求都会竞争同一把行锁,导致更新性能难以提升。
AliSQL 对这种热点数据更新做了优化:
把同一行的多个更新操作聚合成一个批量更新操作。
1 | 多个更新请求竞争同一行 |
这类优化特别适合“高频更新同一条数据”的场景。
本节总结
1 | 异步写:先接收请求,再后台慢慢处理 |
异步写解决的是:
1 | 写请求高峰太猛 |
典型场景:
1 | 跨公网调用第三方接口 |
写聚合解决的是:
1 | 写请求数量太多 |
典型场景:
1 | Kafka Producer 批量发送 |
一句话总结:
1 | 异步写用“排队”削峰, |
2.8 本章小结
本章围绕通用高并发架构设计展开,核心目标不是单纯提高 QPS,而是同时关注三个方面:
1 | 高性能:系统吞吐量高,响应速度快 |
其中:
1 | 高可用性:反映系统可以可靠服务的时间 |
高并发场景可以先分成两大类:
1 | 高并发读场景 |
不同场景的优化思路不同。
高并发读场景的方案
高并发读主要解决的是:大量读请求同时访问系统时,如何降低数据库压力、提升读取速度。
本章讲了三类主要方案:
1 | 1. 数据库读/写分离 |
数据库读/写分离依赖数据库主从复制机制:
1 | Master:负责写 |
它可以降低主库读压力,但要特别注意:主从延迟
因为数据写入 Master 后,不一定立刻同步到 Slave。
所以读写分离不是简单把所有读都丢给 Slave,还要结合业务场景处理一致性问题。
本地缓存使用服务器本地内存保存热点数据,优点是访问速度快,可以减少网络调用时间。
但本地缓存容量有限,所以需要缓存淘汰策略,例如:
1 | LFU |
同时,为了避免大量请求在缓存未命中时同时打到数据库,可以使用类似 SingleFlight 的机制解决缓存击穿问题。
分布式缓存通常使用 Redis 实现。
它比本地缓存更适合多服务共享缓存数据,但也要处理几个典型问题:
1 | 缓存穿透 |
在缓存更新上,本章推荐的核心思路是:
1 | 先更新数据库,再删除缓存 |
这样后续读请求会重新从数据库加载最新数据到缓存中,从而保证数据库和缓存数据最终一致。
最后,本章用 CQRS 总结高并发读场景的核心思想:
1 | 读/写分离 |
也就是:
1 | 写操作走写模型 |
让写系统专注数据变更,让读系统专注高性能查询。
高并发写场景的方案
高并发写主要解决的是:大量写请求同时进入系统时,如何降低单点压力、提高写入吞吐量。
本章讲了两类主要方案:
1 | 1. 数据分片 |
数据分片的代表方案是:
1 | 数据库分库分表 |
它的核心思想是:
1 | 把写请求分散到多个数据分区 |
这样可以避免所有写请求都集中到一个库或一张表上。
其中:
1 | 分库:解决单库并发能力不足 |
如果写压力和数据量都很大,就需要结合使用分库分表。
异步写的核心思想是:
1 | 先把写请求暂存到临时缓冲区 |
典型临时缓冲区是:
1 | 消息中间件 |
这种方案适合削峰填谷,例如秒杀、跨公网调用第三方接口等场景。
写聚合的核心思想是:
1 | 把多个相关写请求合并成一个批量写请求 |
这样可以减少写请求数量,降低数据库、消息系统或锁竞争压力。