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 示意:蓝色渐变背景上一叠半透明玻璃内存方块、发光闪电、沿管道排队的方块与金属磁盘圆盘,代表内存存储、高速读写、消息队列与持久化
Redis 的三重角色:左下的闪电代表内存级读写速度,中部的方块阵列是承载数据的键值空间,右侧排队的方块与磁盘圆盘分别对应消息队列与持久化落盘。

常用数据结构与选型

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 或者更稳妥的方案——把并发控制放回数据库层面,用唯一约束或行锁来解决。