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 过期删除(后台线程)都是多线程的。
完整的线程模型:
- 主线程:事件循环、命令解析、命令执行
- bio_close_file:异步关闭文件(AOF rewrite 后)
- bio_aof_fsync:AOF fsync 操作
- bio_lazy_free:UNLINK/FLUSHDB ASYNC 的惰性删除
- io_threads(6.0+):网络 I/O 多线程读取请求
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 单线程的真实瓶颈
单线程的瓶颈在于:
- CPU 密集型命令:SORT、KEYS *(全量扫描)、LRANGE 0 -1(大 List)会阻塞整个服务
- 单核性能天花板:约 100-200 万 QPS(取决于命令复杂度)
- 大 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 的序列化阻塞主线程
- 内存碎片化加剧
规则:单个 Value 超过 10KB 应当警惕,超过 100KB 应当重新设计。大对象放 S3/OSS,Redis 只存引用。
1.7 小结
Redis 的核心设计哲学是:在正确的场景下,用最简单的工具做最快的事。它的单线程模型、内存存储、丰富的数据结构,构成了一个内部高度一致的设计体系。
理解 Redis 的边界与场景,比记忆命令语法更重要。下一章将深入对比 Redis 的开源竞品,帮助你在技术选型时做出更精准的判断。