跳到主要内容

Redis 核心使用

String:最万能的一类​

String 是唯一一个能直接当「数字」用的类型,也是缓存场景的主力。常用命令:

命令作用示例
SET设置值,支持 EX/PX/NX/XX 选项SET user:1:name tom EX 600
GET取值,key 不存在返回 nilGET user:1:name
MSET / MGET批量设置 / 批量取值MGET user:1:name user:2:name
INCR / DECR原子加一 / 减一INCR article:1001:views
INCRBY原子加减指定整数INCRBY stock:1001 -2
SETEX设置值并带过期时间SETEX token:abc 3600 xyz
SETNX不存在才设置,返回 1/0SETNX lock:job 1
EXPIRE设置过期时间(秒)EXPIRE session:abc 3600
STRLEN / APPEND取长度 / 追加内容APPEND log:1 "done"

几个容易忽略的点:

  • INCR 只能用于能解析成整数的值,对 "abc" 执行会报 ERR value is not an integer or out of range;上限是 64 位有符号整数。
  • SET 会清掉原 key 的 TTL。对一个带过期时间的 key 重新 SET,过期时间就没了,这是「缓存突然变成永久 key」的常见原因。要保持 TTL 用 SET ... KEEPTTL(6.0+)。
  • 批量操作省的是网络往返,不是 CPU。MGET 一次拿 100 个 key 比循环 100 次 GET 快一个数量级,但 key 太多会撑大客户端输出缓冲,100 个左右一批比较稳。

分布式锁:从 SETNX 到 SET NX PX​

用 Redis 做锁的起点是 SETNX:key 不存在才设置成功,谁设置成功谁持有锁。但 SETNX 只负责互斥,过期得另想办法,于是就有了下面这种写法:

# 错误示范:两条命令不是原子的
SETNX lock:order:1001 1
EXPIRE lock:order:1001 30

如果进程在两条命令之间崩了,锁就永远不过期,后面所有请求全部卡死。正确写法是一条命令搞定互斥 + 过期:

# 加锁:value 用唯一 token,NX 保证互斥,PX 保证不会永久死锁
SET lock:order:1001 3f8a1c9d NX PX 30000

# 返回 OK 表示拿到锁;返回 nil 表示已被别人持有

释放锁也不能直接 DEL——万一业务执行时间超过 30 秒,锁已经过期并被别人拿走,此时 DEL 删的就是别人的锁。必须校验 token 再删,而且校验和删除要原子,用 Lua:

-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
redis-cli --eval unlock.lua lock:order:1001 , 3f8a1c9d

--eval 的语法是「脚本文件 + key 列表 + 逗号 + 参数列表」,逗号前后要有空格,否则会被当成参数的一部分。上面命令里 lock:order:1001 是 KEYS[1],3f8a1c9d 是 ARGV[1]。

分布式锁的五个坑
  • 锁过期但业务没跑完:30 秒的锁跑了 40 秒的业务,第二个请求进来了,两个请求同时改数据。要么业务加超时,要么起一个看门狗线程定期续期,要么用 fencing token 让下游拒绝旧持有者的写。
  • 误删别人的锁:不校验 token 直接 DEL,A 的锁过期后 B 拿到锁,A 执行完把 B 的锁删了。必须用上面的 Lua 校验。
  • 主从切换丢锁:锁写在主库,还没同步到从库主库就挂了,从库升主后锁没了。这是异步复制的固有问题,Redlock 想解决它但争议很大;对一致性要求极高的场景(比如扣款)别只靠 Redis 锁,用数据库行锁或 ZooKeeper/etcd。
  • 不可重入:同一线程第二次加锁会失败。需要重入就用 Hash 存「持有者 + 重入次数」,自己封装。
  • 锁竞争激烈:大量请求空转重试会把 Redis 打满。加随机退避,或者先抢本地锁再抢分布式锁。

Hash:存对象的多个字段​

命令作用示例
HSET设置一个或多个字段HSET user:1 name tom age 20
HGET取单个字段HGET user:1 name
HMGET取多个字段HMGET user:1 name age
HGETALL取全部字段和值HGETALL user:1
HDEL删除字段HDEL user:1 age
HLEN字段数量HLEN user:1
HEXISTS字段是否存在HEXISTS user:1 name
HINCRBY字段原子加减HINCRBY cart:1 count 1

Hash 适合「一个对象、字段多、只改其中几个」的场景,比如用户资料、购物车。但 HGETALL 是 O(N),字段上万时就是慢命令,线上要么改用 HMGET 取需要的字段,要么把大 Hash 拆开。

List:顺序 + 两端进出​

命令作用示例
LPUSH / RPUSH左 / 右插入RPUSH queue:mail job-1
LPOP / RPOP左 / 右弹出LPOP queue:mail
BRPOP阻塞式右弹,超时返回 nilBRPOP queue:mail 5
LRANGE按索引范围取LRANGE queue:mail 0 -1
LLEN列表长度LLEN queue:mail
LTRIM只保留指定区间LTRIM feed:1 0 99
LREM删除指定元素LREM queue:mail 0 job-1
LMOVE从一个列表原子移到另一个LMOVE queue:mail queue:done LEFT RIGHT

用 List 做队列的经典姿势是 LPUSH 生产、BRPOP 消费,或者反过来。注意 BRPOP 是阻塞命令,会占住一个连接,连接池要单独给它留余量;而且它不保证「至少消费一次」——消费者弹出后崩溃,这条消息就丢了,要可靠投递用 Stream 的消费组。

Set:去重与集合运算​

命令作用示例
SADD添加元素SADD tag:redis user:1 user:2
SREM删除元素SREM tag:redis user:1
SMEMBERS取全部元素SMEMBERS tag:redis
SISMEMBER判断是否存在SISMEMBER tag:redis user:1
SCARD元素个数SCARD tag:redis
SINTER交集SINTER tag:redis tag:db
SUNION并集SUNION tag:redis tag:db
SPOP随机弹出一个SPOP lottery:pool 1

SMEMBERS 同样是 O(N),百万级集合会直接阻塞实例,线上用 SSCAN 分批取。SPOP 天然适合抽奖、随机发券。

ZSet:带分数的有序集合​

命令作用示例
ZADD添加元素并指定 scoreZADD rank:score 100 user:1
ZRANGE按排名范围取(可加 WITHSCORES)ZRANGE rank:score 0 9 WITHSCORES
ZREVRANGE按排名倒序取ZREVRANGE rank:score 0 9
ZRANGEBYSCORE按 score 范围取ZRANGEBYSCORE rank:score 90 100
ZINCRBY分数原子增减ZINCRBY rank:score 5 user:1
ZSCORE查某个元素的分数ZSCORE rank:score user:1
ZRANK查正序排名(从 0 开始)ZRANK rank:score user:1
ZREM / ZCARD删除元素 / 元素总数ZCARD rank:score

ZSet 的看家场景是排行榜和延时队列:把「执行时间戳」当 score,用 ZRANGEBYSCORE key 0 now LIMIT 0 10 就能取出到期任务。它的底层是跳表 + 字典,插入、删除、按分数查都是 O(log N),几十万元素也不虚。

事务:MULTI / EXEC​

Redis 的事务是把一组命令打包,MULTI 开始入队,EXEC 一次性按顺序执行:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR order:1001:count
QUEUED
127.0.0.1:6379> EXPIRE order:1001:count 60
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 1
2) (integer) 1

它和关系型数据库的事务差别很大,别按 MySQL 的直觉用:

  • 没有回滚。执行阶段某条命令出错(比如对 String 用 LPUSH),其余命令照常执行,错误只体现在返回值里。只有入队阶段就报错(命令名拼错、参数个数不对)时,EXEC 才会整体拒绝。
  • 没有隔离级别。单线程串行执行,EXEC 期间不会插入其他客户端的命令,天然隔离。
  • DISCARD 放弃事务,清空队列。
  • WATCH 是乐观锁。先 WATCH 一个 key,EXEC 时如果它被别的客户端改过,整个事务返回 nil,需要业务重试。
127.0.0.1:6379> WATCH stock:1001
OK
127.0.0.1:6379> GET stock:1001
"10"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY stock:1001 1
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 9

EXEC 返回 nil 就说明 WATCH 的 key 被动过,要重新读值再来一轮。冲突少时这套很轻量;冲突多时重试会放大流量,不如直接用 Lua。

Lua 脚本:把逻辑搬到服务端​

Lua 能在服务端做条件判断,是让「读-判断-写」整体保持原子性的手段(MULTI/EXEC 只能保证批量执行,做不了条件分支),同时还能减少网络往返。最基础的用法:

# EVAL 脚本 参数个数 key... arg...
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 demo:key hello
  • KEYS 和 ARGV 是两个数组,脚本里按下标访问,从 1 开始。
  • 所有 key 必须通过 KEYS 传,不能在脚本里拼字符串——集群模式下 Redis 要靠 KEYS 判断槽位。
  • 脚本执行期间不会穿插其他命令,天然原子;但脚本里也不能有慢操作,否则整个实例卡住。

实际一点:用 Lua 做固定窗口限流,一次网络往返完成「自增 + 首次设过期 + 判断超限」。

-- rate_limit.lua:窗口内最多 limit 次,返回 1 放行、0 拒绝
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end

if current > limit then
return 0
end
return 1
# 每个 IP 每分钟最多 100 次
redis-cli --eval rate_limit.lua ratelimit:api:10.0.0.1 , 100 60

脚本反复执行时别每次都传全文,用 SCRIPT LOAD 拿到 sha1,之后用 EVALSHA 调用,省带宽:

redis-cli SCRIPT LOAD "$(cat rate_limit.lua)"
# 返回一长串 sha1,例如 8f2b...
redis-cli EVALSHA 8f2b... 1 ratelimit:api:10.0.0.1 100 60
Lua 的硬限制
  • 脚本默认 5 秒超时(lua-time-limit),超时后实例进入 BUSY 状态,只能 SCRIPT KILL(脚本没写过数据时)或 SHUTDOWN NOSAVE 兜底。脚本里禁止死循环和无界遍历。
  • 不要在脚本里 KEYS 全库扫,也不要一次处理几万个元素,1 万次 redis.call 就能让实例卡住几十毫秒。
  • 集群模式下所有 key 必须同槽,否则报 CROSSSLOT。需要多个 key 时用 hash tag 把它们绑在一起。
  • 7.0 起默认按效果复制(effect replication),脚本里的随机、时间函数不会再导致主从数据不一致,但脚本本身仍要保证幂等。

key 命名规范与 TTL 习惯​

命名没有强制标准,但团队里必须统一,否则半年后没人看得懂。推荐这套:

业务线:对象:标识[:字段]
user:1001:profile        # 用户资料 Hash
user:1001:token # 用户 token String
order:20240501:queue # 某天的订单队列 List
rank:score:2024w20 # 某周的排行榜 ZSet
lock:order:1001 # 订单锁
ratelimit:api:10.0.0.1 # 限流计数

几条硬规矩:

  • 冒号分层、全小写、不用空格和换行,key 长度控制在几十字节内。key 本身也占内存,百万个长 key 能吃掉几百 MB。
  • 不要把用户输入直接拼进 key,先做白名单或哈希,避免 key 爆炸和注入奇怪字符。
  • 集群里用 hash tag 控制同槽,例如 cart:{1001}:items 和 cart:{1001}:total 会被分到同一个槽,可以做多 key 操作。花括号里的内容才是计算槽位的部分。
  • 别用 KEYS 做前缀查询,用 SCAN 游标遍历;更彻底的做法是维护一个 Set 记录所有 key,但要注意它自身的容量。

TTL 习惯比命名更重要,缓存事故一大半来自「忘了设过期」:

  • 所有缓存 key 必须带 TTL,用 SET key value EX 600 一步到位,不要先 SET 再补 EXPIRE。
  • 大批量 key 不要同一秒过期,在基础 TTL 上加 0~300 秒随机抖动,避免缓存雪崩。
  • 永久 key 要评审,配置、字典这类确实需要长期存在的,单独放一个库或用固定前缀,别和缓存混在一起。
  • 定期巡检:用 SCAN 抽样配合 TTL 找出 -1 的 key,尤其是那些本该是缓存的。
# 抽样检查没有过期时间的 key(示例,量大时限制扫描数量)
redis-cli --scan --pattern 'cache:*' | head -100 | while read -r k; do
ttl=$(redis-cli TTL "$k")
[ "$ttl" = "-1" ] && echo "no ttl: $k"
done

小结​

  • String 是缓存和计数的主力,SET 会清 TTL,批量用 MGET;分布式锁用 SET NX PX + Lua 校验删除,别用 SETNX + EXPIRE。
  • Hash/List/Set/ZSet 各自对应对象、队列、去重、排序,HGETALL、SMEMBERS、LRANGE 这类 O(N) 命令要控制规模。
  • 事务只保证串行执行,没有回滚;WATCH 是乐观锁,冲突多就换 Lua。
  • Lua 是原子性和减少往返的利器,但脚本要短、key 要走 KEYS、集群里要同槽。
  • 命名分层、缓存必带 TTL、加随机抖动、用 SCAN 不用 KEYS,这四条能避开大部分日常事故。

单机用明白之后,下一步是让它别挂:主从、哨兵、Cluster 三套高可用方案怎么选怎么搭,见《部署与高可用》。