目录
-
- [一、 核心场景:百万并发下的秒杀大考](#一、 核心场景:百万并发下的秒杀大考)
-
- [1. 传统多线程服务器会怎么样?](#1. 传统多线程服务器会怎么样?)
- [2. Redis 凭什么能抗住?](#2. Redis 凭什么能抗住?)
-
- [第一步:建立连接(非阻塞 + epoll)](#第一步:建立连接(非阻塞 + epoll))
- [第二步:读取请求(非阻塞 I/O)](#第二步:读取请求(非阻塞 I/O))
- 第三步:执行命令(单线程串行,纯内存操作)
- 第四步:返回结果(非阻塞写)
- [二、 深度对比:多线程阻塞 vs 单线程非阻塞](#二、 深度对比:多线程阻塞 vs 单线程非阻塞)
- [三、 演进:Redis 6.0+ 的多线程 I/O 革命](#三、 演进:Redis 6.0+ 的多线程 I/O 革命)
- [四、 微观视角:一个秒杀请求的时间线拆解](#四、 微观视角:一个秒杀请求的时间线拆解)
- [五、 致命死穴:如果某个命令很慢怎么办?](#五、 致命死穴:如果某个命令很慢怎么办?)
- 本篇总结
一、 核心场景:百万并发下的秒杀大考
假设运营着一个电商平台,双十一零点有 100 万用户同时抢购 1 件商品。后端使用 Redis 存储商品库存:
bash
SET stock 1
每个用户发起请求时,需要原子地检查并扣减库存,核心业务伪代码如下:
python
if stock > 0:
stock = stock - 1
return success
else:
return fail
⚠️ 注意 :在真实生产环境中,该操作必须使用 Redis 的
DECR命令或 Lua 脚本 来执行,以保证复合操作的原子性。
1. 传统多线程服务器会怎么样?
假设采用传统的 Tomcat + MySQL 架构:
- 每一个请求到达服务器后都会分配一个独立的线程。
- 100 万并发会导致系统频繁进行线程上下文切换 ,引发剧烈的锁竞争,进而导致 CPU 瞬间爆满。
- 随后,数据库的磁盘 I/O 成为致命瓶颈,整个系统几乎会立刻崩溃,或者响应时间从 1ms 飙升至 10s 以上。
2. Redis 凭什么能抗住?
面对同样的百万级并发洪峰,Redis 依靠其独特的网络模型,将整个处理链路巧妙地拆解为以下四个步骤:
第一步:建立连接(非阻塞 + epoll)
- 注册监听 :Redis 启动时,主线程会创建一个监听 Socket,并将其注册到 Linux 内核的
epoll实例中。 - 内核排队:当 100 万个客户端同时发起 TCP 连接时,操作系统内核会将这些连接放入全连接队列中。
- 按需感知 :Redis 主线程循环调用
epoll_wait,内核会直接返回当前有事件就绪的 Socket 列表(可能只有几千个)。 - 高效分发 :主线程依次调用
accept接受连接,生成对应的 Client Socket,并同样注册到epoll中。 - ✨ 关键点 :这一步完全由 Linux 内核处理,Redis 主线程只是轮询就绪列表 ,而不需要盲目遍历 100 万个空闲连接,算法复杂度为 O ( 就绪数 ) O(就绪数) O(就绪数)。
第二步:读取请求(非阻塞 I/O)
每个 Client Socket 上都可能传来用户的"扣库存"命令。
- 当数据到达时,
epoll会将该 Socket 标记为可读状态。 - Redis 主线程从
epoll就绪列表中取出该 Socket,调用read读取数据。由于采用了非阻塞模式,如果当前没有数据会立马返回,绝不原地死等。 - 读取到的原始命令会被放入 Redis 的输入缓冲区。
- ✨ 现象 :即便 100 万个连接同时发送请求,内核也会把数据暂存在 Socket 接收缓冲区中。Redis 主线程一次循环只处理几千个就绪的 Socket 并读走数据,单线程运行,没有任何线程切换开销。
第三步:执行命令(单线程串行,纯内存操作)
- Redis 从输入缓冲区中取出解析好的命令(如
DECR stock)。 - 直接在内存中执行该修改操作(耗时通常仅需几十纳秒)。
- ✨ 关键点 :由于执行核心是单线程 ,同一时刻有且仅有一个命令在修改内存,因此不需要对
stock加任何锁 (天然串行化)。即便 100 万个命令同时到达,它们也会在内存缓冲区里排队依次执行,天生免疫超卖问题。
第四步:返回结果(非阻塞写)
- 命令执行完毕后,执行结果会被写入 Client Socket 的输出缓冲区。
- 如果客户端接收较慢导致发送缓冲区满了,Redis 会将该 Socket 标记为可写 并注册到
epoll中。 - 当 Socket 恢复可写状态时,Redis 再将剩余数据异步写回客户端。整个过程不会因为某个客户端的"网速慢"而阻塞其他客户端。
通过一张图可能会看的更加清晰:
bash
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Linux 操作系统内核 (Kernel) │
└───────────────────────────────────────┬─────────────────────────▲───────────────────────────────────────┘
│ ① 100万并发连接 │ ④ 异步网络写 (非阻塞)
│ 注册并触发呼叫铃 │ 网卡空闲时推回结果
▼ │
┌─────────────────────────────────────────────────────────────────┴───────────────────────────────────────┐
│ I/O 多路复用监听器 (epoll_wait) │
└───────────────────────────────────────┬─────────────────────────────────────────────────────────────────┘
│ ② 返回当前活跃的【就绪队列】(如3000个)
▼
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Redis 主线程 (Single Thread) │
│ │
│ Step 1: 网络读取 (Read) ──► Step 2: 命令解析 ──► Step 3: 单线程核心执行 ──► Step 4: 网络写入 (Write) │
│ ┌─────────────────────┐ ┌───────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ 非阻塞网络读 │ │ 将字节流解析 │ │ 💡核心操作: 纯内存 │ │ 执行结果放入 │ │
│ │ 从内核缓冲区把 │ │ 为具体的 Redis │ │ 无论是【读内存】(GET)│ │ 输出缓冲区 │ │
│ │ 字节流命令搬运过来 │ │ 命令 │ │ 还是【改内存】(SET) │ │ │ │
│ └──────────┬──────────┘ └───────┬───────┘ └──────────▲───────────┘ └──────────┬──────────┘ │
└─────────────┼───────────────────────┼────────────────────────┼──────────────────────────┼───────────────┘
│ │ │ │
▼ ▼ │ ▼
┌─────────────────────────────────────────────┐ ┌────────┴─────────────┐ ┌────────────────────────┐
│ Redis 输入缓冲区 (Input Buffer) │ │ Redis 内存数据库 │ │ Redis 输出缓冲区 │
│ [ GET name ] [ DECR stock ] [ ... ] │ │ (String/Hash/List...)│ │ (Output Buffer) │
└─────────────────────────────────────────────┘ └──────────────────────┘ └────────────────────────┘
二、 深度对比:多线程阻塞 vs 单线程非阻塞
为了更直观地理解,我们将传统多线程阻塞模型 与 Redis 单线程非阻塞模型在处理高并发秒杀场景时的表现进行横向对比:
| 维度 | 多线程阻塞模型(如传统 Tomcat) | 单线程非阻塞模型(Redis 自身) |
|---|---|---|
| 并发连接能力 | 1 个连接 → \rightarrow → 1 个线程,1 万个线程将产生约 1GB 的内存开销 | 1 万个连接通过 epoll 集中管理,仅需消耗 几 MB 内存 |
| 上下文切换 | 极其频繁,导致大量 CPU 资源空转损耗 | 零 上下文切换 |
| 锁竞争开销 | 需要依赖分布式锁或数据库事务,开销极大 | 无锁 设计,内存直接操作 |
| 吞吐量极限 | 受限于线程极限与锁屏障,约 5~10 万 QPS(实际往往更低) | 单核即可轻松跑满 10 万+ QPS |
| 高并发稳定性 | 洪峰下极易引发线程池耗尽,导致系统雪崩 | 表现极其稳定,但易受慢查询命令的影响 |
三、 演进:Redis 6.0+ 的多线程 I/O 革命
既然单线程这么好,为什么 Redis 6.0 之后又引入了多线程?
在极端的超高吞吐场景下,网络数据的读取、解析以及结果写回(即网络 I/O) 往往会先于内存执行挤爆单核 CPU 的带宽。为了突破这个瓶颈,Redis 6.0 引入了 多线程 I/O。
在秒杀场景中,它的工作模式演变为:
- 主线程 :依然雷打不动地独占命令执行权(负责扣库存)。
- I/O 线程池 :分配多个辅助线程,并行读取 100 万个连接的网络请求数据,并将处理好的响应结果并行写回网卡。
- ✨ 核心本质 :命令执行依然是单线程的。因此,库存扣减的原子性、无锁特性不受任何影响,只是网络传输的"大门"被拓宽了。
四、 微观视角:一个秒杀请求的时间线拆解
为了更细腻地感受 Redis 的速度,我们以微秒( μ s \mu s μs)为单位,拆解一个客户端发送 DECR stock 到接收响应的微观全过程:
text
┌─────────────────────────────────────────────────────────────────────────────┐
│ 网络传输 --> epoll通知 --> 读数据 --> 命令解析 --> 内存执行 --> 写响应 │
│ 50 μs 几 μs 10 μs 0.5 μs 纳秒级 纳秒级 │
└─────────────────────────────────────────────────────────────────────────────┘
- 网络传输 :命令数据包从客户端出发,通过网络到达 Redis 服务器的内核缓冲区(约耗时 50 μ s 50\,\mu s 50μs,受物理网卡影响)。
- epoll 通知就绪 :Redis 主线程在
epoll_wait阻塞处被唤醒,获取到可读的 Socket 列表(耗时 几 μ s \mu s μs)。 - 读取数据 :调用
read函数将内核数据拷贝到 Redis 的输入缓冲区(约耗时 10 μ s 10\,\mu s 10μs,非阻塞,立即返回)。 - 命令解析 :协议识别为
DECR,并在内存 Dict 中找到 Key 对应的指针(哈希查找,约耗时 0.5 μ s 0.5\,\mu s 0.5μs)。 - 内存执行 :在内存中将整数
1扣减为0(纳秒级,极其恐怖的速度)。 - 写回响应 :将结果
":0\r\n"写入输出缓冲区,并向epoll注册写事件(纳秒级)。 - 数据发送 :异步将数据推回给客户端网卡(约耗时 50 μ s 50\,\mu s 50μs)。
💡 小结 :单个请求的闭环处理大约需要 120 μ s 120\,\mu s 120μs 。在实际运行中,Redis 会通过批量处理(Pipeline 等技术)进一步优化,使单核吞吐量轻松突破 10 万+ QPS。
五、 致命死穴:如果某个命令很慢怎么办?
单线程模型虽然无敌,但它有一个致命的阿喀琉斯之踵。
假设某个开发人员在生产环境中错误地执行了类似 KEYS * 的命令,去全局扫描一个包含 1000 万个 Key 的数据库,该命令耗时将长达 2 秒。
- 🚨 灾难发生:因为 Redis 是单线程串行执行命令的,在这 2 秒期间,主线程被完全卡死。
- 连锁反应:在这 2 秒内涌入的所有其他命令(包括高并发秒杀扣库存的请求)全部被迫排队死等,进而导致前端大面积超时崩溃。
🛑 生产环境高压线 :在企业级开发中,必须严格杜绝一切慢查询。严禁使用
KEYS *,应改用渐进式遍历命令SCAN;同时需要合理设计大 Key,避免使用过于复杂的数据结构。
本篇总结
- "单线程"的本质 :指 Redis 的命令执行是单线程的,这带来了天然的原子性与免锁特性。
- 高并发的底层依赖 :高并发依靠的是 Linux 内核的
epoll(I/O 多路复用) + 非阻塞 I/O,单线程即可轻松驾驭数十万个活跃连接。 - 性能瓶颈的真相 :在纯缓存场景中,CPU 执行命令(内存修改)极快,根本不是瓶颈;真正的瓶颈在于网络带宽 以及多线程带来的上下文切换损耗。
- 现代化演进:Redis 6.0+ 的多线程 I/O 仅仅优化了"网卡到缓冲区"的搬运速度,其灵魂(核心执行线程)依然纯粹单线程。