Skip to content

Redis 基础

当数据库 CPU 因为热点查询被推高时,缓存是常见的缓解手段。一条 SELECT * FROM users WHERE id = ? 被调用了每秒几万次——用户信息、权限、配置,每个请求都在查库。把热点数据放到内存里,数据库立刻回到正常水位。

缓存的实现方式有几种:在数据库里建缓存表(瓶颈在磁盘 IO,即使是 SSD,随机读延迟也比内存高几个数量级)、在应用内存里维护 HashMap(进程重启数据丢失,多实例部署各自维护一份导致数据不一致)。Redis 提供了一个独立的、可共享的内存数据服务,解决这些问题。

同类的内存缓存还有 Memcached。两者都常驻内存、都很快,但 Redis 的数据结构支持在服务端做运算——原子递增、集合交并差、排行榜排名——客户端和 Redis 之间一次往返就能拿到结果。Memcached 只存字符串,要做运算得先把数据取回客户端、算完再写回去,高并发下多次往返的延迟和竞争窗口都是问题。

Redis 是一个内存中的键值数据库,所有数据常驻内存,读写延迟通常在亚毫秒级。它不像关系数据库那样用表格和 SQL,而是直接操作键和值——值可以是字符串、哈希、列表、集合、有序集合等几种数据结构。

"常驻内存"意味着两件事:读写快,但数据量受物理内存限制。Redis 提供了持久化机制把数据保存到磁盘(RDB 快照和 AOF 日志),但内存仍然是数据的主存位置——磁盘文件只用于重启恢复,正常运行时不从磁盘读数据。

Redis 的单线程模型(指命令处理部分,6.0 之后 I/O 线程可以分担网络读写)避免了多线程对共享数据加锁的开销。每条命令在某个时刻只被一个操作处理,不会出现两个命令同时修改同一个键的场景。代价是单条慢命令(比如对一个很大的集合做复杂聚合运算)会堵住后面所有请求。

一、基本数据结构

Redis 的数据结构不只是"能存什么"——每种结构决定了"能对它做什么操作"以及"这些操作的时间复杂度是多少"。选错数据结构,功能上能跑通,但性能可能差几个数量级。

String

String 是 Redis 最底层的结构——整数、浮点数、JSON 文本、序列化后的对象,存进去都是字节串。单键单值,最大 512 MB。"二进制安全"的意思是 Redis 不关心字节串里是什么内容,不会因为遇到 \0 就截断。SET/GET 做基本读写,这里不展开。

String 的真正威力在于原子计数。INCR/DECR 利用单线程模型,对同一个 key 的递增操作天然没有竞争窗口——不需要事务包裹,不需要分布式锁,一行命令就能保证原子递增:

bash
SET article:1001:views 0
INCR article:1001:views       # -> 1
INCRBY article:1001:views 10  # 一次加 10

INCR 要求值能解析为整数,对非数字字符串执行 INCR 会报 ERR value is not an integer or out of range。排查时先确认目标 key 的当前值:GET article:1001:views,如果是非数字字符串(比如被其他 SET 命令覆盖成了文本),INCR 就会失败。这个错误在多人协作的项目里很常见——A 用 SET 存了 JSON,B 用 INCR 想计数,两个操作指向同一个 key。

INCR 的应用场景比直觉更广:接口限流(INCR rate:user:1001,配合 EXPIRE 做滑动窗口)、全局序列号生成(INCR order:seq 永远不重复)、分布式环境下的计数器(PV/UV 统计)。这些场景的共同点是"多客户端并发修改同一个值"——单线程 + INCR 的组合天然解决了这个问题,比在应用层维护计数器再定期刷到 Redis 简单得多,也更可靠。

分布式锁常用 SET ... NX EX——只有 key 不存在时才写入并带上过期时间:

bash
SET lock:order:1001 "thread-5" NX EX 30

NX(Not eXists)保证只有一个客户端能拿到锁,EX 30 保证即使拿到锁的客户端崩溃了,锁也会在 30 秒后自动释放,不会永久死锁。但释放锁时需要验证 value 是不是自己的——如果直接 DEL,可能把自己锁超时后别人拿到的锁删掉。这个校验和删除的两步操作需要用 Lua 脚本保证原子性(见下文 Lua 脚本部分)。

分布式锁看起来简单,实际坑不少。最常见的两个:一是锁超时但业务没执行完,锁被其他客户端抢走,两个客户端同时认为自己持有锁;二是 Redis 主从切换时,锁信息还没同步到从库,新主库上没有这把锁。第一个问题用"续期"机制缓解(后台线程定期延长 TTL),第二个问题靠 Redis 本身解决不了——需要 Redlock 算法或接受"锁可能失效"的事实,在业务层做幂等保护。

Hash

一个 key 下挂多个 field-value 对,适合存对象属性:

bash
HSET user:1001 name "zhangsan" age "28" city "beijing"
HGET user:1001 name             # -> "zhangsan"
HGETALL user:1001               # 取出所有 field 和 value

如果一个用户有几十个属性,用 Hash 存比用多个 String key(user:1001:nameuser:1001:age...)更省 key 数量,也更好管理。但 HGETALL 对 field 数量大的 hash 是 O(n)——几百个 field 可以,几千个就要考虑用 HSCAN 分批取。生产环境中,HGETALL 出现在慢日志里最常见的原因就是 field 数量超出了预期。

一个容易被忽略的点:Hash 在 field 数量少时用 ziplist(压缩列表)编码,内存占用很小;field 数量超过阈值后转成 hashtable 编码,内存占用会跳涨。这个阈值由 hash-max-ziplist-entries(默认 512)和 hash-max-ziplist-value(默认 64 字节)控制。如果业务上一个 Hash 存了几百个 field 但每个 field 的值都很短,内存可能突然在某个时刻翻倍——就是因为触发了编码转换。

List

底层是双向链表,首尾操作 O(1),中间操作 O(n)。LPUSH + RPOP 组合做 FIFO 队列,BLPOP/BRPOP 支持阻塞等待——队列为空时消费者挂起,有新元素时立刻返回,应用层不用轮询:

List 可以当轻量队列用,但它有三个硬伤:没有消息确认机制(消息被 RPOP 就消失了,消费者崩溃等于丢消息)、没有消费者组(多个消费者抢同一个消息,不能广播)、没有持久化保障(如果消息在 RDB 快照之前没被消费,快照恢复后消息还在;但如果用的是 AOF 且消息已经被消费了,AOF 重放时可能重复消费)。需要这些能力时用 Stream 更合适——Redis 5.0 引入的 Stream 在 List 的基础上补了消费者组、消息 ID 和 ACK 机制。

Set

底层是哈希表,增删查都是 O(1)。用于去重和交并差运算:

bash
SADD article:1:tags "redis" "database" "nosql"
SISMEMBER article:1:tags "redis"       # 是否存在
SINTER article:1:tags article:2:tags   # 两篇文章的共同标签
SUNION article:1:tags article:2:tags   # 两篇文章的所有标签

SINTER(交集)、SUNION(并集)、SDIFF(差集)的时间复杂度是 O(n),n 是参与运算的元素总数。集合很大时(几万甚至更多元素)这些运算会阻塞命令处理线程,其他请求全部排队等待。大数据量下的交并差更适合在从库或离线分析环境跑。

Set 的交集运算有一个实际应用场景:社交关系中的"共同好友"。SINTER user:1001:friends user:1002:friends 一条命令就能算出来。但如果两个用户的关注列表各有几十万人,这条命令会阻塞 Redis 数秒——这种场景应该在应用层分批取、分批算,或者把数据导出到专门的分析系统。

Sorted Set

每个元素关联一个 score(浮点数),按 score 排序。底层是跳表 + 哈希表(跳表可以理解为"多层索引的有序链表"——想象一本书的目录:第一层是章,第二层是节,第三层是具体页码。查找时从章开始定位,再缩小到节,最后到页,效率接近二分查找,但实现比红黑树简单):

bash
ZADD leaderboard 100 "player1" 95 "player2" 88 "player3"
ZRANGE leaderboard 0 -1 WITHSCORES         # 按 score 升序
ZREVRANGE leaderboard 0 2 WITHSCORES       # 前 3 名
ZREVRANK leaderboard "player1"             # 排第几(从 0 开始)
ZSCORE leaderboard "player1"               # 查分数

排行榜是 Sorted Set 的经典场景——插入或更新分数是 O(log n),查排名也很快。其他用途包括带权重的任务队列、按时间排序的事件流、延迟队列。

Sorted Set 做延迟队列的思路:score 存"应该被处理的时间戳",消费者用 ZRANGEBYSCORE 取出到期的任务,处理完用 ZREM 删除。这比 List 做队列多了一个"定时触发"的能力,但同样没有 ACK 机制——消费者崩溃后任务就丢了。生产环境的延迟队列通常用专门的消息中间件(如 RocketMQ 延迟消息)更稳妥。


除了这五类,Redis 后续版本还引入了 Stream(带消费者组和消息确认的消息队列)、Geospatial(地理位置索引)、HyperLogLog(基数估算)、Bitmap(位操作)等。Stream 适合需要消息持久化、消费者组和消息确认的场景——相比 List 做队列,Stream 多出了消费者组和多消费者独立消费的能力。

二、键空间操作

Redis 的 key 没有"表"的概念——整个实例是一个平坦的键空间。键的命名规范是人为约定的,常用 业务:对象:id:属性 这种模式:

bash
KEYS user:1001:*         # 列出匹配的所有 key
EXISTS user:1001:name    # key 是否存在
TYPE user:1001:name      # 数据类型
DEL user:1001:name       # 删除 key

KEYS 在生产环境不安全——它会遍历整个键空间,在上百万个 key 的实例上执行 KEYS * 会让 Redis 阻塞数秒甚至更久。替代方案是 SCAN,游标式迭代,一次只返回一批:

bash
SCAN 0 MATCH user:1001:* COUNT 100

SCAN 返回一个新的游标和一批 key,反复用新游标调用直到游标回到 0 表示遍历完成。但 SCAN 有两个容易被忽略的问题:一是它遍历的是变化的键空间而不是快照——可能返回重复的 key(遍历过程中有增删),也可能漏掉部分 key(rehash 导致);二是 COUNT 参数只是"建议数量",实际返回的 key 数量可能远多于或远少于 COUNT。所以 SCAN 适合巡检、清理等不需要精确全量的场景,不适合"必须不漏"的需求。

生产环境中误执行 KEYS user:* 的现象:Redis 延迟突然飙高、监控图上出现一个明显的延迟毛刺、应用侧大量请求超时。排查时在慢日志里能看到 KEYS 命令的执行时间;redis-cli --latency-history 也能检测到对应的延迟峰值。KEYS 执行期间所有其他命令都在排队——不是 Redis 挂了,而是被一条命令堵住了。

三、过期策略

Redis 可以对每个 key 设置存活时间:

bash
EXPIRE session:abc123 3600           # 3600 秒后过期
EXPIREAT session:abc123 1717257600   # 在指定 Unix 时间戳过期
TTL session:abc123                   # 还剩多少秒
PERSIST session:abc123               # 取消过期,变成永久 key

TTL 返回值:正数表示剩余秒数,-1 表示 key 存在但没有过期时间,-2 表示 key 不存在。

Redis 的过期删除不是给每个 key 设一个定时器——几百万个定时器开销太大。实际采用两种机制配合:

  • 惰性删除:访问一个 key 时检查是否过期,过期了就删掉并返回 nil。如果过期的 key 一直没人访问,它不会被惰性删掉。
  • 定期删除:后台每秒执行多次,每次随机抽一批设置了过期时间的 key,把其中过期的删掉。是抽检而非全扫。

两种机制配合意味着:key 过期和内存释放之间有时间差。恰好这个时间差内的 key 被访问了,会被惰性删除;没有被访问的话会等到下次定期扫描才删。对内存敏感的场景,这个时间差需要心里有数——短时间内可能看到过期 key 仍占着内存。

大量 key 集中在同一秒过期时(比如整点设置的缓存),定期删除可能短时间消耗明显 CPU,同时内存会出现一个"先占后降"的锯齿。给 TTL 加一点随机值可以打散过期时间:

bash
# 基础 300 秒,加 0-60 秒随机抖动,避免大量 key 同时过期
ttl=$((300 + RANDOM % 61))
redis-cli SET cache:user:1001 "$value" EX "$ttl"

这个技巧在缓存雪崩的预防中是基础操作。不加随机 TTL 的后果很具体:假设一批缓存是 0 点整写入的、TTL 都是 3600 秒,那么 1 点整这批 key 同时过期,所有请求同时穿透到数据库——数据库从"几乎没压力"瞬间变成"被打满"。

缓存 key 一般要有过期时间。没有过期时间的缓存,后面容易变成"没人敢删"的历史数据,慢慢消耗内存直到 OOM。会话、验证码、临时锁这类 key 更是要带 TTL。

过期策略和内存淘汰是一体两面——key 到期不一定会被立刻删除(惰性删除 + 定期删除的配合),内存满了又有另一套淘汰机制。缓存穿透、击穿、雪崩这三个问题也和过期策略直接相关。这些在 04-内存与性能 中详细展开。

四、Lua 与原子操作

Redis 执行 Lua 脚本时,整个脚本被当作一个原子操作——脚本执行期间其他命令无法插入。适合"读-判断-写"这类不想被中途打断的场景:

lua
-- 释放锁的正确方式:先检查 value 是不是自己的,再删
-- 如果不用 Lua,GET + DEL 之间不是原子的,可能误删别人的锁
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

EVAL 执行:

bash
redis-cli EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order:1001 thread-5

EVALSHA 可以把脚本缓存在 Redis 里,后续只传 SHA1 不传完整脚本,高频调用时减少网络传输量。

Lua 脚本的原子性是把双刃剑:能保证操作不被打断,但如果脚本执行时间太长(比如循环操作大量 key),整条命令处理线程都会被它占用。Redis 默认会在 Lua 执行超过 5 秒时打一条日志警告(lua-time-limit 配置),但不会主动中断——因为它无法判断脚本是否是幂等的,贸然中断可能导致数据不一致。Lua 脚本里要避免循环次数不确定或者集合大小不确定的操作。

五、发布订阅

Redis 的 Pub/Sub 是消息广播机制——发布者往 channel 发消息,订阅了该 channel 的所有客户端都会收到:

bash
SUBSCRIBE news:system               # 订阅频道
PUBLISH news:system "server down"   # 发消息

Pub/Sub 的消息是"即发即忘"——没有持久化,没有确认,没有消费者组。订阅者离线期间的消息就丢了。它只适合通知性质的消息("配置变了,请重新加载")。如果业务需要消息不丢、不重、有消费者组,直接用 Stream,不要试图在 Pub/Sub 上修补可靠性。

Pub/Sub 还有一个容易被忽略的行为:如果订阅者处理消息的速度跟不上发布速度,Redis 会把消息缓存在客户端的输出缓冲区里。缓冲区满了(超过 client-output-buffer-limit 的限制)会被 Redis 主动断开连接——连接断了,后续消息也全丢了。所以 Pub/Sub 的消费端要做好背压处理,或者干脆用 Stream 替代。

六、状态查看

bash
redis-cli INFO server       # 版本、进程、运行时间
redis-cli INFO clients      # 客户端连接情况
redis-cli INFO memory       # 内存使用情况
redis-cli INFO stats        # 命令、连接、命中率等统计
redis-cli INFO persistence  # RDB/AOF 持久化状态

几个基础指标:

指标含义
used_memoryRedis 分配器统计的内存
connected_clients当前客户端连接数
blocked_clients阻塞等待的客户端数(比如 BLPOP 等待)
expired_keys已过期删除的 key 累计数
evicted_keys因内存达上限被淘汰的 key 累计数
keyspace_hits/misses缓存命中和未命中次数

evicted_keysexpired_keys 的区别:前者是因为内存满了被 maxmemory 策略踢掉的,后者是到期后被过期机制删掉的。evicted_keys 持续增长说明内存不够用——要么扩内存,要么清理无用 key,要么调整淘汰策略。

缓存命中率粗略估算:keyspace_hits / (keyspace_hits + keyspace_misses)。命中率突然下降的常见原因:大量 key 集中过期、发布后 key 命名规则变了导致新旧 key 不重合、Redis 数据被清空或切换到了空实例。命中率这个指标要结合业务看——99% 的命中率听起来很好,但如果每秒 10 万次请求,1% 未命中就是每秒 1000 次穿透到数据库,数据库可能扛不住。

七、性能压测

redis-benchmark 是 Redis 自带的压测工具,用来对单机 Redis 做基本的吞吐和延迟摸底——不是性能调优的最终结论,而是"这台机器上的这个 Redis 实例大概是什么水平":

bash
redis-benchmark -h 127.0.0.1 -p 6379 -c 100 -t set,get -n 100000 -q

-c 100 是 100 个并发连接,-t 指定只测 SET 和 GET,-n 100000 总共发 10 万个请求,-q 只输出简要结果。输出示例:

text
SET: 95238.10 requests per second
GET: 102040.82 requests per second

这些数字在普通服务器上(几十个并发连接、小 Value)大概在 8-12 万 QPS 量级。实际业务 QPS 受 Value 大小、网络延迟、Pipeline 使用等因素影响。压测时要在和生产环境接近的硬件和网络条件上跑,否则数据没参考价值。

redis-benchmark 的结果容易被误读。它测的是"最好情况"——Value 只有几个字节、纯 SET/GET、没有复杂数据结构、客户端和 Redis 在同一台机器上。实际业务中 Value 可能是几 KB 的 JSON、会用 HSET/HGET 甚至 SINTER、客户端跨网络访问——这些因素叠加后,实际 QPS 可能只有压测结果的 1/3 到 1/5。所以 redis-benchmark 的定位是"摸底"和"对比不同配置的差异",不是"业务能跑到多少 QPS"的结论。

--pipeline 参数模拟批量发送命令,用来测单连接上的吞吐上限:

bash
redis-benchmark -h 127.0.0.1 -p 6379 -c 1 -t set -n 100000 -P 16 -q

-P 16 表示一次发送 16 条命令,减少 RTT 的影响。Pipeline 下 QPS 远高于单条发送,因为大量减少了网络往返开销。

Pipeline 不是事务——中间某条命令失败不会回滚前面的,后续命令仍会继续执行。批量写入时要控制每批的大小:一批塞太多命令会让 Redis 的输出缓冲区膨胀,客户端也可能因为一次接收太多响应而超时。