2.1 高并发架构设计的要点

高并发不是单纯“QPS 高”。一个系统能否支撑高并发,要同时看三类能力:

1
2
3
4
高并发系统
├── 高性能:处理请求要快
├── 高可用:服务要稳定、不容易挂
└── 可扩展:流量增长时能继续扩容

这三者是后续所有方案的基础:
读写分离、缓存、CQRS、分库分表、异步处理等,本质都是围绕这三点做架构取舍。


2.1.1 形成高并发系统的必要条件

形成高并发系统主要需要三个条件:

1. 高性能

高性能指系统处理请求的速度足够快。

如果系统响应时间太长,即使没有宕机,用户体验也会很差,所以高并发首先要解决“请求处理得够不够快”。

2. 高可用性

高可用指系统能够长期、稳定、正确地提供服务。

高并发系统不能只在正常情况下跑得快,还要在机器故障、服务异常、流量波动时尽量不中断服务。
也就是说,系统不能经常宕机、崩溃,或者出现大面积不可用。

3. 可扩展性

可扩展性指系统可以通过扩容来支撑不断增长的请求量。

理想情况下,当请求量上升时,可以通过增加机器、增加节点等方式水平扩展系统能力。
如果系统只能依赖单机性能,一旦流量超过单机极限,就很难继续支撑。


2.1.2 高并发系统的衡量指标

高并发系统常见的衡量指标主要有三类:高性能指标、高可用性指标、可扩展性指标。

1. 高性能指标

性能通常用“响应时间”来衡量,也就是从请求发出到系统返回结果所花的时间。

但只看平均响应时间不够,因为平均值会掩盖慢请求。例如大部分请求很快,少量请求特别慢,平均值可能仍然好看,但那些慢请求对应的用户体验很差。

所以实际更常看 PCT 指标

PCT 指标

PCT 可以理解为“百分位响应时间”。

例如 PCT99 = 800ms 表示:在所有请求中,99% 的请求响应时间都在 800ms 以内。

其他常见指标:

1
2
PCT50:50% 请求的响应时间
PCT999:99.9% 请求的响应时间

个人理解:

1
2
平均响应时间:看整体大概水平
PCT99 / PCT999:看长尾请求是否过慢

**高并发系统不能只追求平均值好看,还要控制长尾延迟。**因为少量比例的慢请求在大流量下也会影响大量用户。


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
2
3 个 9:99.9%
4 个 9:99.99%

很多互联网系统会要求 3 个 9 或 4 个 9。
可用性越高,系统设计越复杂,需要监控、告警、限流、降级、故障转移、容灾等能力配合。


3. 可扩展性指标

可扩展性主要看系统面对流量增长时,能不能通过增加机器来提升整体处理能力。

理想情况:

1
增加 N 台机器,系统能力提升 N 倍

但实际中通常做不到线性增长,因为节点变多后会带来额外成本,例如:数据同步、负载均衡、网络通信、一致性处理。

可扩展性 = 吞吐量提升比例 / 集群节点增加比例

所以可扩展性不是简单“加机器”,而是系统架构本身要支持横向扩展。

书中提到,一般来说,系统扩展性达到 70%~80%,基本就可以认为能够满足可扩展性要求。


2.1.3 高并发场景分类

计算机系统中的业务操作最终主要体现在两类数据操作上:读 和 写。所以高并发场景也可以围绕“读”和“写”来划分。

1. 高并发读场景

高并发读场景的核心问题是:大量请求同时读取数据,数据库容易成为瓶颈。

常见优化方向包括:读写分离、本地缓存、分布式缓存

这些方案的目标都是减少数据库直接承受的读压力,提高查询响应速度。

对应后续章节:

1
2
3
2.2 数据库读写分离
2.3 本地缓存
2.4 分布式缓存

2. 高并发写场景

高并发写场景的核心问题是:大量请求同时修改数据,系统既要扛住写入压力,又要处理数据一致性问题。

写请求比读请求更复杂,因为写操作通常涉及:数据变更、事务、锁竞争、热点数据、一致性、扩容后的数据分布

对应后续章节:

1
2
2.6 数据分片
2.7 异步处理

3. CQRS 作为读写场景的总结

CQRS 是一种把“读”和“写”职责分离的架构思想。

  • Command:写操作,负责修改数据
  • Query:读操作,负责查询数据

它可以把读模型和写模型拆开,让读、写分别用更适合自己的方式优化。

对应后续章节:

1
2.5 CQRS

本节总结

1
2
3
4
5
高并发系统不能只看 QPS。
真正要看三件事:
1. 高性能:请求处理得快不快
2. 高可用:系统稳不稳定
3. 可扩展:流量增长后能不能扩容

性能指标不要只看平均响应时间,要重点关注:

1
2
PCT99
PCT999

因为它们能反映长尾请求问题。

高并发场景可以先按读写拆分:

1
2
3
读高并发:读写分离、本地缓存、分布式缓存
写高并发:数据分片、异步处理
读写分离思想总结:CQRS

2.2 高并发读场景方案 1:数据库读/写分离

大部分互联网应用都是读多写少,例如浏览帖子、浏览商品的请求通常远多于发帖、下单请求。

因此数据库的高并发压力,很多时候主要来自读请求
数据库读/写分离的核心思路是:

1
2
3
写请求 → 写库
读请求 → 读库
写库把最新数据同步给读库

这样可以把大量读请求从主数据库中分离出去,降低数据库访问压力,并缩短请求响应时间。


2.2.1 读/写分离架构

读/写分离通常依赖数据库的主从复制实现。

1
2
Master:主库,负责写请求
Slave:从库,负责读请求

常见结构是:

1
2
3
4
5
6
7
8
9
          写请求
业务服务 ─────→ Master

│ 主从复制

Slave / Slave / Slave


读请求

一个 Master 可以连接多个 Slave。
写请求先落到 Master,再由 Master 把数据复制到 Slave;读请求则尽量从 Slave 查询。

这样做的核心收益是:主库专注处理写、从库分担大量读、整体数据库读压力下降

但它也带来一个新问题:主从复制不是瞬间完成的,所以可能出现主从延迟。


2.2.2 读/写请求路由方式

读/写分离后,系统必须判断: 这个请求应该去 Master,还是去 Slave?

也就是要做读/写请求路由,有两种较为常见的方式:


1. 基于数据库 Proxy 代理的方式

这种方式是在业务服务和数据库之间增加一个数据库代理层

1
业务服务 → Proxy → Master / Slave

Proxy 负责识别 SQL 类型:

1
2
insert / delete / update → Master
select → Slave

例如:

1
2
写操作:insert、delete、update
读操作:select

常见实现包括:

1
2
3
MySQL-Proxy
MyCat
MySQL-Router

这种方式的特点是:
业务服务不直接关心读写分离逻辑,而是把请求交给 Proxy,由 Proxy 统一转发。

  • 优点:对业务代码侵入较小,读写路由集中管理
  • 缺点:多了一层 Proxy,Proxy 本身也要考虑性能和可用性

2. 基于应用内嵌的方式

这种方式不单独引入 Proxy,而是在业务服务进程内部完成读写路由。

1
2
3
业务服务内部判断读写请求
写请求 → Master
读请求 → Slave

常见框架或工具包括:gorm、shardingjdbc

  • 优点:少一层 Proxy,链路更短
  • 缺点:读写分离逻辑会进入应用侧,业务系统需要接入相关框架

2.2.3 主从延迟与解决方案

数据库读/写分离依赖主从复制,但主从复制存在一个典型问题:主从延迟

也就是:

1
2
3
数据已经写入 Master
但还没来得及同步到 Slave
此时读 Slave 可能读不到最新数据

这会导致短时间内:

1
2
Master 有新数据
Slave 还是旧数据

针对主从延迟,有三种常见解决方案。


1. 同步数据复制

默认情况下,很多数据库主从复制是异步模式:Master 写入成功后立即返回,不等待 Slave 是否收到数据

同步数据复制的思路是:Master 写完数据后,必须等所有 Slave 都收到数据,再返回写入成功

这样可以保证:只要写请求返回成功,Master 和 Slave 都能读到最新数据

但代价也很明显:写请求响应时间变长、数据库吞吐量下降

所以这种方案虽然一致性强,但实用价值较低,一般只适合低并发场景。


2. 强制读主

不同业务对主从延迟的容忍度不同。

例如:用户刚发布一条状态,他自己刷新主页时,应该立刻看到这条状态。

这种场景对延迟不太能容忍,应该强制读 Master。

但如果是:好友浏览这个用户主页,暂时看不到刚发布的状态

这种场景通常可以容忍短暂延迟,可以继续读 Slave。

所以可以按业务场景区分:

1
2
能容忍延迟 → 正常读 Slave
不能容忍延迟 → 强制读 Master

注意:强制读主不是所有读都读主,而是关键场景读主,否则 Master 压力会重新变大


3. 会话分离

会话分离可以理解为:
如果某个用户会话刚刚执行过写操作,那么在接下来很短的一段时间内,该会话的读请求暂时走 Master。

例如:

1
2
3
用户 A 发布状态
接下来几秒内,用户 A 自己的读请求走 Master
几秒后,再恢复读 Slave

这个时间一般设置为略高于主从复制延迟。

这样做的目的:

1
2
保证用户自己的写操作能立刻对自己可见
同时避免所有读请求都打到 Master

总结:

1
2
3
4
强制读主:按业务场景判断
会话分离:按用户会话判断

会话分离更细粒度,通常比全局强制读主更节省 Master 压力

本节总结

数据库读/写分离解决的是高并发读场景下的数据库读压力问题。

核心结构:

1
2
3
Master 负责写
Slave 负责读
Master 把数据复制给 Slave

读写请求路由有两种方式:

  1. Proxy 代理路由
  2. 应用内嵌路由

读/写分离最大的副作用是:主从延迟

主从延迟的三种解决方案:

  1. 同步数据复制:一致性强,但写性能差
  2. 强制读主:关键场景直接读 Master
  3. 会话分离:用户写完后,短时间内自己的读请求走 Master

2.3 高并发读场景方案 2:本地缓存

缓存的核心作用是:把访问频繁的数据放到更快的位置,减少对慢存储的访问。

在高并发读场景中,如果每次请求都访问数据库,数据库很容易成为瓶颈。
本地缓存就是把热点数据缓存在应用进程内存中:

1
2
3
请求 → 应用本地缓存
命中:直接返回
未命中:查数据库/远程存储,再写入本地缓存

本地缓存的优点:访问速度快、不需要网络请求、实现相对简单

本地缓存的缺点:受单机内存限制、多台机器之间缓存不共享、缓存数据可能和数据库不一致

所以本地缓存适合缓存那些:访问频繁、数据量不大、允许短暂不一致、变化不太频繁的数据。


2.3.1 基本的缓存淘汰策略

本地缓存空间有限,不可能无限存数据。
当缓存满了,就需要决定:

1
2
哪些数据留下?
哪些数据淘汰?

这就是缓存淘汰策略。

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 将缓存的内存空间划分为两部分,如图所示:

image-20260524193304769

(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 近似算法来保存每条缓存数据的访问频率,如图所示:

image-20260524193733955

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
2
3
缓存中没有这个 key
100 个请求同时去查数据库
数据库瞬间承受 100 次查询

但实际上,这 100 个请求查的是同一份数据。
理想情况应该是:

1
2
3
只有 1 个请求真正去查数据库
其他 99 个请求等待它的结果
查询完成后,大家共用同一份结果

这就是 SingleFlight 的作用。


SingleFlight 的基本流程

1
2
3
4
1.多个请求访问同一个 key
2.第一个请求执行真正的数据加载逻辑
3.其他请求阻塞等待
4.第一个请求完成后,把结果返回给所有等待请求

这样可以避免缓存未命中时,大量相同请求同时打到数据库。


SingleFlight 的实现思想

它内部大致维护一个 map:

1
key → 正在执行的调用

当请求进来时:

1
2
3
4
5
1. 如果这个 key 已经有请求在执行
当前请求等待已有请求的结果

2. 如果这个 key 没有请求在执行
当前请求成为第一个请求,真正执行查询逻辑

底层会用类似 WaitGroup 的机制,让后续请求等待第一个请求完成。


SingleFlight 的价值

SingleFlight 主要用于防止:缓存击穿、热点 key 并发回源、数据库瞬时压力过大

它不是为了提高单个请求速度,而是为了减少重复查询,保护后端存储。

总结:缓存解决“不要频繁查数据库”,SingleFlight 解决“缓存没命中时,不要所有请求一起查数据库”


本节总结

1
2
本地缓存适合高并发读场景,
核心作用是减少数据库或远程存储访问。

常见缓存淘汰策略:

1
2
3
4
FIFO:先进先出,简单但粗糙
LFU:淘汰访问次数少的数据,但容易受历史热点影响
LRU:淘汰最近最少使用的数据,但可能被一次性访问污染
W-TinyLFU:结合访问频率和最近访问情况,效果更好

SingleFlight 的核心作用:

1
2
3
同一个 key 并发缓存未命中时,
只让一个请求真正回源,
其他请求等待并复用结果。

一句话总结:本地缓存提升读性能,缓存淘汰策略决定缓存命中率,SingleFlight 用来防止缓存未命中时的并发击穿。

2.4 高并发读场景方案 3:分布式缓存

本地缓存虽然访问速度快,但有几个明显问题:

1
2
3
1. 无法共享:不同服务进程之间不能共享同一份缓存
2. 语言绑定:本地缓存通常和具体编程语言绑定
3. 可扩展性差:缓存分散在各个服务进程内,容量和管理都受限制

所以在更大的系统中,通常会使用分布式缓存

分布式缓存的特点是:

  • 缓存独立部署
  • 多个服务都可以访问同一份缓存
  • 和具体编程语言无关
  • 更容易扩容
  • 部分组件支持数据持久化

常见代表就是 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
2
3
4
5
1. 先查 Redis
2. Redis 命中,直接返回数据
3. Redis 未命中,再查数据库
4. 从数据库查到数据后,写入 Redis
5. 给 Redis 数据设置过期时间

流程可以理解为:

1
2
3
请求 → Redis
命中:直接返回
未命中:查数据库 → 写入 Redis 并设置过期时间 → 返回

为什么要设置过期时间?

如果不设置过期时间,数据会一直留在 Redis 中,可能导致:

1
2
3
Redis 内存被大量历史数据占满
缓存中保留很多低价值数据
数据库数据更新后,Redis 中仍然是旧数据

设置过期时间的作用是:

1
2
3
控制 Redis 内存占用
让冷数据自动淘汰
让缓存和数据库最终有机会重新同步

但引入 Redis 后,还需要重点处理三个问题:

1
2
3
缓存穿透
缓存雪崩
缓存更新

2.4.3 缓存穿透

定义:请求的数据在缓存和数据库中都不存在(如请求 ID 为 -1 的数据)。导致每次请求都绕过缓存直接冲击数据库。

解决方案一: 缓存空数据

查询返回的数据为空,把这个空结果进行缓存

优点:简单
缺点:消耗内存,可能会发生不一致的问题

解决方案二:布隆过滤器

布隆过滤器是啥?

  • 布隆过滤器是**用于快速判断‘一个元素是否在一个集合中’**的一种数据结构,底层由一个二进制位数组+多个哈希函数构成。

布隆过滤器的插入和查询流程?

添加数据时:用多个哈希函数计算多个位置,把这些位置设置为 1

查询数据时: 再次计算多个位置,如果有任意一个位置是 0,说明一定不存在。如果所有位置都是 1,说明可能存在

image-20260524210656292

布隆过滤器优缺点?

优点:

  1. 空间效率极高:用位数组存储,1 个元素仅占用 k 个 bit(而非字节)
  2. 查询速度快:插入和查询均为 O (k) 时间复杂度(k 为哈希函数个数,通常 5-10 个),与数据量无关。
    • 场景:缓存穿透防御(如 Redis 前加布隆过滤器),判断不存在的 Key 仅需几次哈希 + 位操作,避免查数据库。

缺点:

  1. 可能误判:可能将 “不存在的元素” 误判为 “存在”,无法 100% 精确。
  2. 不支持删除操作:一旦元素插入,无法直接删除(删除某元素的位会影响其他元素的判断,如 A 和 B 都映射到位置 3,删除 A 时置 0 位置 3,会导致 B 被误判为不存在)。
    • 场景:动态黑名单(如临时封禁 IP),无法从布隆过滤器中移除 “解封的 IP”,需定期重建过滤器
  3. 哈希函数依赖强:若哈希函数分布不均,会导致位碰撞增多,误报率飙升。
    • 场景:自定义哈希函数时,若仅用简单取模,会导致大量元素映射到少数位,过滤器快速 “饱和”。

2.4.4 缓存雪崩

定义大量缓存同时过期,或者缓存服务宕机,导致原本由缓存分担的压力全部转移到数据库上。

解决方案

  1. 过期时间加上随机扰动值,防止集体过期。
  2. 提高 Redis 可用性。避免 Redis 整体故障,导致大量请求直接访问数据库。 所以需要使用更高可用的 Redis 架构,例如:主从复制、Redis Cluster、故障转移、多节点部署

2.4.5 缓存更新

使用 Redis 缓存后,最难的问题之一是:如何保证 Redis 缓存和数据库数据一致?

当数据发生修改时,需要考虑几个问题:

1
2
3
4
先更新数据库,还是先更新缓存?
修改缓存,还是删除缓存?
并发更新时如何保证一致?
更新失败时如何处理?

书中分析了几种方案。


方案 1:先更新缓存,再更新数据库

问题是并发情况下容易不一致。

例如数据 X 原本是 a:

1
2
请求 A 想把 X 改成 1
请求 B 想把 X 改成 2

可能出现:

1
2
3
4
A 更新缓存为 1
B 更新缓存为 2
B 更新数据库为 2
A 更新数据库为 1

最终结果:

1
2
缓存中 X = 2
数据库中 X = 1

缓存和数据库不一致。

而且如果缓存更新成功,但数据库更新失败,也会造成不一致。
所以这个方案不推荐


方案 2:先更新数据库,再更新缓存

这个方案看起来更合理,但并发时也可能出问题。

例如:

1
2
3
4
A 更新数据库为 1
B 更新数据库为 2
B 更新缓存为 2
A 更新缓存为 1

最终结果:

1
2
数据库中 X = 2
缓存中 X = 1

仍然不一致,方案二仍然不推荐。

所以直接“更新缓存”不是最优选择。


方案 3:先删除缓存,再更新数据库

这个方案也有并发风险。

例如:

1
2
3
4
5
A 删除缓存
B 读取缓存,发现未命中
B 读取数据库中的旧值 1
B 把旧值 1 写回缓存
A 更新数据库为 2

最终结果:

1
2
数据库中 X = 2
缓存中 X = 1

仍然不一致。

这个问题的本质是:删除缓存后,数据库还没更新完成,其他读请求可能把旧数据重新写回缓存。


方案 4:先更新数据库,再删除缓存

这是更常用的方案。

原因是:

1
2
3
4
5
数据库更新成功后
删除缓存
后续读请求缓存未命中
重新从数据库读取最新数据
再写入 Redis

这样可以让缓存重新生成,减少旧数据长期存在的风险。

但这个方案也要处理一个问题:

1
2
数据库更新成功了
删除缓存失败了

如果删除缓存失败,Redis 中可能仍然保留旧数据。

常见补救方式:

  • 删除失败后重试
  • 把删除缓存任务放入消息队列异步重试
  • 监听数据库 binlog,再异步删除缓存

总结:缓存更新最推荐的不是“更新缓存”,而是“删除缓存”(而且是更新数据库后再删除缓存)
让下一次读请求重新从数据库加载最新数据。


本节总结

1
2
分布式缓存解决的是本地缓存无法共享、扩展性差的问题。
Redis 是最常见的分布式缓存方案。

三个核心风险及解决思路:

  1. 缓存穿透:查缓存和数据库都不存在的数据,每次请求都绕过缓存打到数据库
    • 解决思路:缓存空值、布隆过滤器
  2. 缓存雪崩:大量缓存同时失效,Redis宕机
    • 解决思路:过期时间加随机值、提高 Redis 可用性
  3. 缓存更新:数据库和缓存之间可能不一致
    • 解决思路:优先采用“先更新数据库,再删除缓存”

2.5 高并发读场景总结:CQRS

前面讲的三种高并发读方案:

1
2
3
2.2 数据库读/写分离
2.3 本地缓存
2.4 分布式缓存

本质上都可以归纳为一种思想:读写分离

CQRS,全称是:

1
2
Command Query Responsibility Segregation
命令查询职责分离

它的核心思想是:把读取操作和更新操作分开处理

1
2
Query:查询操作,只读数据,不修改数据
Command:命令操作,会引起数据变化,例如新增、删除、修改

也就是说,系统不一定要用同一套数据模型同时支撑读和写。
写可以用适合写入的数据存储,读可以用适合查询的数据存储。


2.5.1 CQRS 的简要架构与实现

CQRS 的基本架构可以理解为:

1
2
3
客户端
├── command 请求 → 业务服务 → 写数据存储
└── query 请求 → 业务服务 → 读数据存储

写数据存储和读数据存储之间,通过一个数据传输通道同步数据。

基本流程

1
2
3
4
5
1. 客户端发起 command 请求,也就是写请求
2. 业务服务把写请求交给写数据存储处理
3. 写数据存储完成数据变更后,把变更消息发送到数据传输通道
4. 读数据存储监听数据变更消息,并把数据写入自身
5. 客户端发起 query 请求时,业务服务从读数据存储读取数据并返回

不同场景下的具体实现

CQRS 中的几个概念是抽象的:

1
2
3
写数据存储
读数据存储
数据传输通道

在不同方案中,它们对应的具体组件不同。

1. 数据库读/写分离场景

1
2
3
写数据存储:Master 主库
读数据存储:Slave 从库
数据传输通道:数据库主从复制

也就是写请求进入主库,读请求进入从库。

2. 分布式缓存场景

1
2
3
写数据存储:数据库
读数据存储:Redis 缓存
数据传输通道:消息中间件,或者监听数据库 binlog

也就是数据库负责保存权威数据,Redis 负责支撑高并发读。


CQRS 的设计取舍

CQRS 的核心取舍是:

1
2
写系统优先保证写入能力和数据正确性
读系统优先保证查询性能和高并发读取能力

为什么要这么做?

因为读和写的优化方向不同:

1
2
写:关注事务、一致性、数据完整性
读:关注查询速度、并发能力、数据组织形式

如果强行用同一个模型同时满足读和写,系统会越来越复杂,性能也容易受限。


2.5.2 更多的使用场景

1. 搜索场景

很多应用都需要搜索功能,例如根据关键词搜索用户昵称。

如果用户在微博找人模块中输入关键词: 北京
系统可能要返回:北京日报、北京大学、这里是北京

这就是典型的搜索场景。

但用户账号信息通常存储在数据库中,而数据库并不擅长复杂搜索,尤其是模糊搜索、关键词搜索、大规模搜索。

所以可以使用 CQRS:

1
2
写数据存储:数据库,负责管理账号信息
读数据存储:Elasticsearch,负责搜索查询

Elasticsearch 是基于倒排索引的分布式搜索系统,更适合搜索类读请求。

数据同步方式可以是:

1
2
3
数据库数据变化
→ 通过消息中间件或 binlog 感知变更
→ 将最新用户信息同步到 Elasticsearch

这样做的好处是:

1
2
数据库负责可靠写入
Elasticsearch 负责高效搜索

读写各自使用更适合自己的存储系统。


2. 多表联查询场景

在业务系统中,经常需要查询复杂业务数据。
如果这些数据分散在多张表里,就可能需要 SQL 多表关联查询,也就是 join。

但 join 有几个问题:

  1. 数据量大时性能较差
  2. join 底层执行成本高
  3. 分库分表后,很多 join 语句无法直接执行

所以在复杂查询场景中,也可以使用 CQRS。

核心做法是:提前把需要多表关联的数据聚合好,存成一张宽表。

1
2
3
写数据存储:数据库
读数据存储:宽表
worker:负责数据聚合计算

流程如下图所示:

image-20260524230618836

1
2
3
4
5
6
1. 业务服务写入数据库
2. 数据库产生 binlog
3. 消息队列接收数据变更
4. worker 消费变更消息,执行数据聚合计算
5. worker 把聚合结果写入宽表
6. 查询请求直接读取宽表

这样查询时就不需要临时 join 多张表,而是直接读取提前计算好的聚合结果。

个人理解:

1
2
普通 join:查询时临时计算
CQRS 宽表:写入后提前计算,查询时直接读结果

它用“写入后的异步计算”换取“查询时的高性能”。


2.5.3 CQRS 架构的特点

CQRS 架构主要有两个特点。

1. 读写存储可以采用不同系统

CQRS 中,写数据存储和读数据存储可以完全不同。

  • 写数据存储:选择写性能高、事务能力强的系统
  • 读数据存储:选择读性能高、查询能力强的系统

例如:

1
2
3
4
数据库读/写分离:Master 写,Slave 读  
分布式缓存:数据库写,Redis 读
搜索场景:数据库写,Elasticsearch 读
复杂查询场景:数据库写,宽表读

这就是 CQRS 的最大价值:
读和写不再被迫使用同一套数据结构。


2. 读数据存在延迟

CQRS 通常依赖消息队列、binlog 或定时任务同步数据。

因此写数据存储发生变化后,读数据存储不会立刻同步完成。

也就是说:

1
2
写数据存储中是最新数据
读数据存储中可能暂时还是旧数据

所以 CQRS 通常只能保证:最终一致性 。不能保证所有时刻都强一致。


本节总结

CQRS = 命令查询职责分离

核心思想: 写请求和读请求分开处理,写数据存储和读数据存储可以不同

典型对应关系:

1
2
3
4
5
6
7
8
9
10
11
数据库读/写分离:
Master 写,Slave 读

分布式缓存:
数据库写,Redis 读

搜索场景:
数据库写,Elasticsearch 读

多表联查场景:
数据库写,宽表读

一句话总结:

1
2
3
CQRS 用读写分离提升高并发读能力,
本质是让写系统专注写入,让读系统专注查询。
代价是读数据通常会有同步延迟,只能保证最终一致性。

2.6 高并发写场景方案 1:数据分片之数据库分库分表

数据分片,就是把待处理的数据或请求拆成多份,并行处理。

生活中的类似场景:

1
2
医院开多个挂号窗口
火车站 / 地铁站设置多个闸机口

在互联网系统中,高并发写场景也常用数据分片。
本节重点讲的是数据库层面的分片,也就是:数据库分库分表

它的核心目的是把单库、单表的压力拆散到多个库、多个表中,提升系统的写入能力和扩展能力


2.6.1 分库和分表

分库和分表是两个概念。

分库

分库指的是:
把原来存在一个数据库中的数据,拆分存储到多个数据库中。

1
2
原来:一个数据库承载所有数据
现在:多个数据库共同承载数据

分库主要解决的是:

1
2
单个数据库并发能力不足
单机 CPU、内存、连接数、网络带宽成为瓶颈

分表

分表指的是:
把原来存在一个表中的数据,拆分到多个表中。

1
2
原来:一张大表
现在:多张小表

分表主要解决的是:

1
2
3
4
5
单表数据量过大
SQL 扫描数据行过多
索引层级变深
磁盘 I/O 增加
DDL 操作耗时过长

以 MySQL 为例,书中提到,如果单表数据量超过 2000 万行,表结构 B+ 树层级会增多,读写磁盘 I/O 次数增加,性能压力会明显变大。

分库和分表的区别

1
2
分表:提升单库内单表的处理效率
分库:利用多台服务器资源,提高整体并发处理能力

一般来说:

1
2
3
(单表)数据量大 → 考虑分表
并发量大 → 考虑分库
数据量和并发量都大 → 分库分表结合

数据库拆分方式又可以分为两类:

1
2
垂直拆分:更偏业务维度
水平拆分:更偏数据维度

2.6.2 垂直拆分

垂直拆分包括:

1
2
垂直分库
垂直分表

它的核心思想是:
按照业务归属、字段访问频率、字段大小等维度,把数据拆开。


垂直分库

垂直分库指的是按照业务归属将单个数据库中的数据表进行分类,将不同业务相关的数据表拆分到不同的数据库中,其核心是“专库专用”。

其实就是把一个库中的数据表按业务拆开到不同库中。

image-20260525101137226

垂直分库的好处

1
2
3
1. 不同业务数据解耦
2. 不同业务团队可以独立维护自己的数据库
3. 不同业务使用不同服务器,提升数据库整体并发能力

它适合业务边界比较清晰的系统。


垂直分表

垂直分表是把一张表按照字段拆成多张表。

image-20260525101851113

垂直分表的好处

  1. 隔离核心字段和非核心字段
  2. 高频字段所在表更小,更容易加载到内存
  3. 查询时读取的数据更少,减少磁盘 I/O
  4. 提高查询命中率和数据库性能

垂直分表的局限

垂直分表主要适合:数据量不大、字段较多、部分字段很大、字段访问频率差异明显

但它不能解决单表“行数过多”的问题。
因为垂直分表拆的是字段,不是行。


2.6.3 水平拆分

水平拆分也包括:水平分库、水平分表

它的核心思想是:按照某种规则,同一类数据的不同记录拆到不同库或不同表中


水平分库

水平分库是把同一个数据库中的数据,按照规则拆到多个数据库中。例如用户库可以拆成:

image-20260525145553906

水平分库的作用:利用多台服务器资源、提升数据库并发处理能力、控制每个库中的数据量


水平分表

水平分表是在同一个数据库中,把一张表的数据按照规则拆成多张结构相同的表。

image-20260525145642868

水平分表可以解决: 单表数据量过大,单条 SQL 执行效率下降

但它有一个限制:拆分后的表仍然在同一个数据库中,仍然竞争同一台服务器的连接数、CPU、内存、网络带宽

所以如果并发压力也很大,仅做水平分表还不够。


水平分库分表

水平分库分表就是把水平分库和水平分表结合起来。先把数据拆到多个库,每个库中再拆成多个表。

这样既能解决单表数据量过大,也能解决单库并发能力不足

image-20260525170213987

选择规则可以简单记成:

1
2
3
用户并发量很大,数据量较小 → 水平分库
用户并发量较小,数据量很大 → 水平分表
用户并发量很大,数据量也很大 → 水平分库分表

2.6.4 水平拆分规则

水平拆分时,必须解决一个问题:一条数据应该放到哪个库、哪个表?

这个决定数据去向的规则,就是**数据路由算法**。

理想情况下,路由算法应该让每个数据分区:数据量接近,读写请求量接近

如果某个分区数据量明显更多,叫:数据偏斜

如果某个分区读写请求量明显更高,叫:数据热点

优秀的拆分规则要**尽量避免数据偏斜和数据热点。 **


1. 范围分区法

范围分区法是按照某个可排序字段的区间拆分数据。

常见字段:数据库唯一 ID、数据创建时间

例如按照创建时间拆分:

1
2
3
2020.7 ~ 2020.12 → DB1
2021.1 ~ 2021.6 → DB2
2021.7 ~ 2021.12 → DB3

范围分区法的优点

  1. 支持范围查询比较方便
  2. 扩容比较简单
  3. 增加新分区时,通常只需要增加新的数据范围

范围分区法的问题

范围分区是否均匀,强依赖分区字段。

例如:

1
2
3
使用自增 ID 分区:比较容易均匀
使用用户昵称分区:可能不均匀
使用时间分区:最近时间段的数据更容易成为热点

所以范围分区容易出现:数据偏斜、数据热点


2. 哈希分区法

哈希分区法是对某个字段计算哈希值,再根据哈希结果决定数据分区。

最简单的方式是取模:

1
hash(key) % N

其中 N 是数据分区数量。

例如有 N 个分区:

1
2
3
4
hash(key) % N = 0 → 分区 0
hash(key) % N = 1 → 分区 1
...
hash(key) % N = N-1 → 分区 N-1

哈希分区法的优点

  1. 实现简单
  2. 不依赖字段是否自增
  3. 使用好的哈希函数,可以让数据分布更均匀
  4. 能在较大程度上避免数据偏斜和数据热点

哈希分区法的问题

最大问题是扩容不灵活。

1
2
原来:hash(key) % 3
扩容后:hash(key) % 4

N 一变,很多数据的分区结果都会变化,可能导致大量数据需要重新迁移

所以实际中可以把哈希值再做范围划分:

1
2
先计算 hash 值
再把 hash 值落到某个范围区间

这样比单纯取模更方便扩容。


3. 一致性哈希分区法

一致性哈希的核心结构是:哈希环

如图所示,数值 0~2^32^-1 作为 2^32^ 个节点依次排列在哈希环上并首尾相连。

image-20260525161627700

每条数据先计算哈希值,所得到的哈希值与 2^32^ 取模后被映射到哈希环的某个节点。然后映射到哈希环上的某个位置,再沿顺时针方向找到第一个数据分区节点,这个节点就负责存储该数据。

注:一致性哈希对固定哈希空间取模,不是对机器数量取模。但如果哈希函数本身就输出 32 位或 64 位整数,如果哈希函数输出范围就是哈希空间,就不用“取模”。

一致性哈希的优点

一致性哈希最大的优点是:
当增加或删除一个数据分区节点时,只需要迁移相邻范围内的数据。这比普通取模哈希更适合扩容和缩容。

一致性哈希的问题

如果数据分区数量较少,节点在哈希环上可能分布不均匀,导致:某些分区数据很多,某些分区数据很少,也就是数据偏斜。

解决方式是引入:虚拟节点

一个真实分区对应多个虚拟节点。
虚拟节点越多,哈希环上的节点越多,数据就越容易均匀分布。


2.6.5 扩容方案

当某个分库承载的数据量或请求量明显高于其他分库,或者现有分库分表结构已经接近饱和,就需要扩容。

比较推荐的是平滑扩容方案:从库升级法。

它用更复杂的扩容流程,换取线上系统的平滑迁移。

它的目标是:

1
2
3
尽量不中断服务
尽量减少对线上写请求的影响
平滑完成数据范围拆分

单个分库扩容

如图所示,假设某数据库被拆分为3个库和6个表,此时分库DB0的数据量和资源压力过大。

image-20260525165114964

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

image-20260525171519743

核心步骤:

下面步骤看着复杂,实际上就是:先让 DB0 停止接收写请求,待其从库与完全同步后,将 DB0 的数据范围设为前半段,其从库升为主库然后数据范围设为后半段。最后再用离线范围把冗余数据删除。

1
2
3
4
5
6
7
8
1. 给 DB0 增加 Slave 节点,开始主从复制(一般不用单独做,因为常见的分库分表方案中,本来就会给分库用主从架构来保证分库的高可用)
2. 主从复制完成后,临时封禁 DB0 的写请求,避免产生新数据
3. 检查主从数据是否一致。如果一致,则已完成同步,断开主从关系
4. 修改 DB0 的数据范围为 r0 ~ (r0+r1)/2,即DB0负责原来数据范围的前一半
5. 把 DB0 的从库提升为主库,并命名为 DB3
6. 设置 DB3 的数据范围为 (r0+r1)/2 ~ r1,,即DB3负责原DBO数据范围的后一半。
7. 通知上游业务新的分库范围,恢复 DB0 写请求
8. 用离线任务删除 DB0 和 DB3 中范围外的冗余数据(DB0删除后一半,DB3删除前一半)

翻倍扩容法

如果不是单个分库压力大,而是整个数据库集群都需要扩容,可以对每个分库都执行类似操作。 基本流程和单个分库扩容类似。

image-20260525173034694


2.6.6 其他数据分片形式

1. Kafka 多 Partition

在 Kafka 中:

1
2
Topic:逻辑上的消息队列
Partition:Topic 内部的物理分片

一个 Topic 可以拆成多个 Partition。
每个 Partition 对应一个独立日志文件,可以分布在不同服务器上。

生产者向 Topic 写消息时,实际上是在并行写入多个 Partition。

所以:

1
2
Partition 数量越多
Topic 的消息写入吞吐量越高

总结:Kafka 多 Partition 本质也是数据分片,把一个 Topic 的写入压力拆到多个 Partition 上。


2. 秒杀系统分布式锁

秒杀系统中,常用分布式锁保证商品不会超卖。

但如果一个热门商品只有一把锁,大量秒杀请求都会竞争同一把锁,请求会被串行化,性能很差。

假设一次分布式锁操作耗时 20ms,那么 1 秒最多只能处理 1000ms / 20ms = 50 个请求。对热门秒杀商品来说,这远远不够。

优化思路:库存分片 + 锁分片

库存分片 + 锁分片

把商品库存拆成 N 份,每份库存用一把独立的分布式锁保护。

例如有 1000 台 iPhone:

1
2
3
拆成 20 个库存分段
seg-0 ~ seg-19
每段 50 台库存

同时创建 20 把分布式锁:

1
2
3
4
iphone-lock-0
iphone-lock-1
...
iphone-lock-19

秒杀请求到来时,根据用户 ID 取模 userId % 20 = i,然后请求只竞争:

1
2
iphone-lock-i
seg-i

这样 20 把锁可以并行工作。

吞吐量从:

1
50 个请求 / 秒

提升到:

1
50 × 20 = 1000 个请求 / 秒

本质上,这是把一个热点库存拆成多个库存分片,降低锁竞争。


3. ConcurrentHashMap

ConcurrentHashMap 也是数据分片思想的体现。

普通 HashMap 如果整体加锁,线程安全是保证了,但所有读写都会竞争同一把锁,性能很差。

ConcurrentHashMap 的做法是:不锁整个 HashMap,而是把内部数据拆成多个槽,每个槽单独加锁。这样不同线程访问不同槽时,可以并发读写。

它的核心收益:

1
2
减少线程竞争
提高 HashMap 的读写性能

总结:ConcurrentHashMap 的分段锁思想,本质也是把一个大竞争点拆成多个小竞争点。


本节总结

数据库分库分表是高并发写场景中常见的数据分片方案。

核心目的:

1
2
分表:解决单表数据量过大
分库:解决单库并发能力不足

拆分方式:

1
2
垂直拆分:按业务或字段拆
水平拆分:按数据行拆

水平拆分常见路由算法:

1
2
3
范围分区:方便范围查询和扩容,但容易热点
哈希分区:分布较均匀,但扩容不灵活
一致性哈希:扩缩容影响较小,常配合虚拟节点

扩容方案:

1
先让 DB0 停止接收写请求,待其从库与完全同步后,将 DB0 的数据范围设为前半段,其从库升为主库然后数据范围设为后半段。最后再用离线范围把冗余数据删除。

2.7 高并发写场景方案 2:异步与写聚合

前面的 2.6 数据分片 是把写压力拆散;
本节的 异步与写聚合 则是从另一个角度优化写请求:

1
2
异步:不让用户一直等真正写完
写聚合:把多个写请求合并成批量写

它们的共同目标是:减少写请求对系统的瞬时冲击,提高后端系统整体吞吐量


2.7.1 异步写

异步写是一个比较泛化的概念,不限于某一种具体实现。

它的核心思想是把写请求的交互流程从:

1
用户发起写请求 → 系统同步处理完成 → 返回结果

改成:

1
用户提交写请求 → 系统先快速接收并返回 → 后台异步真正执行写操作

一般来说,异步写有几个特点:

1
2
3
4
1. 先把用户写请求快速保存到一个数据池中
2. 立即响应用户:请求已提交成功
3. 后台任务不断从数据池中取出请求并真正执行
4. 写操作结果通常由用户主动查询,部分场景也可以主动通知用户

这里的“数据池”可以是消息队列、Redis、数据库临时表等。

异步写适合的场景是:

1
2
3
写请求量很大
但后端系统吞吐量跟不上
可以接受短时间内不是立即完成

它的本质是:
削峰填谷。请求高峰时先排队,后端按照自己的处理能力慢慢消费。


1. 跨公网调用

跨公网调用是异步写的典型场景。

例如电商系统接入微信支付、支付宝等第三方支付平台。
这些支付平台不属于本产品的后台服务,因此需要跨公网调用第三方接口。

跨公网调用的问题是:

1
2
3
4
网络耗时不可控
网络抖动比较严重
第三方平台响应速度不可控
大量请求可能阻塞本系统线程

如果支付请求都同步调用第三方接口,那么用户请求可能长时间等待,系统吞吐量也会下降。

所以可以使用异步写方案:

1
2
3
4
5
6
用户支付请求
→ 支付服务
→ 写入消息中间件
→ 立即响应用户:支付处理中
→ 消息消费者慢慢消费消息
→ 跨公网调用第三方支付接口

这种方案牺牲了“立即知道最终结果”,换来了更好的系统吞吐量和稳定性。


2. 秒杀系统异步化

秒杀活动中,短时间内会有大量用户同时点击“抢购”。

如果每个请求都直接访问数据库,执行:

1
2
扣减库存
写入订单

数据库会承受巨大的瞬时压力。

所以秒杀系统通常会做异步化:

1
2
3
4
5
6
7
用户点击抢购
→ 秒杀服务把请求写入消息中间件
→ 立即返回:抢购中
→ 消息消费者按数据库处理能力慢慢消费
→ 执行扣库存、写订单
→ 结果写入 Redis
→ 用户刷新订单页查询抢购结果

这种设计的价值是:

1
2
3
前端请求快速返回
数据库不会被瞬时流量打垮
系统可以按照数据库真实处理能力慢慢消费请求

需要注意:
用户看到的不是“立即成功”,而是“抢购中”,最终结果要通过查询获得。


2.7.2 写聚合

写聚合是指:
把多个写请求聚合成一个写请求,减少写请求数量。

1
多个小写请求 → 合并成一个批量写请求

它的优点是方案简单、容易理解,很多系统中都有应用。


1. Kafka Producer 批量生产

Kafka 为了提升 Producer 的消息发送性能,提供了 Micro-Batch 机制。

核心组件是:RecordAccumulator

它的作用是:

1
2
3
先把 Producer 要发送的消息暂存在内存中
然后把相同 Topic、相同 Partition 的消息聚合成一个批次
最后一次性发送到 Kafka 集群

这样做可以减少网络请求次数,提高消息发送吞吐量。

总结:

1
2
不聚合:来一条消息发一次
聚合后:攒一批消息一起发

代价是:
消息可能会在内存里短暂停留一会儿,所以会牺牲一点点实时性,换取更高吞吐量。


2. AliSQL 热点数据优化

数据库中处理热点数据更新时,经常会遇到行锁竞争问题。

例如很多请求同时更新同一行数据:

1
update table set count = count + 1 where id = ?

这些请求都会竞争同一把行锁,导致更新性能难以提升。

AliSQL 对这种热点数据更新做了优化:
把同一行的多个更新操作聚合成一个批量更新操作。

1
2
3
4
多个更新请求竞争同一行
→ 聚合成一次批量更新
→ 减少行锁竞争
→ 提升热点数据更新效率

这类优化特别适合“高频更新同一条数据”的场景。


本节总结

1
2
异步写:先接收请求,再后台慢慢处理
写聚合:把多个写请求合并成批量写

异步写解决的是:

1
2
3
写请求高峰太猛
后端系统处理不过来
用户请求不能一直阻塞等待

典型场景:

1
2
跨公网调用第三方接口
秒杀系统削峰

写聚合解决的是:

1
2
写请求数量太多
频繁网络调用或频繁锁竞争导致吞吐量低

典型场景:

1
2
Kafka Producer 批量发送
AliSQL 热点数据更新优化

一句话总结:

1
2
3
异步写用“排队”削峰,
写聚合用“批量”提升吞吐;
二者都是高并发写场景下减少系统瞬时压力的重要手段。

2.8 本章小结

本章围绕通用高并发架构设计展开,核心目标不是单纯提高 QPS,而是同时关注三个方面:

1
2
3
高性能:系统吞吐量高,响应速度快
高可用:系统能长期稳定提供服务
可扩展:流量增长后,系统可以继续扩容

其中:

1
2
3
高可用性:反映系统可以可靠服务的时间
高性能:反映系统吞吐量
可扩展性:反映系统面对增长和故障时的韧性

高并发场景可以先分成两大类:

1
2
高并发读场景
高并发写场景

不同场景的优化思路不同。


高并发读场景的方案

高并发读主要解决的是:大量读请求同时访问系统时,如何降低数据库压力、提升读取速度。

本章讲了三类主要方案:

1
2
3
1. 数据库读/写分离
2. 本地缓存
3. 分布式缓存

数据库读/写分离依赖数据库主从复制机制:

1
2
Master:负责写
Slave:负责读

它可以降低主库读压力,但要特别注意:主从延迟

因为数据写入 Master 后,不一定立刻同步到 Slave。
所以读写分离不是简单把所有读都丢给 Slave,还要结合业务场景处理一致性问题。

本地缓存使用服务器本地内存保存热点数据,优点是访问速度快,可以减少网络调用时间。
但本地缓存容量有限,所以需要缓存淘汰策略,例如:

1
2
3
LFU
LRU
W-TinyLFU

同时,为了避免大量请求在缓存未命中时同时打到数据库,可以使用类似 SingleFlight 的机制解决缓存击穿问题。

分布式缓存通常使用 Redis 实现。
它比本地缓存更适合多服务共享缓存数据,但也要处理几个典型问题:

1
2
3
缓存穿透
缓存雪崩
缓存更新一致性

在缓存更新上,本章推荐的核心思路是:

1
先更新数据库,再删除缓存

这样后续读请求会重新从数据库加载最新数据到缓存中,从而保证数据库和缓存数据最终一致。

最后,本章用 CQRS 总结高并发读场景的核心思想:

1
读/写分离

也就是:

1
2
写操作走写模型
读操作走读模型

让写系统专注数据变更,让读系统专注高性能查询。


高并发写场景的方案

高并发写主要解决的是:大量写请求同时进入系统时,如何降低单点压力、提高写入吞吐量。

本章讲了两类主要方案:

1
2
1. 数据分片
2. 异步与写聚合

数据分片的代表方案是:

1
数据库分库分表

它的核心思想是:

1
把写请求分散到多个数据分区

这样可以避免所有写请求都集中到一个库或一张表上。

其中:

1
2
分库:解决单库并发能力不足
分表:解决单表数据量过大

如果写压力和数据量都很大,就需要结合使用分库分表。

异步写的核心思想是:

1
2
先把写请求暂存到临时缓冲区
再由后台任务慢慢处理

典型临时缓冲区是:

1
消息中间件

这种方案适合削峰填谷,例如秒杀、跨公网调用第三方接口等场景。

写聚合的核心思想是:

1
把多个相关写请求合并成一个批量写请求

这样可以减少写请求数量,降低数据库、消息系统或锁竞争压力。