第 1 章

Redis 不只是缓存

第一章:Redis 不只是缓存

1.1 诞生背景:一个意大利程序员的副业项目

2009年,Salvatore Sanfilippo(网名 antirez)正在构建一个实时网站分析系统 LLOOGG。这个系统需要将用户访问日志写入列表,并在列表过长时弹出旧记录。用 MySQL 实现时,他发现即使对一张小表做 LPUSH/LTRIM 操作,磁盘 I/O 也是无法接受的瓶颈。PostgreSQL 也试了,结果一样令人失望。

他的解决方案是自己写一个内存数据结构服务器。最初的 commit 只有几百行 C 代码,支持 list、set 两种结构。他把这个项目发到 Hacker News,当晚就有人提交了 Python 客户端 patch。

2009-04-10  首个公开版本 0.001,Hacker News 帖子
2010-03-15  VMware 雇佣 antirez 全职开发
2013-05-17  Pivotal 接手赞助
2015-06-01  Redis Labs 成立(antirez 加入)
2020-07-01  Redis Labs 更名为 Redis Inc.
2022-03-02  antirez 彻底离开 Redis Inc.,回归个人创作
2024-03-20  Redis Inc. 将许可证从 BSD 改为 RSALv2+SSPLv1
2024-03-21  Linux Foundation 宣布 Valkey fork

这条时间线说明了一件事:Redis 从来不是一家公司规划出来的产品,它是一个程序员用来解决自己问题的工具,恰好也解决了全世界其他程序员的问题。

1.2 六大核心使用场景

1.2.1 缓存(Cache)

最广为人知的用法,但也最容易被误用。Redis 作为缓存的核心价值不是"快",而是"减少下游压力"。

典型架构:应用层先查 Redis,命中则直接返回;未命中则查 MySQL,将结果写入 Redis 并设置 TTL。

def get_user(user_id: int) -> dict:
    cache_key = f"user:{user_id}"
    cached = redis.get(cache_key)
    if cached:
        return json.loads(cached)
    
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.setex(cache_key, 3600, json.dumps(user))  # TTL = 1小时
    return user

缓存穿透、缓存雪崩、缓存击穿是三个独立问题,每个都有对应的标准解法(布隆过滤器、随机 TTL 抖动、互斥锁/singleflight),不在此展开。

1.2.2 排行榜与计数器

有序集合(Sorted Set)是 Redis 专为此场景设计的数据结构。使用跳表(skiplist)+ 哈希表双结构实现 O(log N) 的插入和范围查询。

# 游戏积分排行榜
ZADD leaderboard 9850 "player:1001"
ZADD leaderboard 12400 "player:2007"
ZADD leaderboard 11200 "player:3015"

# 获取前 10 名(分数从高到低)
ZREVRANGE leaderboard 0 9 WITHSCORES

# 获取某玩家排名(0-based,ZREVRANK 从高到低)
ZREVRANK leaderboard "player:1001"  # → 2(第3名)

原子计数器用 INCR 实现,保证在并发下不会丢失计数:

INCR page:views:homepage        # 原子自增
INCRBY article:likes:42 5       # 批量自增
INCR rate:api:user:1001         # 限流计数
EXPIRE rate:api:user:1001 60    # 1分钟窗口

1.2.3 分布式锁

SETNX(SET if Not eXists)+ EXPIRE 的组合是教科书中的分布式锁,但这个方案有竞态条件(SETNX 成功后进程崩溃,锁永不过期)。

正确做法是用 SET 的原子命令:

SET lock:resource:42 "owner-uuid-abc" NX PX 30000
# NX = 不存在才设置
# PX 30000 = 过期时间 30 秒(毫秒单位)

释放锁必须验证 owner,用 Lua 脚本保证原子性:

-- 释放锁的 Lua 脚本
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

Redlock 算法(多节点分布式锁)另当别论,它要求向奇数个独立 Redis 节点依次申请锁,获得超过半数才算成功。该算法存在争议(Martin Kleppmann 的批评),生产中需要根据业务容忍度谨慎选择。

1.2.4 消息队列

Redis Stream(5.0 引入)是功能最完整的队列实现,支持消费者组、ACK 确认、pending 消息重新投递:

# 生产者写入
XADD orders * order_id 10086 amount 299.00 user_id 1001

# 消费者组读取
XREADGROUP GROUP order-processors worker-1 COUNT 10 BLOCK 2000 STREAMS orders >

# 确认消费
XACK orders order-processors "1703123456789-0"

List 的 LPUSH/BRPOP 组合是更简单的方案,适合任务队列:

LPUSH job-queue '{"type":"send_email","to":"[email protected]"}'
BRPOP job-queue 0  # 阻塞等待,超时 0 = 永久等待

Pub/Sub 适合广播但不保证消息送达(无持久化,无重放)。

1.2.5 会话存储(Session Store)

无状态服务架构中,会话数据需要集中存储。Redis 天然适合:

# Flask-Session 配置示例
SESSION_TYPE = 'redis'
SESSION_REDIS = redis.StrictRedis(host='localhost', port=6379)
SESSION_PERMANENT = False
SESSION_USE_SIGNER = True
PERMANENT_SESSION_LIFETIME = timedelta(hours=2)

关键点:TTL 与用户活跃时间挂钩(每次请求刷新过期时间),而非固定过期。

1.2.6 实时分析

HyperLogLog 用于基数估算(UV 统计),误差率约 0.81%,内存仅 12KB(无论数据量多大):

PFADD uv:homepage:2024-01-15 "user-uuid-1" "user-uuid-2" "user-uuid-3"
PFCOUNT uv:homepage:2024-01-15  # → 估算 UV 数
PFMERGE uv:homepage:week "uv:homepage:2024-01-15" "uv:homepage:2024-01-16"

Bitmap 用于布尔状态追踪(如签到):

SETBIT checkin:user:1001:2024-01 14 1   # 1月15日签到(0-based offset)
BITCOUNT checkin:user:1001:2024-01      # 月签到天数
BITPOS checkin:user:1001:2024-01 0      # 第一次未签到的日期

1.3 单线程为何比多线程快

1.3.1 几个常见误解

"Redis 是单线程的"这个说法需要精确:Redis 的命令处理是单线程的,但 I/O 读写(Redis 6.0+)、持久化(后台线程)、Key 过期删除(后台线程)都是多线程的。

完整的线程模型:

1.3.2 单线程优势的量化分析

锁竞争消除:多线程访问共享数据结构(哈希表、跳表)需要加锁。假设一个操作耗时 1μs,锁争用导致的等待时间在高并发下可达 5-50μs,比操作本身慢得多。

CPU 缓存局部性:单线程模型下,同一个 CPU core 持续处理命令,L1/L2 cache 中的数据结构(dict、ziplist)保持热状态。多线程模型下,不同 core 处理同一数据结构会导致缓存失效(Cache Line Invalidation),代价约 100-300ns/次。

上下文切换:Linux 线程切换代价约 1-10μs(含寄存器保存/恢复、TLB flush)。高并发下(10000+ 线程/秒切换),这个代价不可忽视。

实测 Benchmark 数据(Redis 官方,单机,千兆网络):

场景 QPS
GET(pipeline=1) 110,000
SET(pipeline=1) 105,000
GET(pipeline=16) 1,100,000
LPUSH(pipeline=1) 100,000
HSET(pipeline=1) 95,000

注意:上述数字是单节点数据。Redis Cluster 可线性扩展,16节点理论 QPS 接近 160 万/秒。

多线程竞争下的性能损耗:以 Memcached(多线程)为例,在 8 core 机器上,当并发连接从 1 增加到 64 时,吞吐量增长是亚线性的(约 4x,而非 8x),原因是 slab allocator 的全局锁竞争。

1.3.3 单线程的真实瓶颈

单线程的瓶颈在于:

  1. CPU 密集型命令:SORT、KEYS *(全量扫描)、LRANGE 0 -1(大 List)会阻塞整个服务
  2. 单核性能天花板:约 100-200 万 QPS(取决于命令复杂度)
  3. 大 Value 的序列化/反序列化

解决方案:使用 SCAN 替代 KEYS,使用 UNLINK 替代 DEL(异步删除),避免超过 10KB 的大 Value。

1.4 事件驱动架构:Reactor 模式

Redis 的网络层基于 Reactor 模式,核心是 ae(Async Events)库,约 800 行 C 代码。

                    ┌─────────────────────┐
  客户端连接 ──────→ │   epoll/kqueue/select│
                    │    (I/O 多路复用)    │
                    └──────────┬──────────┘
                               │ 就绪事件
                    ┌──────────▼──────────┐
                    │    事件分发器         │
                    │   (aeEventLoop)     │
                    └──────────┬──────────┘
                               │
              ┌────────────────┼────────────────┐
              │                │                │
   ┌──────────▼──┐  ┌──────────▼──┐  ┌─────────▼───┐
   │ 读事件处理   │  │ 写事件处理   │  │ 时间事件处理 │
   │(读命令请求)  │  │(发送响应)    │  │(过期检查等)  │
   └─────────────┘  └─────────────┘  └─────────────┘

epoll 的选择:Linux 使用 epoll,macOS 使用 kqueue,FreeBSD 也是 kqueue,旧系统 fallback 到 select。epoll 的优势是 O(1) 的就绪事件获取(select 是 O(n) 轮询)。

Redis 启动时会根据平台自动选择最优实现:

/* ae.c */
#ifdef HAVE_EVPORT
#include "ae_evport.c"   // Solaris
#elif defined(HAVE_EPOLL)
#include "ae_epoll.c"    // Linux
#elif defined(HAVE_KQUEUE)
#include "ae_kqueue.c"   // macOS/BSD
#else
#include "ae_select.c"   // 兜底
#endif

为何不用线程池:线程池需要任务队列、工作线程、锁机制。对于 Redis 这种每个命令执行时间极短(几微秒)的场景,线程调度和同步的开销可能超过命令执行本身。Nginx 同样采用这种哲学。

1.5 与 MySQL / MongoDB 的分工

1.5.1 三维对比

维度 Redis MySQL MongoDB
存储层 内存(可选磁盘) 磁盘(InnoDB) 磁盘(WiredTiger)
读延迟 亚毫秒(<1ms) 1-10ms 1-10ms
写延迟 亚毫秒(<1ms) 5-20ms 2-10ms
数据容量 受内存限制 TB 级 TB 级
查询能力 Key-based,有限 完整 SQL 丰富的文档查询
事务支持 有限(Lua/MULTI) ACID 多文档事务(4.0+)
一致性 最终一致(主从) 强一致 可配置
数据模型 键值/多种结构 关系表 文档(BSON)

1.5.2 协作架构示例

典型电商场景的数据分工:

MySQL(主数据库)
├── users 表 - 用户基本信息(强一致性要求)
├── orders 表 - 订单记录(事务性写入)
├── products 表 - 商品目录(结构化查询)

Redis(加速层)
├── user:{id} - 用户 Profile 缓存(TTL=1h)
├── product:{id}:stock - 库存(原子 DECR 扣减)
├── session:{token} - 用户会话
├── rank:sales:daily - 每日销售排行榜
├── cart:{user_id} - 购物车(Hash 结构)
└── rate:order:{user_id} - 下单频率限制

MongoDB(日志/分析)
└── events 集合 - 用户行为日志(非结构化,写多读少)

1.6 何时不该用 Redis

Redis 不是万能药,以下场景应该避免或谨慎使用:

1.6.1 数据量超出内存上限

Redis 将所有数据保存在内存中。当数据集达到服务器可用内存的 70-80% 时,操作系统的内存管理开销(页面替换、swap)会导致性能急剧下降。

如果你的数据集是 500GB,而服务器内存是 64GB,你需要的是 Aerospike(SSD 原生存储)或 RocksDB,而不是 Redis。

1.6.2 需要复杂查询

Redis 没有 JOIN,没有 WHERE 子句,没有聚合函数(除了 HyperLogLog 和 Bitmap 的特殊计数)。如果你发现自己在应用层做大量的数据关联和过滤,说明这个场景更适合数据库。

# Redis 无法做这样的查询:
# SELECT * FROM orders WHERE amount > 100 AND status = 'pending' ORDER BY created_at

# 你只能用多个 Key 组合查询,效率极低:
SMEMBERS orders:status:pending          # 获取所有 pending 订单 ID
SMEMBERS orders:amount:gt:100           # 获取金额>100 的订单 ID(需要预维护这个集合)
SINTER orders:status:pending orders:amount:gt:100  # 取交集

1.6.3 强一致性要求

Redis 主从复制是异步的。主节点写入后,在数据同步到从节点之前,从节点读取可能返回旧值。主节点崩溃时,已写入主节点但未复制的数据会丢失。

金融交易、库存扣减(高精度)、支付系统等场景,如果不能容忍数据丢失,需要用 MemoryDB for Redis(多 AZ 持久化)或者直接用 MySQL。

1.6.4 冷数据

Redis 是按内存计费的。如果数据很少访问(每天访问一次的报表数据),用 Redis 存储纯属浪费内存资源。这类数据应该放在对象存储(S3/OSS)或数据仓库中。

1.6.5 超大 Value 的存储

超过 1MB 的 Value(图片、大 JSON 文档、压缩包)会严重影响 Redis 性能:

规则:单个 Value 超过 10KB 应当警惕,超过 100KB 应当重新设计。大对象放 S3/OSS,Redis 只存引用。

1.7 小结

Redis 的核心设计哲学是:在正确的场景下,用最简单的工具做最快的事。它的单线程模型、内存存储、丰富的数据结构,构成了一个内部高度一致的设计体系。

理解 Redis 的边界与场景,比记忆命令语法更重要。下一章将深入对比 Redis 的开源竞品,帮助你在技术选型时做出更精准的判断。

本章评分
4.7  / 5  (123 评分)

💬 留言讨论