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,要么接受掉线并在协议层做重登,要么干脆用专门的有状态服务。换产品不解决配置问题。

关联