
前阵子复盘大促活动时发现一个很有意思的现象同样是用Redis统计UV有的团队还在一个Set里硬塞用户ID有的团队已经用HyperLogLog把内存压到十几KB而真正玩得溜的团队早就不满足于单一数据结构开始把Hash和HyperLogLog组合起来用。统计UVUnique Visitor独立访客这件事表面看就是去重加计数但流量一旦上到千万甚至亿级数据结构选型、写入路径、聚合策略都会直接影响线上成本。这篇文章围绕Redis场景聊聊从Hash分桶精确统计到HyperLogLog近似统计再到两者融合的3种高阶玩法帮你按业务精度要求找到最适合的方案。1. 选型之前先搞清楚UV统计的四个边界条件1.1 UV统计绝不是“存个ID 计个数”那么简单很多第一次做UV统计的人第一反应是在Redis里存一个Set把每个用户ID都加进去最后SCARD一数就完事。这种做法在小流量阶段没什么问题但用户量一涨坏消息就来了Set内存随用户数线性增长更难受的是业务往往还要按天、按小时、按渠道、按活动维度统计每个维度一个Set最后内存完全失控。我在实际的业务复盘里发现UV统计在选型前必须问自己四个问题。第一个问题能不能接受误差。运营看板上差几千个UV根本没人发现但广告计费场景差一个都不行。第二个问题需不需要查出具体是哪些用户。HyperLogLog只能给你一个近似数字没法回答“把今天的前1000个新客名单给我”这种请求。第三个问题数据量级大概是多少。百万级、千万级还是亿级直接决定Hash分桶的桶数量和内存预算。第四个问题统计的时间粒度是什么。只统计当天还是要做周、月、季度累计这会影响要不要做多级聚合。这四个问题想清楚再看Hash和HyperLogLog就不会盲目照搬网上的方案了。所有技术选型本质上都是在精确、内存、查询能力和开发成本之间做权衡UV统计也一样。没有绝对好的数据结构只有适不适合当前业务场景的组合方式。1.2 Hash、HyperLogLog、融合方案一张表看清差异在展开三种玩法之前我先把它们放在一张表里对比。这张表是我自己在项目里经常用来和团队成员对齐认知的遇到新同学问“为什么不直接上HyperLogLog”我就把这张表丢过去。方案精确度内存成本是否支持用户明细典型场景Hash分桶精确随用户数线性增长千万级通常数百MB支持可查询每个用户ID精确日报、新客名单、需要判重的场景HyperLogLog近似标准误差约0.81%固定约12KB与基数大小无关不支持只能得到估算值实时大盘、流量趋势、海量累计UVHash HyperLogLog融合短期精确长期近似短期Hash 长期HLL成本远低于全量精确存储短期支持长期仅估算当日精确运营、月度/年度留存分析需要注意这里的HyperLogLog固定12KB是Redis实现中的常规大小实际占用约12288字节误差并不是固定不变的它和数据量、合并方式都有关系后面我会详细说。融合方案则是把Hash和HLL的长处拼在一起用短期精确对冲长期内存膨胀是很多大厂做用户分析的底层逻辑。2. 玩法一Hash分桶精确统计2.1 分桶的核心思想把大key拆成一组可管理的小key用Hash做UV统计最直接的想法是建一个Hash每个用户ID作为fieldvalue存访问次数或时间戳统计时HLEN。这个方案能精确去重也不会自动带上重复数据。但一个致命问题是高并发下所有写入都落到同一个key上单个key会变成热点Redis是单线程处理命令的一个热点key会让整个实例的CPU被拖住。我采用的方案是分桶。把用户ID通过哈希函数映射到固定数量的桶里每个桶对应一个Hash key。伪代码如下import zlib def bucket_index(user_id: str, bucket_count: int 1024) - int: # 使用CRC32把字符串映射成固定范围的桶编号 return zlib.crc32(user_id.encode(utf-8)) % bucket_count def uv_key(date: str, user_id: str, bucket_count: int 1024) - str: bucket bucket_index(user_id, bucket_count) return fuv:{date}:{bucket}为什么用CRC32而不是简单取len的哈希因为CRC32能把不同长度的字符串均匀打散而且在Redis Cluster环境下基于这个桶编号还能继续拆key。分桶之后同一个用户每次都会被分配进同一个桶不会出现一个用户在多个桶里重复的问题。统计全量UV时只需要把1024个Hash的HLEN相加再用Pipeline一次性取回不会有太大延迟。分桶数量也不是越大越好。桶太少热点问题解决不了桶太多统计聚合会变慢内存碎片也可能上升。我在百万到千万级用户量的项目里一般取1024或2048个桶平均每个桶几千到几万个用户Redis处理起来毫无压力。如果你是一亿以上用户量建议试试4096个桶但要做好统计时Pipeline批次管理的准备。2.2 为什么HSETNX是去重计数的原子利器Hash分桶方案的核心命令是HSETNX。它和HSET不同的地方在于只有当field不存在时才会写入。当多个请求同时处理同一个用户ID时只有一个请求能成功写入返回1其余请求返回0。这个原子性判断在Redis单线程执行模型下天然安全不需要额外加分布式锁。写入逻辑可以这样设计def add_uv(user_id: str, date: str) - bool: key uv_key(date, user_id) # 返回1表示这个用户第一次出现返回0表示已经统计过 return redis_client.hsetnx(key, user_id, 1) 1这样做的好处是写操作就是判重操作不需要先SISMEMBER再SADD那样分成两步。如果业务需要知道用户是“新访客”还是“老访客”HSETNX的返回值直接就是答案。而且Hash本身还支持读字段想查某个用户最近访问时间把value改成时间戳HGET一下就行。有一点要注意Hash的field不能设置TTL只能对整个key设置过期时间。如果业务需要保留单个用户维度上的不同期限那就不能只靠Hash得配合其他结构或定时清理。这个问题我在后面工程落地部分会单独说。2.3 1000万UV的Hash方案内存估算很多同学问我一个实际问题用Hash分桶存1000万用户IDRedis到底要吃多少内存这个问题没有一个标准答案因为你存的是自增数字还是UUID字符串内存差别很大。只分析用户ID是20字节随机字符串的情况。Redis的Hash在字段数量较小且value较短时会使用紧凑编码结构旧版本叫ziplistRedis 7.0之后叫listpack每个字段的内存占用约等于field长度加上一些额外指针和元数据。如果单个桶字段数变多底层会转换为真正的hashtable内存开销还会再大一些。拿我自己实测过的环境来说20字节左右的用户ID存1000万条Hash分桶方案总体占用在400MB到600MB之间取中间值大概500MB。这已经比Set方案节省不少了因为Set的member存储通常比Hash的field还要多一层指针。但500MB对一个缓存实例来说仍然不小。所以玩法一从来不是无脑的省内存方案而是用内存换确定性和精确性。只有业务确实需要精确用户明细、精确新客判断的时候我才会推荐它。如果只想要一个UV趋势数字直接看下面的HyperLogLog。2.4 适用场景与局限基于Hash分桶做UV统计我最常推荐给两类场景。一是运营活动页的精确UV统计活动周期短用户量可控但运营要能导出一份“参与用户ID名单”用来发奖品。二是需要实时判断“这个用户是否新客”的业务例如首单优惠、新人礼包判断HSETNX的原子返回可以直接作为发券依据。它的局限也很明显内存随用户数线性增长不适合跨很长时间的累计统计。如果做一个持续一年签到活动每天都要精确查当日的用户一年下来Hash数据会非常多。这时候就需要引入HyperLogLog做更长周期的压缩存储也就是后面的融合方案。3. 玩法二HyperLogLog的海量近似统计3.1 抛硬币实验HyperLogLog在干什么先讲讲HyperLogLog的原理不然你只知道用它出了问题根本不会排查。想象你在做抛硬币实验抛到正面记为0抛到反面记为1记录连续反面的最大长度。你会发现总抛掷次数越多出现连续反面的最大长度的期望也越大。反过来你只要知道连续反面的最大长度就能估算总共抛了多少次硬币。HyperLogLog的思路就和这个实验类似但它比单个实验更精密。具体到Redis实现每来一个用户ID会先通过哈希函数生成一个很长的二进制串比如64位。这个二进制串的前若干位用来决定放进哪个桶剩余位从低位开始看记录第一次出现“1”的位置。所有桶都维护一个当前遇到的位置的最大值。最后把所有桶里的最大值做调和平均再乘一个修正系数得到近似的基数估计。Redis默认16384个桶每个桶用6bit存储总内存正好在12KB左右。这解释了为什么HyperLogLog的误差基本和基数大小无关基数再大存储的还是每个桶里的那几位最大值。所以用12KB估算百万和估算十亿内存完全一样误差都保持在1%附近。3.2 PFADD、PFCOUNT、PFMERGE的正确打开方式Redis对HyperLogLog提供了三个高频命令PFADD、PFCOUNT、PFMERGE。PFADD负责把元素加入PFCOUNT负责返回估算基数PFMERGE负责把多个HyperLogLog合并成一个。我在项目里通常这样用import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) # 写入当日UV一次可以加多个用户ID r.pfadd(uv:20240617, user_001, user_002, user_003) # 查询当日UV估算值 today_uv r.pfcount(uv:20240617) print(今日UV约:, today_uv) # 合并7天数据得到周UV r.pfmerge(uv:20240610_20240617, uv:20240610, uv:20240611, uv:20240612, uv:20240613, uv:20240614, uv:20240615, uv:20240616, uv:20240617) week_uv r.pfcount(uv:20240610_20240617) print(本周UV约:, week_uv)这里要注意一点PFCOUNT传入多个key时Redis会自动执行临时合并再计数在这个临时过程中会产生CPU开销不建议频繁对很多key做PFCOUNT。正确做法是先用PFMERGE合并成目标key再对目标key做PFCOUNT。而PFMERGE本身是支持多个key一起合并的语法上比多次PFCOUNT更友好。3.3 误差到底多大能不能调小Redis HyperLogLog的标准误差是1.04除以根号下桶数默认16384个桶算下来大约是0.81%。也就是说真实UV是100万时PFCOUNT的结果大概率落在99.2万到100.8万之间。我自己用生产数据做过一次压测真实值50万的时候PFCOUNT返回50.3万左右误差0.6%真实值300万的时候返回301.5万左右误差0.5%基本都在0.81%以内。但这个误差在某些情况下会变得不那么稳定。一个典型问题是数据量很小时比如基数只有几十个HLL可能因为寄存器还没被充分填充估算偏差反而容易偏高或偏低。另一个典型问题是多个HLL合并后如果这些HLL本身基数很小合并结果可能会因为取最大值的方式产生正误差。不要对PFCOUNT返回的小数点后几位过于较真它天生就是一个概率结构。能不能调小误差可以但你在Redis层面调不了因为桶数在创建时就被固定了。要调误差只能换算法或者自己在应用层做多HLL分片把用户ID按哈希分到多个HLL key里查询时PFMERGE再PFCOUNT。分片增多相当于把总寄存器数变大误差会略微降低但代价是查询时需要合并。我一般不建议为了把0.8%压到0.5%付出那么大的复杂度除非业务有硬性要求。3.4 多时间粒度聚合的应用HyperLogLog最漂亮的一点是支持快速跨时间维度聚合。日常中我们需要看小时、日、周、月维度的UV如果把每天的每个用户ID都保存下来成本不可想象。但用HLL我们每天只需要维护一个约12KB的key月底把30个key合并一次就能得到全月UV估算。我做过一个实时流量看板核心逻辑就是三句话按小时建HLL key每收到一个访问事件就PFADD到当前小时的HLL前端看小时维度直接PFCOUNT看日维度把当天24个HLL合并看周维度把7天HLL合并。整个过程内存消耗几乎不涨查询速度也快运营同学完全接受0.8%左右的误差。不过这引出了一个新问题时间维度越宽合并的HLL数量越多查询时临时合并的CPU开销会上升。我后来的优化是不实时做跨天PFCOUNT而是每天凌晨用定时任务把前一天24小时的HLL合并成“日HLL”再把7个日HLL合并成“周HLL”用户看报表时只做一次PFCOUNT响应时间一下子就降下来了。4. 玩法三Hash HyperLogLog 分层融合玩法4.1 为什么不直接把历史数据都留在Hash里前面说了Hash分桶能精确到用户ID但内存随数据量线性增长。如果你每天一个Hash连续跑一年存储的就是一年每一天的全量用户ID这对任何一个非核心报表来说都太奢侈了。我问过一些团队他们其实只需要当天的精确数据历史月份的统计能看出大致趋势就行但因为没有做分层只能让Hash一直留在Redis里内存越来越臃肿。另一个角度是HyperLogLog虽然省内存但只支持新增不支持删除和查询用户明细。运营找你要某天的新客名单时你总不能给一个近似概率结构让他们自己猜。所以最优解从来不是在Hash和HLL里二选一而是把它们按时间价值和查询需求分成两层短期用Hash保证精确长期用HLL保证成本和可聚合性。4.2 融合架构短期Hash精确 长期HLL近似的双层设计我改造过的UV统计系统最终落地的是这个架构。当天实时数据同时写入两个地方一个是按天分桶的Hash用于精确的当日UV、新客判断、用户名单查询另一个是按天创建的HyperLogLog用于当天数据的长期留存和后续跨天聚合。Hash只保留最近7天或30天到过期时间后直接删除HyperLogLog因为是12KB级别的小体积可以长期保留。举个例子假设今天是6月17号你会同时看到两类keyuv:hash:20240617:{bucket} # 分桶Hash精确记录今天访问用户 uv:hll:20240617 # 今天的HLL估算今天UV留给未来聚合到了7月1日6月17日的Hash早就因为TTL过期被清理了但6月17日的HLL还在。要统计6月整月UV直接把6月1日到30日的HLL全部PFMERGE成一个key再PFCOUNT一次几毫秒就能拿到近似值。如果想看某一天的精确名单因为Hash保留期还没过依然可以直接查。如果Hash已经过期那就只能说明你当时没有给这类查询预留窗口这是需要提前和业务对齐的。4.3 写入与查询路径的代码级实现在实际代码里融合方案的写入路径不是简单地调两个Redis命令我推荐用Lua脚本把HSETNX和PFADD包成一次原子操作。这样既能保证“写Hash成功”和“写HLL成功”在逻辑上处于同一时间点又能减少一次网络往返。-- uv_record.lua -- KEYS[1] 分桶Hash key -- KEYS[2] 当日HLL key -- ARGV[1] 用户ID local added redis.call(hsetnx, KEYS[1], ARGV[1], 1) redis.call(pfadd, KEYS[2], ARGV[1]) return added调用方式如下def record_uv(date: str, user_id: str) - bool: bucket zlib.crc32(user_id.encode(utf-8)) % 1024 hash_key fuv:hash:{date}:{bucket} hll_key fuv:hll:{date} script local added redis.call(hsetnx, KEYS[1], ARGV[1], 1) redis.call(pfadd, KEYS[2], ARGV[1]) return added added redis_client.eval(script, 2, hash_key, hll_key, user_id) return added 1查询路径分成两种查当天精确UV直接对当天所有分桶Hash执行HLEN用Pipeline把1024个桶一次性拿回来求和。查累计趋势UV对目标时间范围内的所有HLL做PFMERGE再PFCOUNT。如果业务对查询性能要求极高还可以预先在凌晨把昨天和前天的HLL合并成日粒度key报表查询只访问合并结果。这套设计有一个很实用的副产品HSETNX的返回值天然就是“是否新客”的标志所以在同一套架构里新客判断、实时UV看板、精确用户名单都能覆盖。运营再也不用来回追问“这个数是估的还是准的”只要说明日期范围就能明确回答是精确值还是估算值。4.4 融合方案的成本与精度实测我在一个日均百万访问的项目里对比过纯Hash和融合方案。纯Hash保留30天数据内存峰值约1.5GB融合方案里Hash仍保留30天但历史月份只保留HLL半年累计后Redis内存只增加不到200MB等于用1/10的成本换来了半年的趋势数据。精度方面当天精确UV来自Hash是准确的。跨月估算UV来自HLL合并我在抽样核对中发现和从完整日志离线算出的真实UV相比误差在0.3%到1.2%之间多数情况下低于0.8%。对增长趋势分析来说这个误差完全可控。唯一需要注意的是随着HLL合并次数增加误差不会线性放大但也不该期望它比单HLL更精确。如果某一天你发现月度UV比把每天精确UV加起来还大那不是Bug而是HLL合并后的正常概率波动。5. 工程落地中的避坑经验5.1 写入放大和热点Key给HLL加分片即便用了融合方案也会遇到一个工程细节当天只有一个HLL key如果流量峰值太过集中这个key会成为热点。Redis单线程处理PFADD单命令本身很快但每秒几十万次写操作落在一个key上CPU也会报警。我的解决办法是给HLL做分片比如在HLL key后面按用户ID哈希再加一个分片号。写入时根据用户ID选分片查询时把所有分片PFMERGE再PFCOUNT。这个操作会稍微增加查询成本但换来了写流量的水平扩展。实测中从单HLL改成4分片后写入峰值QPS有接近4倍提升空间误差不会因为分片增多而有明显变化。分片不是越多越好。我见过一个团队给HLL做了256个分片结果每次查询都要合并256个key耗时不稳定。通常4到8个分片已经足够除非你的写入量已经大到单实例都扛不住那就应该先考虑扩容Redis实例而不是无限分片。5.2 回刷历史数据时要注意的阻塞问题有时候你会遇到活动补数据、日志重放、历史迁移这样的情况。用PFADD回刷HLL是幂等的同一个用户重复添加不会导致计数翻倍所以相对安全。但大批量回刷时命令如果一条一条发不仅慢还会给Redis造成大量无效网络请求。我习惯用Pipeline批量发送每批500到1000个元素。Hash回刷则要多留一个心眼。HSETNX本身也是幂等的但如果回刷的数据量远大于当前已有数据Hash底层编码可能从紧凑结构转成hashtable这个转换过程会占用额外内存和时间。回刷期间要盯紧Redis的used_memory避免内存飙升触发淘汰策略把热数据赶出去。有一个更隐蔽的坑回刷时如果不断对同一个分桶Hash写入可能让这个key的ERTL? 计算压力变大又因为HSETNX的field本身不带时间戳你还没法知道哪些数据是旧回刷、哪些是新实时。所以我会在回刷之前先把回刷目标的TTL临时延长回刷完成后再重新调整。一旦出问题至少还有时间容错。5.3 命名规范、过期策略与监控工程上如果没有命名规范几个月后你自己看到一堆key都会懵。我常用的一套规范如下数据类型命名格式示例Hash分桶uv:hash:{yyyyMMdd}:{bucket}uv:hash:20240617:1023HyperLogLog当日uv:hll:{yyyyMMdd}:{shard}uv:hll:20240617:0合并统计用HLLuv:hll:week:{yyyyMMdd}uv:hll:week:20240617过期策略上Hash默认保留7天如果业务需要更长的精确名单窗口可以保留30天但要在监控里明确这些key占用的内存。HLL建议至少保留13个月方便做同比分析。不要把所有HLL都设置为永不过期否则时间长了还是会有不必要的累积建议用定时任务扫描过期key。监控指标方面我至少盯四项Redis内存使用量、指定业务前缀的key数量、PFCOUNT和PFMERGE的P99耗时、慢查询日志里是否出现大量操作HLL的命令。有一次线上查询变慢最后定位到是一个报表页面直接对100多个HLL做一次性PFCOUNT临时合并开销导致Redis CPU升高。后来改成预聚合HLL问题立刻消失。这类问题只靠压测很难发现必须有监控和日志来兜底。最后再分享一个选型的心得。我踩过最大的坑是只看内存不看业务精度要求结果上线后运营要求精确用户名单只能临时回补日志折腾了一整晚。后来我学乖了所有UV统计需求先问两个问题能不能接受0.8%误差需不需要查具体用户。这两个问题能过滤掉大部分不合理的方案。Redis里的Hash和HyperLogLog从来不是对立关系把它们当作工具箱里的不同扳手按时间维度和精度需求组合使用才是海量UV统计的成熟做法。