Redis HSCAN 的 COUNT 参数"失效"了?——Hash 编码里一个隐蔽的坑
给 HSCAN 传了 COUNT 10,返回的却是整个 Hash 的全部数据。命令没写错、Redis 也没出 bug——问题出在你看不见的一层:这个 Hash 底层根本不是你以为的数据结构。本文从现象出发,把 SCAN 家族的 COUNT 语义、Hash 的双编码机制、以及游戏服里正确的分批扫描姿势一次讲清。
一、背景与现象
场景很常见:游戏服里用一个 Hash 存全服玩家的活动奖励领取状态(field = 玩家 ID,value = 领取标记),定时任务需要分批扫描这个 Hash 做补发和对账——一次性 HGETALL 拉全量,数据量大时响应体、网络传输、业务线程处理时长都会失控,所以按惯例用 HSCAN + 游标循环,每批 10 条:
ScanParams params = new ScanParams().count(10);
String cursor = 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-entries 512 # field 数量超过 512 → 转 hashtable
hash-max-ziplist-value 64 # 任意一个 value 长度超过 64 字节 → 转 hashtable
| ziplist | 一块连续内存,紧凑存储 | field 数 ≤ 512 且 所有 value ≤ 64B | 省内存;遍历只能整体一次走完 |
| hashtable | 哈希表 | 任一条件被突破 | O(1) 读写;SCAN 游标按桶推进,COUNT 才有意义 |
三个容易被忽略的细节:
排查技巧:遇到"某条 Redis 命令行为和文档对不上",先 OBJECT ENCODING <key> 看一眼底层编码,再看 TYPE。同一种类型、不同编码,命令行为可能完全不同——这是 Redis 隐蔽坑的高发区。
四、影响评估与正确姿势
4.1 先说清楚:这个"失效"到底有没有危害
冷静评估一下:会触发它的前提是 Hash 处于 ziplist 编码——field ≤ 512 且所有 value ≤ 64B。这样的 Hash 全量也就是几十 KB 以内,一次返回的开销其实很小,甚至比分多次 RTT 更省。
所以它真正的危害不在性能,而在认知与假设:
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 限流"的假设,以及大集合的分桶建模 |
三条可复用的经验:
六、写在最后
这个坑小,但很有代表性:它不出在 bug 上,而出在"你以为的语义"和"实际的语义"之间那道缝里。Redis 这类基础设施把大量复杂度藏在"开箱即用"后面,而工程能力的一部分,就是知道去哪里找那道缝。




