前提
笔者最近在做一个项目时候使用Redis
存放客户端展示的订单列表,列表需要进行分页。由于笔者先前对Redis
的各种数据类型的使用场景并不是十分熟悉,于是先入为主地看到Hash
类型:
USER_ID:1 ORDER_ID:ORDER_XX: {"amount": "100","orderId":"ORDER_XX"} ORDER_ID:ORDER_YY: {"amount": "200","orderId":"ORDER_YY"} 复制代码
感觉Hash
类型完全满足需求实现的场景。然后想当然地考虑使用HSCAN
命令进行分页,引发了后面遇到的问题。
SCAN和HSCAN命令
SCAN
命令如下:
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type] // 返回值如下: // 1. cursor,数值类型,下一轮的起始游标值,0代表遍历结束 // 2. 遍历的结果集合,列表 复制代码
SCAN
命令在Redis
2.8.0版本中新增,时间复杂度计算如下:每一轮遍历的时间复杂度为O(1)
,所有元素遍历完毕直到游标cursor
返回0的时间复杂度为O(N)
,其中N
为集合内元素的数量。SCAN
是针对整个Database
内的所有KEY进行渐进式的遍历,它不会阻塞Redis
,也就是使用SCAN
命令遍历KEY的性能会优于KEY *
命令。对于Hash
类型有一个衍生的命令HSCAN
专门用于遍历Hash
类型及其相关属性(Field
)的字段:
HSCAN key cursor [MATCH pattern] [COUNT count] // 返回值如下: // 1. cursor,数值类型,下一轮的起始游标值,0代表遍历结束 // 2. 遍历的结果集合,是一个映射 复制代码
笔者当时没有详细查阅Redis
的官方文档,想当然地认为Hash
类型的分页简单如下(假设每页数据只有1条):
// 第一页 HSCAN USER_ID:1 0 COUNT 1 <= 这里认为返回的游标值为1 // 第二页 HSCAN USER_ID:1 1 COUNT 1 <= 这里认为返回的游标值为0,结束迭代 复制代码
实际上,执行的结果如下:
HSCAN USER_ID:1 0 COUNT 1 // 结果 0 ORDER_ID:ORDER_XX {"amount": "100","orderId":"ORDER_XX"} ORDER_ID:ORDER_YY {"amount": "200","orderId":"ORDER_YY"} 复制代码
也就是在第一轮遍历的时候,KEY对应的所有Field-Value
已经全量返回。笔者尝试增加哈希集合KEY = USER_ID:1
里面的元素,但是数据量相对较大的时候,依然没有达到预期的分页效果;另一个方面,尝试修改命令中的COUNT
值,发现无论如何修改COUNT
值都不会对遍历的结果产生任何影响(也就是还是在第一轮迭代返回全部结果)。百思不得其解的情况下,只能仔细翻阅官方文档寻找解决方案。在SCAN
命令的COUNT
属性描述中找到了原因:
简单翻译理解一下:
SCAN
命令以及其衍生命令并不保证每一轮迭代返回的元素数量,但是可以使用COUNT
属性凭经验调整SCAN
命令的行为。COUNT
指定每次调用应该完成遍历的元素的数量,以便于遍历集合,本质只是一个提示值。
COUNT
默认值为10。- 当遍历的目标
Set
、Hash
、Sorted Set
或者Key
空间足够大可以使用一个哈希表表示并且不使用MATCH
属性的前提下,Redis
服务端会返回COUNT
或者比COUNT
大的遍历元素结果集合。 - 当遍历只包含
Integer
值的Set
集合(也称为intsets
),或者ziplists
类型编码的Hash
或者Sorted Set
集合(说明这些集合里面的元素占用的空间足够小),那么SCAN
命令会返回集合中的所有元素,直接忽略COUNT
属性。
注意第3点,这个就是在Hash
集合中使用HSCAN
命令COUNT
属性失效的根本原因。Redis
配置中有两个和Hash
类型ziplist
编码的相关配置值:
hash-max-ziplist-entries 512 hash-max-ziplist-value 64 复制代码
在如下两个条件之一满足的时候,Hash
集合的编码会由ziplist
会转成dict
:
- 当
Hash
集合中的数据项(即Field-Value
对)的数目超过512的时候。 - 当
Hash
集合中插入的任意一个Field-Value
对中的Value
长度超过64。
当Hash
集合的编码会由ziplist
会转成dict
,Redis
为Hash
类型的内存空间占用优化相当于失败了,降级为相对消耗更多内存的字典类型编码,这个时候,HSCAN
命令COUNT
属性才会起效。
案例验证
简单验证一下上一节得出的结论,写入一个测试数据如下:
// 70个X HSET USER_ID:2 ORDER_ID:ORDER_XXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX // 70个Y HSET USER_ID:2 ORDER_ID:ORDER_YYY YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY 复制代码
接着开始测试一下HSCAN
命令:
// 查看编码 object encoding USER_ID:2 // 编码结果 hashtable // 第一轮迭代 HSCAN USER_ID:2 0 COUNT 1 // 第一轮迭代返回结果 2 ORDER_ID:ORDER_YYY YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY // 第二轮迭代 HSCAN USER_ID:2 2 COUNT 1 0 ORDER_ID:ORDER_XXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 复制代码
测试案例中故意让两个值的长度为70,大于64,也就是让Hash
集合转变为dict(hashtable)
类型,使得COUNT
属性生效。但是,这种做法是放弃了Redis
为Hash
集合的内存优化。显然,HSCAN
命令天然不是为了做数据分页而设计的,而是为了渐进式的迭代(也就是如果需要迭代的集合很大,也不会阻塞Redis
服务)。所以笔者最后放弃了使用HSCAN
命令,寻找更适合做数据分页查询的其他Redis
命令。
小结
通过这简单的踩坑案例,笔者得到一些经验:
- 切忌先入为主,使用中间件的时候要结合实际的场景。
- 使用工具的之前要仔细阅读工具的使用手册。
- 要通过一些案例验证自己的猜想或者推导的结果。
Redis
提供的API十分丰富,后面应该还会遇到更多的踩坑经验。
附件
- Github Page:www.throwable.club/2019/08/12/…
- Coding Page:throwable.coding.me/2019/08/12/…
- Markdown文件:github.com/zjcscut/blo…