41.9 产品 (Redis / Memcached) 与内部结构
一句话
Redis 与 Memcached 的差别不是“谁的 QPS 更高”,而是数据结构与内核模型:Redis 用单线程换极简的并发模型、用丰富结构换服务端计算、用持久化换可重启;Memcached 用多线程换多核吞吐、用纯 KV 换内存效率——选型看“要不要结构/持久化/原子操作”——单实例 QPS 两者都是十万量级(Redis 本机实测见下),差异不在这一个数上。
两个产品,两条路线
| 维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | String / Hash / List / Set / ZSet / Bitmap / HyperLogLog / Stream | 只有 KV(value 是不透明字节) |
| 线程模型 | 命令执行单线程(8.x 的 io-threads 只做网络读写) | 多线程,天然吃多核 |
| 持久化 | RDB 快照 + AOF 日志 | 无(重启即空) |
| 服务端计算 | INCR、ZADD、Lua、过期、事务 | 几乎没有(只有 CAS/incr 等少量) |
| 内存分配 | libc/jemalloc + 紧凑编码(listpack/intset) | slab allocator(固定尺寸 chunk) |
| 集群 | Cluster(16384 slot)/ Sentinel | 客户端分片(服务端无集群) |
| 过期 | 惰性删除 + 定期采样删除 | 惰性 + LRU 淘汰 |
| 适合 | 缓存 + 计数 + 排行榜 + 简单队列 + 分布式锁 | 纯缓存、大 value、多核吞吐优先 |
🔑 判断只需三问:要不要服务端原子操作/数据结构?要不要重启后数据还在?要不要单线程带来的“简单可预测”? 三个都要 → Redis;只要“KV 缓存且能多核跑满” → Memcached。
本机实测(Redis 8.10.2,Apple M1,loopback)
| 指标 | 实测值 | 测量方式 |
|---|---|---|
| SET 吞吐 | 92,764 rps,p50 = 0.375 ms | redis-benchmark -p 6399 -n 100000 -c 50 -t set |
| GET 吞吐 | 87,489 rps,p50 = 0.407 ms | 同上,-t get |
| 独立 key 内存 | 99 B/key(key 约 17 B + value 32 B) | 写 10,000 个 key,读 used_memory 差值 |
| hash 分桶内存 | 43 B/field | 同样数据装进 100 个 hash(每个 100 field,listpack) |
| TTL 额外开销 | 35.7 B/key | 10,000 个 key 加 EXPIRE 3600 后的内存增量 |
| 8 MB 上限下写 20 万 key | 淘汰 159,787 个,保留 40,213 个 | maxmemory 8mb + allkeys-lfu |
🔑 第一条读数最值得记住:同样一份数据,放独立 key 是 99 B/key,放进 hash 的 listpack 只要 43 B/field(2.3 倍差距)。这不是编码技巧,是“每个 key 都有独立的 dictEntry + SDS + 过期字典槽位”的结构性开销。
Redis 的五个内部结构,五个必须知道的后果
| 结构 | 是什么 | 工程后果 |
|---|---|---|
| SDS(简单动态字符串) | 带头部(len/alloc/flags)的字节数组 | STRLEN O(1)、二进制安全;但每个字符串有额外头部开销 |
| dict + 渐进式 rehash | 两张哈希表,扩容时分批搬迁 | 不会因为扩容停顿;但扩容期间内存短暂翻倍 |
| listpack / intset | 小集合的紧凑连续内存编码 | 小对象省内存(实测 2.3 倍);超过阈值(如 hash 512 项 / 64 字节 value)转 hashtable,内存跳变 |
| skiplist(ZSet) | 多层索引链表 | 范围查询 ZRANGE O(log N + M);比平衡树实现简单,内存开销更大 |
| 过期字典 + 惰性/定期删除 | 单独一张 dict 存 TTL | 每条 TTL 约 36 B;过期 key 不会立刻消失(惰性删除要等访问) |
代码:为什么必须“渐进式” rehash
Redis 是单线程执行命令的——如果一次 rehash 要搬 100 万条,这 100 万条期间所有请求都会排队。下面这段程序量化两种做法的最大停顿。
package main
import (
"fmt"
"time"
)
const (
n = 1_000_000 // 待迁移的 key 数
batch = 100 // 渐进式每批迁移的条数
qps = 10000 // 假设的请求速率
)
func main() {
keys := make([]string, n)
m := map[string]int{} // 先构造出这批 key
for i := 0; i < n; i++ {
keys[i] = fmt.Sprintf("k:%d", i)
m[keys[i]] = i
}
// ① 一次性 rehash:所有 key 在一个“命令”里搬到新表
dst1 := make(map[string]int, n)
t0 := time.Now()
for i, k := range keys {
dst1[k] = i
}
stw := time.Since(t0)
// ② 渐进式:每处理一个请求搬 batch 条,记录单次最大停顿
dst2 := make(map[string]int, n)
var maxPause, total time.Duration
t0 = time.Now()
for i := 0; i < n; i += batch {
b0 := time.Now()
for j := i; j < i+batch && j < n; j++ {
dst2[keys[j]] = j
}
if d := time.Since(b0); d > maxPause {
maxPause = d
}
}
total = time.Since(t0)
fmt.Printf("待迁移 %d 个 key,渐进式每批 %d 个,请求速率 %d QPS\n\n", n, batch, qps)
fmt.Printf("%-14s %-14s %-14s\n", "做法", "总耗时", "单次最大停顿")
fmt.Printf("%-14s %-14v %-14v\n", "一次性 rehash", stw.Round(time.Millisecond), stw.Round(time.Millisecond))
fmt.Printf("%-14s %-14v %-14v\n", "渐进式 rehash", total.Round(time.Millisecond), maxPause.Round(time.Microsecond))
fmt.Printf("\n总工作量相同(都是搬 %d 条),最大停顿差 %.0f 倍\n", n, float64(stw)/float64(maxPause))
fmt.Printf("停顿期间排队的请求:一次性 %d 个,渐进式 %d 个\n",
int(float64(qps)*stw.Seconds()), int(float64(qps)*maxPause.Seconds()))
}
本机实测(macOS,Apple M1,Go 1.27):
待迁移 1000000 个 key,渐进式每批 100 个,请求速率 10000 QPS
做法 总耗时 单次最大停顿
一次性 rehash 60ms 60ms
渐进式 rehash 60ms 163µs
总工作量相同(都是搬 1000000 条),最大停顿差 366 倍
停顿期间排队的请求:一次性 595 个,渐进式 1 个
读数:
| 做法 | 总耗时 | 单次最大停顿 | 后果 |
|---|---|---|---|
| 一次性 rehash | 几十到几百毫秒 | 等于总耗时 | 单线程下所有命令排队,P99 直接爆表 |
| 渐进式 rehash | 与一次性相当 | 微秒级 | 请求穿插执行,用户几乎无感 |
🔑 结论:渐进式 rehash 不减少总工作量,只把“一次性停顿”切成“每命令一小片”。代价是扩容期间两张表同时存在(内存短暂翻倍)、每次读写要查两张表。这就是 Redis 用“复杂度”换“延迟可预测”的典型交易。
单线程模型的两个直接后果
| 后果 | 说明 | 应对 |
|---|---|---|
| 一条慢命令阻塞所有人 | KEYS *、FLUSHALL、大 key 的 HGETALL、复杂 Lua | 禁用 KEYS(用 SCAN);大 key 拆分;Lua 限制执行时间 |
| 单实例吃不满多核 | 命令执行是一个线程,QPS 上限约 10 万量级(本机实测 SET 9.2 万) | 多实例分片;8.x 可开 io-threads 分担网络读写(命令仍单线程) |
⚠️
io-threads不是“多线程执行命令”。它只把 socket 读写、协议解析分给多个线程;命令仍然串行执行。所以它提升的是“高并发小包”的吞吐,不会让一条慢命令变快。
落地:指名 → 可抄 → 可验 → 可退
本节数据来自本机实跑的 Redis 8.10.2(Homebrew),以下命令均实际执行过。
# 1) 指名 + 可抄:起一个只做缓存的实例(不持久化、限制内存、LFU 淘汰)
redis-server --port 6399 --save '' --appendonly no \
--maxmemory 64mb --maxmemory-policy allkeys-lfu --daemonize yes
# 2) 可验:确认版本、内存策略、淘汰情况(真实输出见下)
redis-cli -p 6399 info server | grep redis_version
redis-cli -p 6399 config get maxmemory-policy
redis-cli -p 6399 info memory | grep -E "used_memory_human|maxmemory_human"
redis-cli -p 6399 info stats | grep evicted_keys
# 3) 可退:改回 LRU(不重启、不丢数据)
redis-cli -p 6399 config set maxmemory-policy allkeys-lru
redis-cli -p 6399 shutdown nosave
真实输出(本机):
redis_version:8.10.2
maxmemory_policy
allkeys-lfu
used_memory_human:8.00M
maxmemory_human:8.00M
evicted_keys:159787
| 步骤 | 判据 | 失败时看什么 |
|---|---|---|
| 启动 | PING 返回 PONG | 端口被占、--save '' 写法(空字符串)是否正确 |
| 内存上限生效 | used_memory ≤ maxmemory,且 evicted_keys 开始增长 | 是否设了 maxmemory;策略是否 noeviction(那样会写失败) |
| 紧凑编码生效 | OBJECT ENCODING 返回 listpack/intset | 是否超过 hash-max-listpack-entries(本机默认 512)或 value 超过 64 字节 |
| 回退 | CONFIG SET 立即生效,无需重启 | 配置是否被配置文件覆盖(重启后又变回去) |
反模式
| 反模式 | 后果 |
|---|---|
用 KEYS * 做线上查询 | 单线程被阻塞,全站超时(用 SCAN) |
| 一个大 hash 存百万 field | 单次 HGETALL 阻塞 + 内存集中,大 key 拆分 |
| 把 Redis 当数据库(无持久化 + 无备份) | 重启即空,业务数据丢失 |
不设 maxmemory | 内存涨到机器上限被 OOM Killer 杀掉 |
用 allkeys-lru 却混布不可丢数据 | 队列/会话被淘汰 |
认为 io-threads 能加速单条命令 | 命令仍串行,慢命令还是慢 |
| 用 Memcached 存需要原子自增/排行的数据 | 需要客户端 CAS 重试,复杂且易错 |
| 大 value(几 MB)放 Redis | 网络与内存放大,复制/迁移变慢 |
自检
Q1 为什么“同样 10,000 条数据,装进 100 个 hash 比 10,000 个独立 key 省 2.3 倍内存”?
▸ 答案:因为每个独立 key 都要付一整套固定开销:dictEntry(哈希表槽位)、key 的 SDS 头部、value 的 SDS 头部、以及过期字典的潜在槽位——本机实测约 99 B/key,而 value 只有 32 B。装进 hash 后,一个 hash 只有一份 key 开销,100 个 field 共享一个 listpack 的连续内存(本机实测 43 B/field)。代价是:整个 hash 是一个 key,过期/淘汰的粒度变粗,且超过 hash-max-listpack-entries(本机默认 512)会转成 hashtable,内存优势消失。
Q2 Redis 是单线程的,为什么还能跑到 9 万 QPS?
▸ 答案:因为瓶颈不在 CPU 计算,而在“每命令的固定开销”:网络往返、协议解析、内存分配。单线程避免了锁竞争与上下文切换,纯内存操作又快,所以单核就能处理十万级简单命令。真正的限制是:① 一条慢命令(O(N))会阻塞所有请求;② 单实例无法利用多核(要用多实例分片);③ 大 value 会放大网络与内存拷贝成本。所以“单线程”不是缺点,是把并发复杂度换成了“不能有慢命令”的纪律。
Q3 渐进式 rehash 期间,读写请求怎么找到数据?
▸ 答案:rehash 期间同时存在两张表(旧表 ht[0] 与新表 ht[1])。查找时先查旧表,没有再查新表;写入一律写到新表(保证新表数据完整);搬迁是“从旧表头部取一个桶,把该桶的所有 entry 迁到新表”,由每次命令顺带执行一小批。这样任意时刻数据只可能在其中一张表里,查找最多两次哈希,且没有停顿。
Q4 一个业务用 Memcached 存会话,某次重启后所有用户掉线。换成 Redis 就解决了吗?
▸ 答案:不一定。Redis 有持久化能力,但是否真的能恢复取决于配置:只开 RDB 会丢最后一次快照之后的数据;只开 AOF 且 appendfsync everysec 最多丢 1 秒;两者都关(很多“当缓存用”的部署就是这样)重启照样全空。会话这种“丢了就掉线”的数据,要么开 AOF + 合理 fsync,要么接受掉线并在协议层做重登,要么干脆用专门的有状态服务。换产品不解决配置问题。