我一度以为 Redis 就是个能过期的 String 字典
刚接触 Redis 那两个月,我写的缓存代码全是这个画风:
// 用户信息缓存
redisTemplate.opsForValue().set("user:" + userId, JSON.toJSONString(user), 1, TimeUnit.HOURS);
// 购物车缓存
redisTemplate.opsForValue().set("cart:" + userId, JSON.toJSONString(cartList), 7, TimeUnit.DAYS);
// 商品详情缓存
redisTemplate.opsForValue().set("goods:" + goodsId, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
万物皆可 JSON。直到有次做"修改用户昵称"的功能,我发现每次改一个字段都要把整个用户对象读出来、反序列化、改字段、再序列化写回去。数据量大的时候,一次昵称修改要传输 8KB 的 JSON。
师傅看了我的代码说:"Redis 有五种数据类型,你只用了一个,另外四个是摆设吗?"
String:不只是字符串
String 是 Redis 最基础的类型,也是唯一一个其他四种类型都"嵌套"不了的类型。它能存字符串、整数、浮点数,最大 512MB。
我以前只知道 get/set,实际常用的还有这些:
# 分布式计数器(原子自增)
INCR article:read:10086
INCRBY user:points:9001 50
DECR stock:sku:5588
# 设置过期时间和原子 set
SET lock:order:123 request_id NX PX 30000
# 批量操作,减少网络往返(重要)
MSET user:1:name "张三" user:1:age "28"
MGET user:1:name user:1:age
# 位操作,适合做签到、活跃度统计
SETBIT sign:201811:9001 22 1
BITCOUNT sign:201811:9001
INCR 这类命令是原子的,不需要额外加锁,我用它做过文章阅读数统计。实测单机 Redis 的 INCR 能到 8 万 QPS,比先读后写快得多也安全得多。
String 适合的场景:
- 单个对象的整体缓存(读多写少、通常整存整取);
- 计数器、限流器(
INCR+EXPIRE); - 分布式锁(
SET NX PX); - 共享的动态配置。
选型误区:把结构化的、需要频繁部分更新的对象塞进 String。这就是我犯的错。判断标准很简单——如果你经常需要"读出来改一个字段再写回去",那就不该用 String。
Hash:对象字段要单独更新时用它
Hash 是 key 下面再套一层 field-value 映射,适合存对象。
HSET user:9001 name "张三"
HSET user:9001 age "28"
HSET user:9001 city "杭州"
HMSET user:9001 name "张三" age "28" city "杭州" # 批量
HGET user:9001 name
HGETALL user:9001
HINCRBY user:9001 points 10
HLEN user:9001
改昵称就变成一次操作,不用动其他字段:
HSET user:9001 name "李四"
网络传输量从 8KB 降到 30 字节左右。我们改完之后,用户信息修改接口的平均耗时从 18ms 降到 2.3ms。
但 Hash 有个必须注意的坑:HGETALL 会返回整个 hash。如果一个 hash 里有几百个 field,这个命令会很慢并且可能打满网卡。要用 HSCAN 分批取,或者只取需要的 field(HMGET)。
HSCAN user:9001 0 COUNT 100
另外还有个编码问题很多人不知道。Redis 的 hash 有两种内部编码:
- ziplist(压缩列表):field 数量 ≤ 512 且每个 value ≤ 64 字节时使用,内存紧凑,省空间;
- hashtable:超出上述阈值自动转换,转换不可逆。
# redis.conf
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
用 OBJECT ENCODING 能看到当前编码:
127.0.0.1:6379> OBJECT ENCODING user:9001
"ziplist"
我们的用户 hash 一般就 10 来个字段,全是 ziplist,比存 JSON 字符串省了大概 40% 内存(我对比过 10 万用户的数据,String+JSON 用了 68MB,Hash 用了 41MB)。
List:有序、可重复,适合队列和最新列表
List 是个双向链表(小数据量时用 ziplist),两头操作 O(1),中间按索引访问 O(n)。
LPUSH queue:order "order_10086"
RPOP queue:order # 从右边取,FIFO 队列
LPUSH timeline:user:9001 "动态A" "动态B"
LRANGE timeline:user:9001 0 9 # 取最新 10 条
LTRIM timeline:user:9001 0 99 # 只保留最新 100 条,防止无限增长
我用它做过两个东西:
一是简单的异步任务队列。LPUSH 生产,多个消费者 BRPOP 阻塞消费:
BRPOP queue:sms 30 # 阻塞 30 秒等消息,超时返回 nil
BRPOP 是阻塞式的,不会像轮询那样空转耗 CPU。不过要注意,它不保证消息不丢失——消费者 pop 出来之后进程挂了,这条消息就没了。对可靠性要求高的场景得用 RPOPLPUSH 搞一个备份队列,或者干脆上专业的 MQ。我们后来短信发送换成了 RabbitMQ,List 只用来做那种丢了也无所谓的场景。
二是"最新动态"。LPUSH 加 LTRIM 是很经典的组合,插入的同时裁剪长度,保证列表不会无限膨胀。
误区:用 List 做需要按索引随机访问的分页。LRANGE list 1000 1010 的复杂度是 O(n),数据量大时非常慢。要么改用 ZSet,要么就只保留最新的一页。
Set:无序、去重,集合运算是它的杀手锏
SADD article:10086:likes 9001 9002 9003
SREM article:10086:likes 9001
SISMEMBER article:10086:likes 9002 # 判断用户是否点过赞,O(1)
SCARD article:10086:likes # 点赞数
SMEMBERS article:10086:likes # 全部成员,数据量大时别用
Set 最值钱的是集合运算:交集、并集、差集。
# 共同关注
SINTER follow:9001 follow:9002
# 可能认识的人(二度好友)
SDIFF follow:9002 follow:9001
# 把交集结果存到新 key
SINTERSTORE common:9001:9002 follow:9001 follow:9002
我们做"好友推荐"功能时,第一版是在 Java 里把两个几万人的关注列表拉回来做循环比对,接口耗时 800ms+。改成 SDIFF 之后,Redis 端计算,耗时降到 12ms。
还有个很好用的命令 SRANDMEMBER,随机取元素,我拿它做过抽奖:
SRANDMEMBER lottery:pool 3 # 随机取 3 个(可重复)
SPOP lottery:pool # 随机取并删除,用于"中奖后不放回"
误区:把 Set 当列表用。它是无序的,SMEMBERS 返回的顺序不保证稳定,别拿它做需要保序的东西。
ZSet:有序且带分数,排行榜的唯一选择
ZSet 在 Set 的基础上给每个 member 加了一个 score(double 类型),并按 score 排序。底层是跳表 + 哈希表,插入和查找都是 O(log n)。
ZADD rank:sales 10086 "商品A"
ZADD rank:sales 23450 "商品B"
ZINCRBY rank:sales 100 "商品A" # 销量加 100
ZREVRANGE rank:sales 0 9 WITHSCORES # top 10,分数从高到低
ZRANK rank:sales "商品A" # 正序排名(从 0 开始)
ZREVRANK rank:sales "商品A" # 倒序排名
ZSCORE rank:sales "商品A" # 查分数
ZCARD rank:sales # 总数
这里有个我踩过的坑:ZRANK 返回的是从 0 开始的索引,做排行榜展示时要 +1。我第一版排行榜第一名显示的是"第 0 名",被测试提了 bug。
按分数区间查询是 ZSet 的另一个强项:
ZRANGEBYSCORE rank:sales 10000 20000 WITHSCORES # 分数在 1万~2万之间的
ZCOUNT rank:sales 10000 +inf # 统计销量过万的
ZREMRANGEBYSCORE rank:sales -inf 100 # 删除分数低于 100 的
我们用 ZREMRANGEBYRANK 做过"只保留前 1000 名"的裁剪,每天凌晨跑一次:
ZREMRANGEBYRANK rank:sales 0 -1001 # 删除排名 1001 之后的(保留前 1000)
延迟队列也是 ZSet 的经典用法——score 存执行时间戳,轮询取到期的:
ZADD delay:queue 1543046400000 "task:order:12345:cancel"
ZRANGEBYSCORE delay:queue 0 1543046460000 LIMIT 0 10 # 取已到期的任务
ZREM delay:queue "task:order:12345:cancel" # 取出后删除,防止重复消费
我们用它做"30 分钟未支付自动取消订单",比定时扫库轻量得多。
一张对照表
| 类型 | 有序 | 去重 | 典型业务 |
|---|---|---|---|
| String | - | - | 整存整取的缓存、计数器、分布式锁 |
| Hash | 否 | field 唯一 | 需要部分更新的对象(用户资料、购物车) |
| List | 是 | 否 | 消息队列、最新动态、评论列表 |
| Set | 否 | 是 | 标签、点赞用户、共同好友、抽奖 |
| ZSet | 是 | 是 | 排行榜、延迟队列、带权重的推荐 |
几条我后来补上的规矩
- key 要有命名空间:
biz:entity:id三层结构,用冒号分隔。我们早期有一批 key 是userinfo_9001、userInfo:9001混着写的,后来清理起来很痛苦。 - key 不要太长。Redis 的 key 是要占内存的,我见过有人把整个 SQL 当 key,一个 key 300 多字节。1000 万个这样的 key,光 key 本身就占 300MB。
- 禁止在生产用 KEYS 命令。它是 O(n) 的,800 万 key 的实例上执行一次会阻塞好几秒。用
SCAN代替。我之前不知道,在生产上跑了一次KEYS user:*,整个 Redis 卡了 4 秒,业务超时一片。 - 批量操作用 pipeline。我要插入 1 万条数据时对比过:单条 SET 循环 1 万次耗时 3.2 秒;用 pipeline 分 100 批,耗时 0.28 秒,快了 11 倍。
- 大 value 要拆。单个 value 超过 10KB 就算大了,超过 1MB 会严重影响性能(Redis 是单线程处理命令的,传输和序列化都要占用主线程)。我们有个 2MB 的配置 value,每次读取都要 40ms,后来拆成了 hash。
现在回头看那段万物皆 JSON 的代码,最大的问题不是性能,是我压根没意识到 Redis 提供的是数据结构而不是一个扁平的 key-value 存储。选对类型,很多原本要写几十行 Java 逻辑的需求,一条 Redis 命令就解决了。