Administrator
发布于 2018-11-22 / 1576 阅读
21

Redis 五种数据类型分别该用在什么场景

我一度以为 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 只用来做那种丢了也无所谓的场景。

二是"最新动态"。LPUSHLTRIM 是很经典的组合,插入的同时裁剪长度,保证列表不会无限膨胀。

误区:用 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--整存整取的缓存、计数器、分布式锁
Hashfield 唯一需要部分更新的对象(用户资料、购物车)
List消息队列、最新动态、评论列表
Set标签、点赞用户、共同好友、抽奖
ZSet排行榜、延迟队列、带权重的推荐

几条我后来补上的规矩

  • key 要有命名空间biz:entity:id 三层结构,用冒号分隔。我们早期有一批 key 是 userinfo_9001userInfo: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 命令就解决了。

参考