
Redis HSCAN 的 COUNT 参数失效了——Hash 编码里一个隐蔽的坑给HSCAN传了COUNT 10返回的却是整个 Hash 的全部数据。命令没写错、Redis 也没出 bug——问题出在你看不见的一层这个 Hash 底层根本不是你以为的数据结构。本文从现象出发把 SCAN 家族的 COUNT 语义、Hash 的双编码机制、以及游戏服里正确的分批扫描姿势一次讲清。一、背景与现象场景很常见游戏服里用一个 Hash 存全服玩家的活动奖励领取状态field 玩家 IDvalue 领取标记定时任务需要分批扫描这个 Hash 做补发和对账——一次性HGETALL拉全量数据量大时响应体、网络传输、业务线程处理时长都会失控所以按惯例用HSCAN 游标循环每批 10 条ScanParamsparamsnewScanParams().count(10);StringcursorScanParams.SCAN_POINTER_START;// 0do{ScanResultMap.EntryString,Stringresultjedis.hscan(reward:activity:1001,cursor,params);process(result.getResult());// 处理本批数据cursorresult.getCursor();}while(!0.equals(cursor));// 游标归 0 表示一轮完成上线前自测问题出现了这个 Hash 里有 20 个 fieldHSCAN ... 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 只是给实现的一个提示值每次调用实际返回多少条不做保证。也就是说COUNT10 每次最多 10 条这个假设本身就不成立——COUNT 从来不是分页参数。但是提示和完全无视还是两回事正常情况下返回条数应该在 COUNT 附近浮动一个 hint 不至于连看都不看。继续往下挖。2.3 第三步看编码真相大白问 Redis 这个 key 到底长什么样127.0.0.1:6379 OBJECT ENCODING reward:activity:1001 ziplistziplist——问题就在这。这个 Hash 的 field 只有 20 个、每个 value 都很短Redis 判断它小底层没有用哈希表存而是用了一块紧凑的连续内存ziplist。而HSCAN 的游标迭代是实现在底层数据结构上的ziplist 没法按槽跳跃只能一次把整个 entry 吐完游标直接归 0——COUNT 自然无从谈起。2.4 第四步实验验证造两个对照的 key 验证# 实验1601 个 field超过 512value 都很短 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 生效了 # 实验220 个短 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 可能处于不同编码一个从没超过 512COUNT 无效一个峰值到过 513COUNT 生效。如果你依赖 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 哈希拆开# 一个大 Hashreward: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 ENCODINGMEMORY 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 这类基础设施把大量复杂度藏在开箱即用后面而工程能力的一部分就是知道去哪里找那道缝。