Redis HSCAN 的 COUNT 参数"失效"了?——Hash 编码里一个隐蔽的坑
给
HSCAN传了COUNT 10,返回的却是整个 Hash 的全部数据。命令没写错、Redis 也没出 bug——问题出在你看不见的一层:这个 Hash 底层根本不是你以为的数据结构。本文从现象出发,把 SCAN 家族的 COUNT 语义、Hash 的双编码机制、以及游戏服里正确的分批扫描姿势一次讲清。
一、背景与现象
场景很常见:游戏服里用一个 Hash 存全服玩家的活动奖励领取状态(field = 玩家 ID,value = 领取标记),定时任务需要分批扫描这个 Hash 做补发和对账——一次性HGETALL拉全量,数据量大时响应体、网络传输、业务线程处理时长都会失控,所以按惯例用HSCAN+ 游标循环,每批 10 条:
ScanParamsparams=newScanParams().count(10);Stringcursor=ScanParams.SCAN_POINTER_START;// "0"do{ScanResult<Map.Entry<String,String>>result=jedis.hscan("reward:activity:1001",cursor,params);process(result.getResult());// 处理本批数据cursor=result.getCursor();}while(!"0".equals(cursor));// 游标归 0 表示一轮完成上线前自测,问题出现了:
- 这个 Hash 里有 20 个 field;
HSCAN ... COUNT 10第一次调用,20 个 field 全部返回,游标直接归 0;- 传
COUNT 1也一样——COUNT 参数完全被无视。
预期是"每次 10 条、分两批",实际是"一把全给"。第一次见到这个现象的人,十有八九会先怀疑自己代码写错了。
二、排查过程
2.1 第一步:排除客户端
第一反应是怀疑 Java 侧的封装:是不是ScanParams没传对?游标处理错了?
绕开客户端,直接用redis-cli原生验证:
127.0.0.1:6379> HLEN reward:activity:1001 (integer) 20 127.0.0.1:6379> HSCAN reward:activity:1001 0 COUNT 10 1) "0" <- 游标直接返回 0(一轮结束) 2) 1) "pid:1001" <- 但下面跟着全部 20 对 field-value 2) "{\"state\":1}" 3) "pid:1002" 4) "{\"state\":1}" ...(共 20 对,40 个元素)redis-cli下行为一致——问题在服务端语义,不在客户端。客户端封装无罪释放。
2.2 第二步:读文档,第一次认知校正
翻 Redis 官方文档对 SCAN 家族 COUNT 的定义,会发现一句容易被忽略的话:
The COUNT option … is just a hint for the implementation.
(COUNT 只是给实现的一个提示值,每次调用实际返回多少条不做保证。)
也就是说"COUNT=10 每次最多 10 条"这个假设本身就不成立——COUNT 从来不是分页参数。但"是提示"和"完全无视"还是两回事:正常情况下返回条数应该在 COUNT 附近浮动,一个 hint 不至于连看都不看。继续往下挖。
2.3 第三步:看编码,真相大白
问 Redis 这个 key 到底长什么样:
127.0.0.1:6379> OBJECT ENCODING reward:activity:1001 "ziplist"ziplist——问题就在这。这个 Hash 的 field 只有 20 个、每个 value 都很短,Redis 判断它"小",底层没有用哈希表存,而是用了一块紧凑的连续内存(ziplist)。而HSCAN 的游标迭代是实现在底层数据结构上的:ziplist 没法按"槽"跳跃,只能一次把整个 entry 吐完,游标直接归 0——COUNT 自然无从谈起。
2.4 第四步:实验验证
造两个对照的 key 验证:
# 实验1:601 个 field(超过 512),value 都很短 127.0.0.1:6379> OBJECT ENCODING reward:test:big "hashtable" 127.0.0.1:6379> HSCAN reward:test:big 0 COUNT 10 1) "392" <- 游标不为 0,还有下一批 2) ...(约 10 对) <- COUNT 生效了 # 实验2:20 个短 field,但塞进一个 100 字节的 value 127.0.0.1:6379> HSET reward:activity:1001 note "xxxxx……(省略,共 100 个字符)" 127.0.0.1:6379> OBJECT ENCODING reward:activity:1001 "hashtable" <- 编码被"长 value"触发转换结论坐实:COUNT 是否生效,取决于 Hash 的底层编码;编码取决于两个阈值配置。
三、原理:Hash 的双编码机制
Redis 的 Hash 类型有两套底层实现:从 ziplist 起步,写入过程中一旦突破阈值就升级为 hashtable,阈值由两个配置决定:
# redis.conf(默认值)hash-max-ziplist-entries512# field 数量超过 512 → 转 hashtablehash-max-ziplist-value64# 任意一个 value 长度超过 64 字节 → 转 hashtable| 编码 | 结构 | 何时使用 | 特点 |
|---|---|---|---|
ziplist | 一块连续内存,紧凑存储 | field 数 ≤ 512且所有 value ≤ 64B | 省内存;遍历只能整体一次走完 |
hashtable | 哈希表 | 任一条件被突破 | O(1) 读写;SCAN 游标按桶推进,COUNT 才有意义 |
三个容易被忽略的细节:
- 转换是单向的。从 ziplist 转成 hashtable 后,即使把数据删回 20 个,编码也不会转回来——"内存优化失败"一旦发生就固化了。所以线上同结构的 key 可能长期存在两种编码并存的状态,行为不一致;
- 编码"看历史",且同结构的 key 表现可能不一致。结合第 1 条:一个 Hash 哪怕只在某个瞬间突破过阈值,就永远停留在 hashtable——于是线上同结构的两个 key 可能处于不同编码:一个从没超过 512(COUNT 无效),一个峰值到过 513(COUNT 生效)。如果你依赖 COUNT 做限流,"同样的代码,有的 key 分批、有的 key 全量"的灵异现象就从这来;
- Redis 7.0 起 ziplist 被 listpack 取代(配置名变为
hash-max-listpack-entries/hash-max-listpack-value,旧名兼容),但对本问题行为完全一致:小 Hash 整体返回,COUNT 无效。
排查技巧:遇到"某条 Redis 命令行为和文档对不上",先
OBJECT ENCODING <key>看一眼底层编码,再看TYPE。同一种类型、不同编码,命令行为可能完全不同——这是 Redis 隐蔽坑的高发区。
四、影响评估与正确姿势
4.1 先说清楚:这个"失效"到底有没有危害
冷静评估一下:会触发它的前提是 Hash 处于 ziplist 编码——field ≤ 512 且所有 value ≤ 64B。这样的 Hash 全量也就是几十 KB 以内,一次返回的开销其实很小,甚至比分多次 RTT 更省。
所以它真正的危害不在性能,而在认知与假设:
- 依赖"每批 N 条"做限流的逻辑直接失效——批大小不可控,下游处理超时设计失去依据;
- 排查成本高:行为不符合直觉,且随数据规模变化(过 512 那天突然"好了"),不懂数据在哪个编码里就很难定位;
- 掩盖设计问题:如果这个 Hash 将来会长到很大,今天的"全量返回"就是明天的慢查询大响应。
4.2 正确姿势
姿势一:把 COUNT 当提示,业务按"任意批量"设计。
游标循环的写法本身没问题(开头那段 Java 就是标准写法),要改的是假设:每批可能 10 条,也可能 1 条或 100 条,处理逻辑要能优雅接受任意批量。这本来就是 SCAN 家族的契约——文档还规定了遍历过程中可能返回重复元素(hashtable 渐进式 rehash 期间,游标的高低位反向迭代保证不漏,代价是可能重复),业务侧要做幂等或去重。
姿势二:真需要稳定分批,在数据建模层面分桶。
预期会很大的集合(全服玩家的奖励状态、排行榜),不要放在一个 key 里等它膨胀,写入时就按 field 哈希拆开:
# 一个大 Hash:reward:activity:1001 # 拆成 128 个桶: reward:activity:1001:0 reward:activity:1001:1 ... reward:activity:1001:127 # 写入:bucket = hash(玩家ID) % 128 # 扫描:先遍历桶,再对每个桶 HSCAN(此时每个桶都小,HGETALL 也无所谓)这是游戏服处理"全服级"集合的标准答案:单 key 尺寸可控(大 key 治理)、天然支持并行扫描、还顺手解决了单热点。桶数量按预估总量 / 单桶目标大小取 2 的幂。
姿势三:让编码处于预期之内。
- 知道这两个阈值的存在,Hash 的设计规模要么明确留在 ziplist 内(小配置类数据,全量读反而快),要么明确会超过它(集合类数据,按姿势二建模);
- 需要确认线上 key 的编码和体量时:
OBJECT ENCODING+MEMORY USAGE <key>,巡检大 key 时顺手看一眼编码。
五、复盘
| 环节 | 当时的情况 | 应有的认知 |
|---|---|---|
| 设计阶段 | 以为 HSCAN COUNT = 分页大小 | COUNT 是 hint,批量不可控是契约的一部分 |
| 现象阶段 | 全量返回,怀疑客户端 bug | 命令行为异常先查OBJECT ENCODING |
| 原理阶段 | 不知道 Hash 有双编码 | ziplist/hashtable 阈值 512/64,转换单向 |
| 方案阶段 | 小数据全量返回其实无害 | 真正要修的是"依赖 COUNT 限流"的假设,以及大集合的分桶建模 |
三条可复用的经验:
- SCAN 家族(SCAN/SSCAN/HSCAN/ZSCAN)的 COUNT 都是提示值,返回条数不保证,遍历期间还可能重复——按任意批量 + 幂等设计,永远正确;
- 同类型不同编码,行为可能天差地别:set 的
intset、zset 的listpack同样有各自的"小数据特判",遇到诡异行为先看编码; - 小,本身就是一种不同的数据结构。Redis 为省内存做的隐形优化,对上层命令是透明的,但对你写的代码不是。
六、写在最后
这个坑小,但很有代表性:它不出在 bug 上,而出在"你以为的语义"和"实际的语义"之间那道缝里。Redis 这类基础设施把大量复杂度藏在"开箱即用"后面,而工程能力的一部分,就是知道去哪里找那道缝。