Redis 核心使用
String:最万能的一类
String 是唯一一个能直接当「数字」用的类型,也是缓存场景的主力。常用命令:
| 命令 | 作用 | 示例 |
|---|---|---|
SET | 设置值,支持 EX/PX/NX/XX 选项 | SET user:1:name tom EX 600 |
GET | 取值,key 不存在返回 nil | GET 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/0 | SETNX 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 | 阻塞式右弹,超时返回 nil | BRPOP 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 | 添加元素并指定 score | ZADD 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
- 脚本默认 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 三套高可用方案怎么选怎么搭,见《部署与高可用》。