Redis 为什么这么快:单线程与内存模型
Redis 的响应通常在亚毫秒级,很多人第一反应是“它用了多线程”。恰恰相反,Redis 处理命令的核心线程长期是单线程的。它快的原因并不神秘:所有数据都放在内存里,读写不碰磁盘;核心命令都是 O(1) 或 O(log n) 的简单操作;单线程省掉了多线程切换与锁竞争的开销。单线程反而让语义变得简单——任意一条命令都是原子的,你不用为“两个命令之间会不会被打断”操心。
当然,单线程也意味着一条慢命令会阻塞所有请求。像 KEYS *、对大集合做 SMEMBERS、对上百万元素的 ZSET 做全量操作,都可能把 Redis 卡住几百毫秒。这也是为什么生产环境的规范里,KEYS 永远要让位于 SCAN,批量操作要用 pipeline 分批发送。
在 Redis 6 之后,网络读写这部分引入了多线程 IO,但命令执行仍然是单线程的。所以“Redis 是单线程”这句话用在理解命令原子性与阻塞风险上依然成立,它是我们后面讨论缓存、锁与队列的语义基础。
- 缓存模式旁路缓存(Cache-Aside)是默认选择:先读缓存,未命中再读库并回填。
- 三大问题穿透、击穿、雪崩要分别用空值缓存、互斥重建、随机过期来应对。
- 分布式锁用 SET key value NX EX 加锁、Lua 脚本比对值解锁,避免误删他人锁。
- 持久化RDB 是快照、AOF 是日志,生产通常是两者结合并开启 AOF everysec。
常用数据结构与选型
Redis 的价值不只是“一个 key 存一个 value”,而是它提供了若干种带语义的数据结构。选对了结构,很多原本要在应用层写循环的逻辑,一条命令就能完成。
| 结构 | 典型场景 | 常用命令 |
|---|---|---|
| String | 缓存对象、计数器、分布式锁 | SET / GET / INCR / SETEX |
| Hash | 存储对象字段,可局部更新 | HSET / HGET / HGETALL |
| List | 简单队列、最新列表 | LPUSH / RPOP / LRANGE |
| Set | 去重、共同好友、标签 | SADD / SINTER / SMEMBERS |
| ZSet | 排行榜、延时队列、带权重排序 | ZADD / ZRANGE / ZRANGEBYSCORE |
| Stream | 可回溯的消息队列、消费组 | XADD / XREADGROUP / XACK |
选型时有一条简单的判断线:需要“按分数排序”就用 ZSet,需要“多个消费者各自消费不重复”就用 Stream 的消费组,只是先进先出、丢了也能接受就用 List。把排行榜硬塞进 List、把消息队列硬塞进 Pub/Sub(它不持久化、断线即丢),是新手最常犯的两个错。
缓存模式:旁路缓存与一致性
绝大多数业务用的是旁路缓存:读请求先查 Redis,命中就直接返回;未命中则查数据库,把结果写回 Redis 并设置过期时间。写请求先更新数据库,再删除缓存(而不是更新缓存)。删缓存比更新缓存更常见,因为并发写时“更新缓存”可能写入旧值,而删除能让下次读自然回填最新数据。
为什么是“先更新数据库再删缓存”,而不是反过来?如果先删缓存再更新数据库,在两步之间恰好有读请求进来,它会读到旧数据并回填缓存,随后数据库更新完成,缓存里就留下了长期不一致的旧值。先更新库再删缓存把这个窗口压到极小,再配合一个几百毫秒的延迟双删,就足以覆盖绝大多数场景。
至于强一致,需要先说清楚:缓存与数据库是两个系统,想要严格的实时一致,只能放弃缓存或付出极大的协调成本。缓存的目标是最终一致,允许存在秒级延迟。真正不能容忍不一致的数据(比如账户余额),应该直接读库,或者用版本号、消息补偿来兜底,而不是寄希望于缓存本身。
-- 读:先查缓存,未命中再查库并回填(伪代码逻辑)
GET user:10086 -- 命中直接返回
-- 未命中:SELECT ... FROM t_user WHERE id = 10086
SET user:10086 "<json>" EX 300 -- 回填并设 5 分钟过期
-- 写:先更新数据库,再删除缓存
UPDATE t_user SET nickname='新昵称' WHERE id = 10086;
DEL user:10086
-- 高一致性要求时,可再 DEL 一次,间隔约 500ms 做延迟双删
缓存穿透、击穿与雪崩的应对
这三个词经常被混用,其实描述的是三种不同的失效形态,应对手段也不一样。
- 穿透:请求的数据在缓存和数据库里都不存在(常见的恶意刷不存在的 id),每次都打到数据库。应对:缓存空结果并设置较短过期时间,或者用布隆过滤器在入口拦掉一定不存在的 key。
- 击穿:某个热点 key 恰好过期,瞬间大量并发同时涌向数据库重建数据。应对:对重建过程加互斥锁,只让一个请求去查库回填,其余请求短暂等待或返回旧值。
- 雪崩:大批 key 在同一时刻集中过期,或者 Redis 实例整体宕机,请求全部压到数据库。应对:给过期时间加随机抖动打散、热点数据不过期由后台刷新、以及做好多级缓存与降级预案。
它们的共同点是“把压力从缓存转移回了数据库”,所以防护的重点永远在数据库这一侧:限制并发重建、做熔断降级、保证数据库不被瞬时流量打垮。缓存只是第一道闸,不是免死金牌。
用 Redis 做消息队列与 Stream
当业务需要“异步处理、削峰填谷”,又不想额外引入一套 Kafka 或 RabbitMQ 时,Redis 的 List 和 Stream 就能派上用场。List 最简单:生产者 LPUSH,消费者 BRPOP 阻塞获取,天然是 FIFO。它的缺点是消息一旦被弹出就不再有副本,消费者处理失败就只能靠自己重试。
需要更可靠时用 Stream。它像一条只追加的日志,每条消息有唯一 ID,支持消费组与待确认列表。消费者处理完用 XACK 确认,没确认的消息会留在 pending 列表里,可以重新投递,做到“至少一次”消费。
-- 生产者写入一条消息
XADD orders * orderId 90210 event created
-- 创建消费组(从 0 开始,读全新消息用 $)
XGROUP CREATE orders order-workers 0 MKSTREAM
-- 消费者以消费组身份读取,> 表示只读新消息
XREADGROUP GROUP order-workers worker-1 COUNT 10 BLOCK 5000 \
STREAMS orders >
-- 处理完成后确认;未确认的会留在 pending 里等待重投
XACK orders order-workers 1706349000000-0
XINFO GROUPS orders -- 查看消费组的 pending 情况
但要诚实地认识边界:Redis 的队列适合中小规模、可容忍极端情况下丢少量消息的场景。若业务要求分区、持久化、海量堆积与严格顺序,还是应该交给专业消息中间件,Redis 只做辅助。
分布式锁的正确姿势
多台机器要争抢同一个资源(比如同一订单只能被处理一次),就需要分布式锁。Redis 提供原语,但正确用法有几个关键点,少一个都会出问题。
- 加锁要原子:用一条
SET key value NX EX 10,不要写成先判断再 SET 的两步,否则并发下会互相覆盖。 - 必须设过期时间:万一持锁进程崩溃,锁能自动释放,不会成为死锁。过期时间要略大于业务最长耗时。
- value 要唯一:用一个随机串标识持有者,解锁时先比对再删除,避免“删掉了别人的锁”。
- 解锁要用 Lua:把“比对 + 删除”合成一个原子操作。
把这几条写成代码,就是一个可用的最小实现:
import redis, uuid
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
LOCK_KEY = "lock:order:90210"
token = uuid.uuid4().hex # 唯一标识本次持锁者
# 加锁:NX 保证互斥,EX 保证自动过期
ok = r.set(LOCK_KEY, token, nx=True, ex=10)
if not ok:
raise RuntimeError("获取锁失败,稍后重试")
try:
# ... 处理真正的业务逻辑 ...
pass
finally:
# 解锁:先比对 token 再删除,两条命令合成原子 Lua
unlock_lua = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
r.eval(unlock_lua, 1, LOCK_KEY, token)
若面对的是对一致性要求极高的场景(比如金融扣款),单实例 Redis 锁在故障切换时仍可能被两方同时持有,这时应考虑 Redlock 或者更稳妥的方案——把并发控制放回数据库层面,用唯一约束或行锁来解决。
持久化:RDB 与 AOF 的取舍
Redis 是内存数据库,一旦进程退出,内存里的数据就没了。持久化解决的就是重启后如何恢复数据的问题,它提供两种方式。
| 维度 | RDB | AOF |
|---|---|---|
| 形式 | 某一时刻的全量二进制快照 | 追加写命令日志 |
| 恢复速度 | 快,直接加载快照 | 慢,需重放全部命令 |
| 数据丢失 | 两次快照之间可能丢较多 | everysec 最多丢 1 秒 |
| 文件体积 | 小、紧凑 | 大,需定期重写压缩 |
| 适用 | 备份、可容忍少量丢失的缓存 | 对数据完整性要求更高的场景 |
实践中的做法通常是两者结合:用 RDB 做定期的全量备份,用 AOF 保证更高的数据完整度,并开启 appendfsync everysec——它在性能与安全之间取了折中,每秒刷盘一次,最坏丢一秒数据,对绝大多数业务可接受。只做缓存、数据可从数据库重建的场景,甚至可以只开 RDB 或干脆不持久化。
还要留意 fork 的代价:RDB 和 AOF 重写都依赖 fork 出子进程,内存占用大时,fork 引起的写时复制会带来内存翻倍的风险。因此对内存敏感的实例,要控制单实例内存规模,并关注 kdump 与 swap 设置。
内存淘汰与线上运维清单
内存是 Redis 最宝贵的资源。当内存达到上限时,淘汰策略决定它会删掉什么。默认的 noeviction 会直接对写入报错,这在纯缓存场景是危险的——缓存满了不应该让业务写失败。用作缓存时通常选 allkeys-lru 或 allkeys-lfu,按最近最少使用或最少频率淘汰;如果 key 有明显的冷热之分且不能误删,则用 volatile-lru 只淘汰设置了过期时间的 key。
- 上线前评估内存:按“单条平均大小 × key 数量 × 预留倍数”估算,并设置 maxmemory 与合理的淘汰策略。
- 禁用危险命令:用
rename-command屏蔽 KEYS、FLUSHALL,避免误操作清库。 - 大 key 治理:单个 String 超过 10KB、集合元素过万都要拆分,它们会拖慢删除与迁移。
- 慢查询监控:把
slowlog-log-slower-than设成合理阈值,定期 review slowlog。 - 容量与高可用:主从加哨兵或集群,至少保证故障时能自动切换。
把 Redis 当成一个又快又娇贵的共享内存来对待:给它设好上限、监控好大 key 与慢查询、想清楚数据丢了能不能重建。想清楚这三点,它就能稳稳地扛住高并发。
至此,从 MySQL 的索引到 Redis 的缓存,我们走完了一条常见的数据访问链路。下一篇会回到设计的源头,讨论在写第一行建表语句之前,范式与反范式到底该怎么权衡。