Skip to content

Redis 基础

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

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

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

一、五类基本数据结构

每种数据结构不是"能存什么",而是"数据的组织方式 + 针对这种组织方式能做什么操作"。

String——二进制安全的字节串

String 是 Redis 最底层的结构——整数、浮点数、JSON 文本、序列化后的对象,存进去都是字节串。单键单值,最大 512 MB。

"二进制安全"的意思是:Redis 不关心字节串里是什么内容,不会因为遇到 \0 就截断,也不会尝试按某种字符编码去解析。存什么字节进去,取出来就是什么字节。

bash
SET user:1001:name "zhangsan"
GET user:1001:name

计数操作用的是 INCR/DECR。因为单线程,对同一个 key 的 INCR 不存在"读-改-写"的竞争窗口——不需要事务包裹也能保证原子递增:

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

INCR 要求值能解析为整数,对非数字字符串执行 INCR 会报错。

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

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

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

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 分批取。

List——按插入顺序排列的字符串列表

底层是双向链表,首尾操作 O(1),中间操作 O(n)。常用的不是随机存取,而是当队列或栈用:

bash
LPUSH queue:tasks "task1" "task2"   # 左边进
RPOP queue:tasks                     # 右边出 → FIFO 队列

BLPOP/BRPOP 支持阻塞等待——队列为空时消费者挂起,有新元素时立刻返回,应用层不用轮询:

bash
BLPOP queue:tasks 5   # 最多等 5 秒,超时返回 nil

List 可以当轻量队列用,但它没有消息确认、失败重试、死信队列这些机制。需要这些能力时用 Stream 或专门的消息队列会更合适。

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 是参与运算的元素总数。集合很大时(几万甚至更多元素)这些运算会阻塞命令处理线程。大数据量下的交并差更适合在从库或离线分析环境跑。

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 名
ZRANK leaderboard "player1"                # 排第几(从 0 开始)
ZSCORE leaderboard "player1"               # 查分数

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


除了这五类,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 导致)。所以 SCAN 适合巡检、清理等不需要精确全量的场景,不适合"必须不漏"的需求。

三、过期策略

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 一般要有过期时间。没有过期时间的缓存,后面容易变成"没人敢删"的历史数据。会话、验证码、临时锁这类 key 更是要带 TTL。

四、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),整条命令处理线程都会被它占用。Lua 脚本里要避免循环次数不确定或者集合大小不确定的操作。

五、发布订阅

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

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

Pub/Sub 的消息是"即发即忘"——没有持久化,没有确认,没有消费者组。订阅者离线期间的消息就丢了。它适合通知性质的消息("配置变了,请重新加载"),不适合需要可靠投递的业务消息。

六、基础状态查看

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,要么调整淘汰策略。

七、简单性能压测

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 使用等因素影响。压测时要在和生产环境接近的硬件和网络条件上跑,否则数据没参考价值。

--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 远高于单条发送,因为大量减少了网络往返开销。