Redis 数据类型全解 - HyperLogLog:12 KB 估出亿级 UV(八)

适用:要在生产环境落地 UV / 去重计数、做容量规划或排查线上问题的后端与运维。

本文只回答一件事:这份 12 KB 的估算值能不能进你的生产链路,边界在哪。阅读路径是「值不值 → 长什么样 → 怎么写 → 怎么落地 → 怎么不被打死 → 踩过的坑 → 上线核对」,不涉及估计器推导与源码逐行走读。

1. 这笔交易值不值

1.1 0.81% 到底是什么意思

标准误差 = 1.04 / sqrt(m) = 1.04 / sqrt(16384) ≈ 0.008125,即 0.81%

三个限定缺一个就会被误用。

  1. 相对误差,不是绝对误差:10 万基数约 ±810,1 亿约 ±81 万,看板差几十万不是 bug。
  2. 是 1 sigma,不是上限:约 68% 落在 ±0.81%,95% 落在 ±1.62%,99.7% 落在 ±2.43%——约 32% 的概率会超出 ±0.81%,偶尔看到 ±1.5% 正常。
  3. 小基数不适用:几百到几千时估计器近似线性计数,偏差常是个位数,别拿 0.81% 反推区间。同一固定集合的估计值又是确定的,「多查几次取平均更准」不存在。

工程上该问的不是「误差大不大」,而是误差会不会让某个动作跨过阈值:看板差几十万可接受;熔断阈值 500、真实 498 读成 502 就误熔断。能改精度的只有桶数 m,它写死在编译期。

回到开头那个问题:真正值得上 HLL 的只有「一个海量、可容忍约 1% 误差的去重总数」这一种需求;要精确、要明细、要交集、要判存,就在 1.4 换结构。

1.2 三代方案,与一笔被设计出来的交易

Set / ZSet 直接存 ID,内存随基数线性涨,亿级到 GB。Bitmap 把 ID 映射成 offset,内存取决于最大 ID 而非基数,前提是稠密非负有界整数——UUID、雪花 ID 直接失效。

HLL 是第三条路:不存成员,只存统计摘要,稠密时封顶约 12 KB;代价是约 0.81% 误差和 1.3 的四条「不能」。它相对 Bitmap 的优势不是「更省」,而是对输入形态不挑。

这笔交易是设计出来的:误差 1.04/sqrt(16384) 与内存 16384 * 6 / 8 + 16 = 12304 字节,来自同一组寄存器,也就是同一个常量 P = 14。HLL_P 无法配置,改源码才能换搭配(P = 16 约 0.41% / 48 KB);工程上要么接受「12 KB / 0.81%」,要么换结构。

1.3 四不能,与什么时候别用

PFADD 不保存元素,只哈希后更新寄存器最大值。由此推出:

一句话:HLL 只回答「有多少个」,不回答「有哪几个」「是不是这个」。

1.4 和另外四种结构比

Bloom 那 114 MB 由 m = -n*ln(p)/(ln2)^2 估得,写成「10 MB」是常见数量级错误。

选型顺序:要精确 / 明细 / 判存 / 交集 / 删单元素 → Set 或 Bitmap;只要「在不在」且容忍误判 → Bloom;要「出现几次」→ CMS;基数只有几百 → 小 Set;其余只要总数 → HLL。交集别用容斥:三个量各带约 0.81% 误差,交集远小于并集时误差会大于交集本身,用 SINTER / BITOP AND。

1.5 版本差异一览

读 HLL 资料前先确认它写的是哪个版本;未标注版本的结论对 2.8.9 至今都成立。

另有两点。前两代都有三条命令、双编码与 hll-sparse-max-bytes,PFCOUNT 单键都会回写缓存;8.0 的 ACL 是破坏性变更,@read / @write 从此包含概率结构命令;PFCOUNT 整条都是 @read @hyperloglog @slow。安全修复版本见 5.5。

「未实测」的格子按源码结构推得:跑一次 PFMERGE dest src 再看 STRLEN dest 即可自判——12304 是稠密,几十到几千字节是稀疏。估计器三代输出几乎一致(新实现约快 20%),换版本不改变「要不要用」,只改变小基数的实际偏差。

2. 它内部长什么样

2.1 一次哈希怎么变成 16384 个寄存器

把元素哈希成均匀随机的 64 位串,从低位起数「连续 0 直到第一个 1」的长度 rho。rho = 20 意味着这个桶大约被喂过 2^20 量级元素。只留全局最大方差太大,于是分桶后对 16384 个寄存器取调和平均(对极端值不敏感),把误差压到 1.04/sqrt(m)。

元素(任意字符串)
  |  MurmurHash64A(64 bit,seed 0xadc83b19,与字节序无关)
  v
64 位哈希值
  |-- 低 14 位(bit 0-13): index = hash & HLL_P_MASK,桶编号 0-16383
  |-- 高 50 位(bit 14-63): 右移后找 000...1 模式长度 rho,范围 1-51
  v
M[index] = max(M[index], rho)      只留最大值,天然去重、只增不减
  v
16384 个寄存器的直方图 + 估计器 -> 基数估计 E

不少资料写「前 14 位定桶、后 50 位数前导零」,方向反了:源码 hllPatLen() 是低 14 位定桶,hash >>= HLL_P 后用剩余高位找 000...1;哈希是 MurmurHash64A,不是 MurmurHash3。

官方说 64 位哈希使基数「接近 2^64 之前没有实际限制」,但哈希碰撞(生日悖论)决定的可用量程远低于 2^64。1.04 也不是拟合系数:渐近常数 α∞ ≈ 0.7213(源码 HLL_ALPHA_INF)推出来的。

2.2 12 KB 从哪来

+--------------------- 16 字节头部 ----------------------+------- 寄存器区 -------+
| HYLL(4B) | encoding(1B) | notused(3B) | card(8B)      | 16384 * 6 bit        |
| 魔数     | 稀疏/稠密     | 保留        | 基数缓存       | 稠密 12288B / 稀疏可变 |
+-------------------------------------------------------+----------------------+

头部固定 16 字节,card 那 8 字节就是让单键 PFCOUNT 产生写副作用的缓存字段(最高位为 1 表示失效)。稠密 16 + 12288 = 12304 字节,与基数无关;稀疏刚创建时寄存器区只需 2 字节(一个 XZERO 表示 16384 个全 0),STRLEN 为 18。MEMORY USAGE 含 key 名、dictEntry、过期字典,通常比纯数据大 2 - 3 倍。

2.3 稀疏与稠密:什么时候翻倍

满足任一条件即转稠密,且单向不可逆:某寄存器需写入的值 > 32(HLL_SPARSE_VAL_MAX_VALUE);或稀疏表示总长超过 hll-sparse-max-bytes(默认 3000 字节,含 16 字节头)。

# redis.conf / ADVANCED CONFIG 段:稀疏表示的长度上限
hll-sparse-max-bytes 3000

官方权衡:3000 兼顾省内存与写入性能;CPU 充裕、内存紧张、且基数多在 0 - 15000 时可提到约 10000;超过 16000 无用,稠密反而更省。不能盲目调大是因为稀疏下 PFADD 是 O(N)。

源码注释的实测均值:基数 1,000 时稀疏约 1,882 字节,10,000 时约 10,591,稠密恒为 12,288;原话「基数约 2,000 - 3,000 以下稀疏明显更省」比默认值 3000 更有用——稀疏的真正收益区间在三千以内。转稠密后不会退回稀疏,容量按最坏情况估。

2.4 怎么判断现在是稀疏还是稠密

只认 STRLEN:18 到 3000 之间是稀疏,12304 是稠密。

# 类型永远是 string:Redis 侧无法区分 HLL 与普通字符串
TYPE uv:demo
# 这是 String 对象的编码(raw / embstr),不是 HLL 的 sparse / dense
OBJECT ENCODING uv:demo
# 整只为二进制,前 4 字节是 HYLL 魔数
GET uv:demo
# 判断稀疏 / 稠密只认它
STRLEN uv:demo

OBJECT ENCODING 给不出 sparse / dense,不能用来判断 HLL 编码。它连 raw 都不一定给:createHLLObject() 是拿 18 字节 sds 直接 createObject(OBJ_STRING, sds),没显式指定 OBJ_ENCODING_RAW,短对象在部分版本显示 embstr;第一次带元素的 PFADD(内部 dbUnshareStringValue 拷贝一次)后才稳定为 raw。自己版本跑两条就知道:PFADD uv:probe 看一次,PFADD uv:probe a 再看一次。

两个推论:Redis 侧无法识别「哪些 key 是 HLL」,监控清理只能靠前缀 + SCAN;HLL 就是普通 String,EXPIRE / DEL / UNLINK 都能用,也能 GET 整只读出、SET 写回做迁移;反过来 PFADD 打在普通 String 上报 WRONGTYPE,必须用独立前缀隔离。

3. 命令与 API

3.1 三条命令速查

官方 PFCOUNT 那句 "Hundreds of operations per second are possible" 在单键性能段,与同段 "performances are excellent" 并存,是未附硬件条件的保守表述:别当吞吐上限,也别据此把单键查询赶出链路。

3.2 60 秒最小示例

# 11/7 写入 3 个用户:同一天可多次调用,重复元素返回 0
PFADD uv:page:home:20261107 user:1001 user:1002 user:1003
# 单键查询:返回近似基数 3
PFCOUNT uv:page:home:20261107
# 合并两天到周 key:尚不存在,以稀疏编码创建
PFMERGE uv:page:home:week:2026W45 uv:page:home:20261107 uv:page:home:20261106
# 并集是 5 个不同用户,不是 3 + 3 = 6
PFCOUNT uv:page:home:week:2026W45
# 日 key 保留 35 天;周汇总保留 90 天,PFMERGE 不继承源 key 的 TTL
EXPIRE uv:page:home:20261107 3024000
EXPIRE uv:page:home:20261106 3024000
EXPIRE uv:page:home:week:2026W45 7776000

七条依次返回 1 / 3 / OK / 5 / 1 / 1 / 1。两天 3 + 2 次写入、5 个不同用户,user:1003 只算一次。

3.3 PFADD:返回 0 不等于老元素

# 3 个新元素,至少一个寄存器被改写,返回 1
PFADD uv:demo user:1001 user:1002 user:1003
# 重复元素:落在同一个桶且 rho 不更大,返回 0
PFADD uv:demo user:1001
# 不带元素:key 不存在时创建空 HLL,返回 1
PFADD uv:empty
# 已存在的空 HLL 再做一次空操作,返回 0
PFADD uv:empty

返回 1 表示「至少一个寄存器被改变」,0 表示「没有」。更新规则是 M[index] = max(M[index], rho):

结论:返回 0 既可能是老元素,也可能是新元素,只能当「估计值可能变了」的信号;判新请用 SETNX、Set、Bloom 或唯一约束。事故原型:用返回 1 发新手券,同一用户偶尔也返回 1,券被重复发放。

3.4 PFCOUNT:同一条命令的两种形态

官方那句「PFCOUNT 在技术上是一条写命令」指的就是单键缓存回写;命令 flag 仍是 readonly,7.0 起额外带 may-replicate。三条工程影响:缓存失效会修改 value 并传播到副本 / AOF,产生复制流量,哪怕只是查个数;7.0 起禁止在只读脚本(EVAL_RO / FCALL_RO)中调用;在 replica-read-only yes 的从库上 PFCOUNT 可以正常执行(源码用 lookupKeyRead()),查询可以路由到副本,但别把「副本一定报错」写进预案。反复查同一个并集时,先 PFMERGE 落成结果 key 再单键查。

3.5 PFMERGE:三条必须写准的语义

# 先清历史数据,保证结果可复现、可反复执行
DEL uv:demo:d1 uv:demo:d2 uv:demo:week
# 两个日集合,c 两边都有,用来验证并集去重
PFADD uv:demo:d1 a b c
PFADD uv:demo:d2 c d e
# dest 不存在时以稀疏创建;若 dest 已存在,它会被当成额外的源一起合并
PFMERGE uv:demo:week uv:demo:d1 uv:demo:d2
# 并集基数 5:a b c d e,c 只算一次
PFCOUNT uv:demo:week

一、destkey 已存在时参与合并。 官方原文 "If the destination variable exists, it is treated as one of the source sets"——不是覆盖,源码的合并循环从 argv[1](destkey 本身)开始。干净重建必须先 DEL 或用临时 key + RENAME;把新一天追加进已有周 HLL 则不要删,直接 PFMERGE weekKey dayKey。语法允许零个源 key,等价于与自身合并。

二、目标编码不是「一定稠密」。 dest 不存在时以稀疏创建,仅当任一参与合并的 HLL 为稠密时才转稠密(use_dense)。2.8.9 曾无条件转稠密,早期「结果一定稠密」的说法由此而来;日 / 周场景源通常已稠密,实际结果基本都是 12 KB。

三、不继承 TTL。 dest 拿不到任何源的过期时间,必须显式 EXPIRE。另外:源 key 不存在按空 HLL 处理;源 key 不是合法 HLL 报 WRONGTYPE Key is not a valid HyperLogLog string value.;集群下 dest 与所有源必须同 slot。

3.6 集群同槽与 hash tag

PFCOUNT 多键与 PFMERGE 都做 slot 校验,跨槽报 CROSSSLOT Keys in request don't hash to the same slot。

# {home} 是 hash tag:下面所有 key 都落在这一个 slot 上
PFADD uv:{home}:20261107 user:1001
PFADD uv:{home}:20261108 user:1002
# destkey 也必须带同一个 tag,否则多键命令直接 CROSSSLOT
PFMERGE uv:{home}:week:2026W45 uv:{home}:20261107 uv:{home}:20261108
# 返回的是并集基数,不再区分是哪一天的用户
PFCOUNT uv:{home}:week:2026W45

「客户端分别取数再相加」不是替代方案:多键 PFCOUNT 是并集语义,分别相加会把重叠用户重复计入。无法同槽时只能把各 HLL 字节搬到同一实例再合并(4.6);hash tag 的代价见 5.3。

3.7 .NET 调用与四个坑

using StackExchange.Redis;

// redis 为 IConnectionMultiplexer 实例,例如 ConnectionMultiplexer.Connect("redis://127.0.0.1:6379")
IDatabase db = redis.GetDatabase();

// PFADD:单元素 / 批量(批量减少 RTT),均返回 Task<bool>
bool changed = await db.HyperLogLogAddAsync("uv:page:home:20261107", "user:1001");
bool changed2 = await db.HyperLogLogAddAsync("uv:page:home:20261107",
    new RedisValue[] { "user:1002", "user:1003" });

// PFCOUNT:单键与多键重载都是 Task<long>,但集群下多键需同 slot
long uv = await db.HyperLogLogLengthAsync("uv:page:home:20261107");
long unionUv = await db.HyperLogLogLengthAsync(new RedisKey[]
    { "uv:page:home:20261107", "uv:page:home:20261106" });

// PFMERGE:Task 重载;不会帮你设置 destkey 的 TTL
await db.HyperLogLogMergeAsync("uv:page:home:week:2026W45",
    new RedisKey[] { "uv:page:home:20261107", "uv:page:home:20261106" });
await db.KeyExpireAsync("uv:page:home:week:2026W45", TimeSpan.FromDays(90));

// 客户端未封装的命令用原生执行兜底,返回 RedisResult
RedisResult raw = await db.ExecuteAsync("PFCOUNT", "uv:a", "uv:b");

// raw 为 nil 或 error 时强转会抛 InvalidCastException,必须先判空
long unionFromRaw = raw.IsNull ? 0 : (long)raw;

客户端有四个坑。HyperLogLogAddAsync 返回 bool,别用来判新老;多键 HyperLogLogLengthAsync 在集群下不会自动路由,跨 slot 是运行时 CROSSSLOT;HyperLogLogMergeAsync 不设 TTL,业务层必须补;(long)RedisResult 强转前必须判 IsNull,否则 nil 或 error 时抛 InvalidCastException。

4. 生产怎么落地

4.1 口径先行:比命令更容易出错的地方

UV 看板「莫名下降」的多数根因在这里,不在算法。上线前书面定死四件事:

PFADD 天然幂等,埋点重试安全;标识规则变更必须新建一批 key,不要在旧 key 上混写。

4.2 key 规范与 TTL

uv:{tag}:<yyyyMMdd>              例:uv:{home}:20261107
uv:{tag}:week:<ISO周>            例:uv:{home}:week:2026W45
uv:{tag}:month:<yyyyMM>          例:uv:{home}:month:202611
uv:{tag}:src:<渠道>:<yyyyMMdd>    例:uv:{home}:src:wechat:20261107

四条规范。独立前缀 uv:(隔离 WRONGTYPE,也是监控 / 清理的唯一抓手);周期用定长格式;需要一起合并的 key(含 destkey)复用同一 tag,且 tag 只绑定确实要合并的 key。

最典型的事故:日 key 设了 EXPIRE 86400,凌晨周 / 月合并还没跑完,最早几天已过期,结果偏小。统一口径:

TTL 是兜底防线,关键报表链路同时用离线清理(低峰 UNLINK)并做「日到月」降采样落库。EXPIRE 是覆盖语义,反复调用会把日 key 无限续期(4.3 的 Lua 只在 TTL < 0 时设置)。

4.3 日 / 周 / 月

# 日 key 写入:埋点链路每次访问调用一次
PFADD uv:{home}:20261107 user:1001
# 日 UV:单键 O(1),可以放在请求链路上
PFCOUNT uv:{home}:20261107
# 周汇总:7 个日 key 覆盖 11/2 - 11/8,全部带 {home} 保证同 slot
PFMERGE uv:{home}:week:2026W45 uv:{home}:20261102 uv:{home}:20261103 uv:{home}:20261104 uv:{home}:20261105 uv:{home}:20261106 uv:{home}:20261107 uv:{home}:20261108
# 显式设 TTL(90 天):destkey 不继承任何源 key 的过期时间
EXPIRE uv:{home}:week:2026W45 7776000
# 周 UV:结果 key 是单键,同样可进链路
PFCOUNT uv:{home}:week:2026W45

周号与日期必须对齐:2026W45 是 11/2(周一)到 11/8(周日)。把 20261109 并进 W45 是最常见的错误,它属于 W46,会让周 UV 与月汇总同时偏大;定时任务用 ISO 周数反推 7 个日期,不要手填。

月 UV 同理:离线合并当月日 key 到 uv:{home}:month:202611,接口只读结果 key。7 个 key 的周合并可接受,30 个 key 的月合并必须离线错峰。

.NET 两个关键点:TTL 只在 key 首次出现时设置(每次访问都 EXPIRE 等于无限续期);汇总用「临时 key + RENAME 原子切换」(避开 dest 参与合并,也避免合并失败后看板空窗),写法见 4.4 的 RebuildRollingWindowAsync。

using System;
using System.Linq;
using System.Threading.Tasks;
using StackExchange.Redis;

// UV 读写服务:日 key 写入、单键查询、周 / 月汇总重建
public sealed class UvService
{
    private readonly IDatabase _db;

    // 复用同一个 multiplexer,不要在每次请求里新建连接
    public UvService(IConnectionMultiplexer redis) => _db = redis.GetDatabase();

    // hash tag 统一由这里生成,避免某个调用点漏写花括号导致 CROSSSLOT
    private static string Tag(string page) => $"{{{page}}}";
    private static string DayKey(string page, DateTime d) => $"uv:{Tag(page)}:{d:yyyyMMdd}";

    // 一段 Lua 原子完成 PFADD 与「TTL 只在首次出现时设置」:拆两条命令会有并发续期
    private const string TrackScript = @"
        local ttl = redis.call('TTL', KEYS[1])
        redis.call('PFADD', KEYS[1], ARGV[1])
        if ttl < 0 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
        return 1";

    // day 由调用方换算成业务时区的自然日(见 4.4);dayTtl 建议 35 天
    public async Task TrackVisitAsync(string page, string userId, DateTime day, TimeSpan dayTtl)
    {
        // KEYS[1] 日 key;ARGV[1] 用户标识;ARGV[2] TTL 秒数
        await _db.ScriptEvaluateAsync(TrackScript,
            new RedisKey[] { DayKey(page, day) },
            new RedisValue[] { userId, (long)dayTtl.TotalSeconds });
    }

    // 日 UV 查询:单键 O(1),可以放进请求链路
    public Task<long> DayUvAsync(string page, DateTime day)
        => _db.HyperLogLogLengthAsync(DayKey(page, day));
}

4.4 滚动窗口(最近 N 天)

窗口每天在动,HLL 不能删、PFMERGE 只能加,所以必须整窗重建。先定死「含不含当日」:含当天则白天查的是「截至此刻」的 N 天 UV,要完整自然日就从 today.AddDays(-1) 起算;两种都行,但看板、告警、报表必须一致。

以下为 4.3 UvService 类中的补充成员:

// 业务时区固定为 Asia/Shanghai;时区 ID 在 Windows 与 Linux 上由 .NET 统一映射
private static readonly TimeZoneInfo BusinessTz =
    TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");

// 「今天」必须由业务时区换算,不能用 DateTime.Today(那是服务器本地时区,见 4.1)
private static DateTime BusinessToday() =>
    TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, BusinessTz).Date;

// ttl 不传时按 4.2 的 2 天口径,避免每个调用方各自拍一个值
public async Task<long> RebuildRollingWindowAsync(
    string page, int windowDays, TimeSpan? ttl = null)
{
    if (windowDays <= 0) throw new ArgumentOutOfRangeException(nameof(windowDays));

    // 窗口 key 不含日期:它是「最近的 N 天」,靠 TTL 保证不会长期滞后
    var windowTtl = ttl ?? TimeSpan.FromDays(2);
    var today = BusinessToday();
    var windowKey = $"uv:{{{page}}}:rolling{windowDays}d";
    var tmp = $"uv:{{{page}}}:tmp:{Guid.NewGuid():N}";

    // 含当日:白天重建得到「截至此刻」的 N 天 UV;要完整自然日请从 today.AddDays(-1) 起算
    var keys = Enumerable.Range(0, windowDays)
        .Select(i => (RedisKey)$"uv:{{{page}}}:{today.AddDays(-i):yyyyMMdd}")
        .ToArray();

    try
    {
        // 全新临时 key,结果不含任何历史窗口数据
        await _db.HyperLogLogMergeAsync(tmp, keys);

        // tmp 的 TTL 会随 RENAME 迁移给 windowKey,不必重建后再补 EXPIRE
        await _db.KeyExpireAsync(tmp, windowTtl);

        long uv = await _db.HyperLogLogLengthAsync(tmp);
        await _db.KeyRenameAsync(tmp, windowKey);
        return uv;
    }
    finally
    {
        // 失败时清理临时 key,避免重建反复失败时堆积
        _ = _db.KeyDeleteAsync(tmp, CommandFlags.FireAndForget);
    }
}

窗口 key 不含日期,所以「重建失败」和「窗口里是昨天的数据」在 key 名上看不出来。用 TTL 兜底:2 天后自动消失,宁可没有也不给旧数据;需要更强可观测性时另存 built 时间戳 key,取值前先比对。

4.5 多维度:PV 与 UV 双口径

# 活动开始前清一次:dest 若已存在会被当成源,不清会带上上一轮数据
DEL uv:{activity}:all:20261107 uv:{activity}:src:wechat:20261107 uv:{activity}:src:search:20261107
# PV 用 INCR 精确计数,不要拿 PFADD 返回值凑
INCR pv:{activity}:20261107
# 同一用户从两个渠道进来各写一次;跨渠道重叠由并集自动去重
PFADD uv:{activity}:src:wechat:20261107 user:1001
PFADD uv:{activity}:src:search:20261107 user:1001
PFADD uv:{activity}:src:search:20261107 user:1002
# 渠道并集就是全站 UV,不要再单独维护一个「全站」HLL
PFMERGE uv:{activity}:all:20261107 uv:{activity}:src:wechat:20261107 uv:{activity}:src:search:20261107
PFCOUNT uv:{activity}:all:20261107

前提是渠道枚举完备;若有「直接访问」,建兜底 HLL 一并合并。PV 精确 + UV 估算不要混用:去重率 = UV / PV,分子在估、分母精确,边界场景留余量。写入侧用 CreateBatch 把 INCR 与 PFADD 合成一次 RTT,改完必须 await 两个 Task——只排队不 await,WRONGTYPE / CROSSSLOT 全不可见。完整写法见 4.4 的批量示例。

4.6 多机房 / 跨实例

PFMERGE 只能合并同一实例内的 key。正确做法是把各机房的 HLL 字节(每个约 12 KB)搬到中心实例写成临时 key,再在中心合并,重叠用户自动去重。

using System.Collections.Generic;

// 中心节点定时任务:各机房取字节 -> 中心临时 key -> 合并 -> RENAME;_db 为中心实例
public async Task<long> MergeAcrossRegionsAsync(
    DateTime day, IReadOnlyDictionary<string, IDatabase> regionDbs,
    string srcKey, TimeSpan destTtl)
{
    // 所有中转 key 与 dest 共用同一个 tag,否则中心侧合并会 CROSSSLOT
    var tag = $"global:{day:yyyyMMdd}";
    var dest = $"uv:global:{{{tag}}}";
    var tempKeys = new List<RedisKey>();

    try
    {
        // 不用 KeyValuePair 解构,兼容 netstandard2.0 / net472
        foreach (var kv in regionDbs)
        {
            // 只取整只二进制,不做任何解析或改写
            RedisValue blob = await kv.Value.StringGetAsync(srcKey);
            if (blob.IsNullOrEmpty || !LooksLikeHll(blob)) continue;

            // 2 小时自动过期:搬运成功与否都不会留下垃圾
            var tmp = (RedisKey)$"uv:tmp:{{{tag}}}:{kv.Key}";
            await _db.StringSetAsync(tmp, blob, TimeSpan.FromHours(2));
            tempKeys.Add(tmp);
        }

        // 所有机房当天都没数据,不必建空 HLL
        if (tempKeys.Count == 0) return 0;

        // 全新临时 key:dest 不参与合并,机房数据晚到只需重跑同一天
        var staged = (RedisKey)$"uv:global:{{{tag}}}:tmp:{Guid.NewGuid():N}";
        await _db.HyperLogLogMergeAsync(staged, tempKeys.ToArray());

        // 先设 TTL:RENAME 之后 dest 继承它,不必事后补
        await _db.KeyExpireAsync(staged, destTtl);

        long uv = await _db.HyperLogLogLengthAsync(staged);
        await _db.KeyRenameAsync(staged, dest);
        return uv;
    }
    finally
    {
        // 清理中转 key;FireAndForget 不阻塞
        foreach (var k in tempKeys)
            _ = _db.KeyDeleteAsync(k, CommandFlags.FireAndForget);
    }
}

// 搬运前粗筛:魔数 + encoding + 长度自洽。只挡误传,不是安全边界
private static bool LooksLikeHll(RedisValue blob)
{
    byte[] bytes = (byte[])blob;

    // 16 字节头 + 至少 2 字节数据,短于此不可能合法
    if (bytes == null || bytes.Length < 18) return false;

    // 魔数必须是 HYLL:这里是唯一能挡住「拿错 key」的检查
    if (bytes[0] != 'H' || bytes[1] != 'Y' || bytes[2] != 'L' || bytes[3] != 'L') return false;

    // encoding 只允许 0(稠密)/ 1(稀疏);稠密长度必须正好 12304
    byte encoding = bytes[4];
    if (encoding > 1) return false;
    return encoding == 1 || bytes.Length == 12304;
}

三条要点。只 GET / SET 整只搬运,绝不解析或改写(也可 DUMP / RESTORE);LooksLikeHll 挡不住恶意构造——长度、魔数、encoding 都对但寄存器畸形的串照样过,来源必须可信(hllMerge() 正是 CVE-2025-32023 的触发点);源 key TTL 覆盖机房最大延迟 + 回补窗口,跨版本搬运先在测试环境用 PFCOUNT 验收。

5. 怎么不被打死

5.1 容量:按 12,304 字节估,再乘 2 - 3 倍

按 1 GB = 10^9 字节换算;纯数据列只用于量级直觉,预算与告警阈值用含开销列,并用 used_memory 校验。

真正的成本在 key 数量:每加一个维度就是「约 12 KB × 天数」,所以必须设 TTL、做「日到月」降采样、把冷维度导出 OLAP 后清理。

1.2 GB 的 HLL 数据集还会在三处放大:持久化(RDB 跟着涨,AOF 每条 PFADD 都是一次写);fork 与 COW(合并集中在凌晨,正撞备份窗口);全量同步(乘以机房数就是带宽与时间)。所以容量要交两个数:used_memory 峰值 + RDB 体积 / 全量同步耗时。

5.2 热路径禁区

一条流传很广的错误建议是「大合并优先在从库执行,结果同步回主库」。PFMERGE 是 @write,在 replica-read-only yes 的从库上会被拒绝,应放独立分析实例或错峰在主库跑批;PFCOUNT 是 readonly,可在副本执行。反过来,基数只有几百时小 Set 更紧凑、可枚举,HLL 没必要上场。

5.3 hash tag 的代价

同 tag 的所有 key 固定落同一 slot。按 {page} 绑定,这个页面的日 / 周 / 月 / 渠道 key 永远在同一分片上:热点分片(全部 PFADD 压在单线程)、slot 倾斜(reshard 拆不开这个 tag)、单分片 maxmemory 成为硬上限。

三种缓解:tag 加时间桶(如 {home:2026W45},跨周期合并要先搬字节);热点维度放独立实例;缩小同 tag 集合。原则:先确定「哪些 key 必须同槽」,再设计 tag;上线后用 CLUSTER COUNTKEYSINSLOT 与各分片 used_memory 看分布。

5.4 淘汰、热点与可用性

另外:重跑要用「临时 key + RENAME」,别「先 DEL 再合并」留空窗;机房数据晚到只能靠源 key TTL 覆盖延迟 + 重跑同一天兜底。

5.5 安全与升级:这一节比性能重要

官方 PFCOUNT 页那句「对损坏的 HLL 执行 PFADD / PFCOUNT / PFMERGE 不影响服务端稳定性」是正常误操作语境下的旧表述,不是安全承诺:

  • CVE-2019-10192 / 10193(2019):损坏的 HLL 可触发堆 / 栈越界写;影响 3.x < 3.2.13、4.x < 4.0.14、5.x < 5.0.4。
  • CVE-2025-32023 / GHSA-rp2m-q4j6-gr43(2025,高危,CVSS 7.0):稀疏编码的游程长度累加发生整数溢出,特制字符串可在 hllMerge()(栈)或 hllSparseToDense()(堆)造成越界写,可致远程代码执行;影响 >= 2.8,修复版本 8.0.3 / 7.4.5 / 7.2.10 / 6.2.19,5.x 及更早没有补丁。

四条工程要求。

  1. 升到修复版本之一,把 HLL 安全公告纳入升级流程;升不动的 5.x 只能用 ACL 禁用 HLL 命令。
  2. 用 ACL 把 @hyperloglog 与 SET / SETRANGE / RESTORE 按最小权限分开授予,不给不可信客户端同时开放 PF* 和写 String 的能力。
  3. 跨实例搬运只接受可信来源,外来字节先校验 HYLL 魔数与 encoding;别让不可信输入直接进 PFMERGE——PFMERGE 不是合法性校验器。
  4. 成员推断侧信道:哈希是 MurmurHash64A,seed 固定且不含密钥,寄存器可复算。拿到完整字节(GET / DUMP / RDB 外泄)后,对候选 uid 逐个算 index 与 rho 比对,就能判定「这个人大概来过」——有假阳性,但足以缩小候选集。所以 HLL key 导出与备份按敏感数据管理;确需抗推断时给标识加盐,代价是无法跨实例合并。

5.6 监控与误差抽样

IServer.Keys() 内部是长 SCAN 循环,单次 SCAN 0 只返回一批,拿那个结果当全量 key 数就会误判;这项统计只适合低峰跑或抽样,别挂进每分钟执行的监控。

误差校验必须离线(不要线上双写):用同口径的 Set 或离线明细做基准,样本选基数 1 万 - 10 万。每批抽 30 个 key 算 abs(estimate - exact) / exact 与 0.81% 比;单 key 超 3 sigma(约 2.43%)先查工程链路(埋点丢失、标识混用、key 漂移、TTL 提前过期、汇总漏合并),最后才怀疑算法;同批 key 系统性同向偏移几乎一定是口径问题。

HLL 的误差是可控的,工程失误是没底线的。

6. 踩过的 18 个坑

按严重度排序:高 = 金额差、数据错或不可恢复;中 = 性能、集群、生命周期;低 = 认知偏差。


7. 上线前 18 项核对

口径与建模

  1. 书面确认时区 / 自然日、事件时间 vs 到达时间、UV 标识定义与治理规则(4.1)
  2. 「今天」由业务时区换算,不依赖服务器本地时区(4.1 / 4.4)
  3. HLL 用独立前缀 uv:,周期用定长格式,全链路统一标识(4.2)
  4. 集群下所有需要合并的 key(含 destkey)同 tag,且已评估倾斜(3.6 / 5.3)

精度与边界

  1. 与产品 / 运营书面确认「0.81% 是 1 sigma,不是上限」(1.1)
  2. 熔断、结算、抽奖判新等阈值 / 金额场景禁用 HLL(1.3)
  3. 不把 PFADD 返回值、PFCOUNT 结果用于业务分支(3.3)

命令与性能

  1. 实时链路只允许 PFADD 与单键 PFCOUNT;多键 PFCOUNT / PFMERGE 一律离线(5.2)
  2. 并集查询先 PFMERGE 落结果 key,再单键查询(3.4)
  3. 重建类合并用「临时 key + RENAME」,追加类保留 destkey(3.5 / 4.3)
  4. 完成实测:单键缓存命中吞吐、缓存失效 P99、7 键与 30 键合并耗时,填表后据此设阈值(5.2)
  5. 埋点写入批量化;需要原子性用 Lua 而非 MULTI/EXEC(4.5 / 5.4)

生命周期与容量

  1. 每个 key 显式 TTL:日 35 天 / 周月 90 天 / 窗口 2 天 / 临时 1 - 2 小时(4.2)
  2. destkey 手动补 EXPIRE;关键报表配离线清理与降采样落库(4.2)
  3. 容量按含 key 开销口径预估,同时给 used_memory 峰值与 RDB 体积 / 全量同步耗时(5.1)

安全与观测

  1. 版本已升到 CVE-2025-32023 修复版;升不动则用 ACL 禁用 PF*,并按最小权限隔离 String 写命令(5.5)
  2. 外来字节只来自可信来源,LooksLikeHll 只当过滤器不当安全边界;key 导出与备份按敏感数据管理(5.5)
  3. 监控 key 总数、无 TTL key、接近 12 KB 占比、慢日志、used_memory、slot 分布,并按 5.6 离线抽样留记录(5.6)
posted @ 2026-11-08 08:47  IT规划师  阅读(0)  评论(0)    收藏  举报