☰
Redis HSCAN 的 COUNT 参数“失效“了?——Hash 编码里一个隐蔽的坑
2026/10/8 13:28:29 网站建设 项目流程

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 才有意义

三个容易被忽略的细节:

  1. 转换是单向的。从 ziplist 转成 hashtable 后,即使把数据删回 20 个,编码也不会转回来——"内存优化失败"一旦发生就固化了。所以线上同结构的 key 可能长期存在两种编码并存的状态,行为不一致;
  2. 编码"看历史",且同结构的 key 表现可能不一致。结合第 1 条:一个 Hash 哪怕只在某个瞬间突破过阈值,就永远停留在 hashtable——于是线上同结构的两个 key 可能处于不同编码:一个从没超过 512(COUNT 无效),一个峰值到过 513(COUNT 生效)。如果你依赖 COUNT 做限流,"同样的代码,有的 key 分批、有的 key 全量"的灵异现象就从这来;
  3. 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 更省。

所以它真正的危害不在性能,而在认知与假设:

  1. 依赖"每批 N 条"做限流的逻辑直接失效——批大小不可控,下游处理超时设计失去依据;
  2. 排查成本高:行为不符合直觉,且随数据规模变化(过 512 那天突然"好了"),不懂数据在哪个编码里就很难定位;
  3. 掩盖设计问题:如果这个 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 限流"的假设,以及大集合的分桶建模

三条可复用的经验:

  1. SCAN 家族(SCAN/SSCAN/HSCAN/ZSCAN)的 COUNT 都是提示值,返回条数不保证,遍历期间还可能重复——按任意批量 + 幂等设计,永远正确;
  2. 同类型不同编码,行为可能天差地别:set 的intset、zset 的listpack同样有各自的"小数据特判",遇到诡异行为先看编码;
  3. 小,本身就是一种不同的数据结构。Redis 为省内存做的隐形优化,对上层命令是透明的,但对你写的代码不是。

六、写在最后

这个坑小,但很有代表性:它不出在 bug 上,而出在"你以为的语义"和"实际的语义"之间那道缝里。Redis 这类基础设施把大量复杂度藏在"开箱即用"后面,而工程能力的一部分,就是知道去哪里找那道缝。


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询