
1. 项目概述为什么Redis统计是数据处理的“宝藏”在数据驱动的时代统计是几乎所有业务系统的核心需求。从简单的用户在线数、页面访问量到复杂的去重用户数、热点商品排行统计无处不在。很多开发者一提到统计第一反应就是写SQL把数据从业务库捞出来扔给MySQL或者大数据平台去跑。这当然没错但对于实时性要求高、并发量大的场景这种传统方式往往会成为性能瓶颈甚至拖垮整个系统。我经历过一个典型的案例一个内容平台的“文章阅读数”统计最初是每次阅读都去更新MySQL的一个计数字段。在流量高峰期这个简单的UPDATE操作直接导致了数据库连接池耗尽和严重的行锁竞争页面打开速度慢得令人发指。这时候Redis的价值就凸显出来了。它不仅仅是一个缓存更是一个高性能的数据结构服务器。今天我们就来深挖Redis在统计领域的“四重宝藏”——这四种数据结构或功能能帮你以极低的成本和极高的效率解决那些让传统数据库头疼的统计难题。它们不是冷门功能而是经过无数大厂实战验证的“银弹”。无论你是想实时统计UV还是快速找出某天的活跃用户亦或是进行简单的聚合运算Redis都可能藏着比你想象中更优雅的解决方案。这篇文章我们就抛开理论直接上手看看如何用HyperLogLog、Bitmap、Sorted Set和String的INCR系列命令构建起一套强悍的实时统计体系。2. 第一重宝藏HyperLogLog —— 海量数据去重计数的“空间魔术师”当你需要回答“这个活动页面有多少独立访客UV”时你面临的是一个去重计数问题。如果用户ID是数字或较短的字符串你或许可以用SET存储所有ID然后用SCARD命令获取集合大小。这很精确但如果UV量级达到百万、千万甚至上亿呢存储一个包含1亿个用户ID的集合内存消耗将是巨大的可能达到几个GB这成本难以接受。HyperLogLogHLL就是为此而生的。它的核心价值在于用极小的、固定的内存空间约12KB以可接受的误差率标准误差约0.81%估算出海量集合的基数去重后的元素个数。是的它是估算不是精确计数。但在大多数业务场景下UV统计的误差在1%以内是完全可接受的比如一个100万UV的活动实际结果在99万到101万之间对于运营分析来说其价值与精确数字无异。2.1 HLL 的工作原理与内存奥秘HLL的原理源于概率论。它并不存储每个元素本身而是通过一个哈希函数将每个元素映射成一个很长的二进制串比如64位。然后它观察这个二进制串从最低位开始连续出现0的个数称为“前导零”的个数。一个简单的直觉是如果哈希函数足够均匀那么你看到一次很长的“前导零”序列比如...000001的概率是很低的。一旦你看到了就暗示着可能已经输入了非常多的元素。HLL算法通过多个“桶”Redis实现中是16384个来记录这种观察结果并使用调和平均数来平滑估算最终得出一个稳定的基数估计值。为什么内存固定是约12KB因为Redis的HLL实现使用了2^14 16384个桶每个桶只需要6个比特bit来记录“前导零”的最大值因为64位哈希前导零最多63个需要6比特表示。所以总内存是16384 * 6 bit 98304 bit 12288 Byte 12 KB。无论你要统计1万个还是10亿个独立元素它都只占这12KB这就是“空间魔术”的本质。2.2 实战用PFADD和PFCOUNT统计UV使用起来非常简单。假设我们要统计2024年5月1日某个活动页的UV。添加元素每当有一个用户访问我们就将其唯一标识如用户ID或设备ID添加到对应的HLL中。# 键名设计uv:activity:20240501 PFADD uv:activity:20240501 user_id_001 user_id_002 user_id_003PFADD命令会返回1表示至少有1个元素是新的影响了基数估算返回0表示所有元素可能都已存在。注意即使user_id_001被重复添加也不会影响最终的计数。获取估算值PFCOUNT uv:activity:20240501这个命令会返回估算的独立用户数。合并数据HLL支持合并。如果你想统计整个5月份的UV可以合并每天的数据。# 假设已经存在 uv:activity:20240501, uv:activity:20240502 ... uv:activity:20240531 PFMERGE uv:activity:202405_total uv:activity:20240501 uv:activity:20240502 ... uv:activity:20240531 PFCOUNT uv:activity:202405_totalPFMERGE命令会创建一个新的HLL其基数是所有源HLL基数的并集估算值。注意HLL的合并是“无损”的合并后的误差率仍然保持在标准误差范围内不会因为多次合并而误差累积。2.3 适用场景与避坑指南最适合的场景网站/APP/活动页面UV统计这是HLL的经典用例。搜索关键词去重统计统计一天内有多少不同的搜索词。大型集合的近似成员检测结合PFADD的返回值虽然不能精确判断某个元素是否存在但如果你添加一个元素后PFADD返回1它很可能是新元素返回0则它很可能已存在。这在某些风控或去重场景有参考价值。需要避开的坑需要精确结果的场景比如金融交易流水计数、需要严格对账的场景绝对不能使用HLL。小数据量场景当元素数量很少比如小于几百时HLL的相对误差可能显得较大且其12KB的内存开销可能比直接用SET存储原始ID还要大得不偿失。误用作集合HLL不支持SMEMBERS这样的操作你无法取出里面存了哪些元素。它生来就是为了计数不是为了存储。个人心得在UV统计中我习惯将HLL的键名按“业务:维度:时间粒度”来设计例如uv:article:12345:daily:20240501。这样结构清晰也方便用KEYS或SCAN模式进行合并或清理过期数据。另外务必在业务需求评审时就和产品、运营明确这是“估算值”取得他们的理解避免后续产生误会。3. 第二重宝藏Bitmap —— 二值状态与区间统计的“位操作大师”如果说HLL解决的是“有多少个”的问题那么Bitmap位图解决的就是“哪些有”和“有多少个有”的问题特别是当状态只有“是/否”两种时。Bitmap的本质是一个以位bit为单位的数组数组的每个下标称为偏移量offset只能取0或1。在Redis中Bitmap是基于String类型实现的你可以把它想象成一个超长的二进制字符串并提供了强大的位操作命令。3.1 Bitmap的内存效率与核心命令它的内存效率极高。假设你需要记录用户ID从1到1亿的每个用户是否完成了某个任务。如果用SET存储已完成用户的ID假设每个ID用8字节long型1千万用户就要约80MB。而使用Bitmap你只需要开辟一个足以容纳最大用户ID的位数组1亿个用户只需要100,000,000 bit / 8 / 1024 / 1024 ≈ 11.92 MB。实际上Redis会按需分配内存如果只有前100万个用户有记录内存占用会更少。核心命令包括SETBIT key offset value设置指定偏移量上的位0或1。GETBIT key offset获取指定偏移量上的位值。BITCOUNT key [start end]计算指定字节范围内值为1的位的数量。BITOP operation destkey key [key ...]对一个或多个Bitmap进行位运算AND, OR, XOR, NOT结果存到destkey。BITPOS key bit [start] [end]查找第一个被设置为0或1的位的位置。3.2 实战场景一用户签到与活跃度统计这是Bitmap最经典的应用。假设我们记录用户每月的签到情况用户ID为10086。# 用户10086在5月1日签到偏移量设为0代表当月第1天 SETBIT sign:10086:202405 0 1 # 在5月15日签到偏移量14 SETBIT sign:10086:202405 14 1 # 检查5月1日是否签到 GETBIT sign:10086:202405 0 # 返回 1 # 统计该用户5月份总签到次数 BITCOUNT sign:10086:202405 # 返回 2 # 统计5月份前15天的签到次数 BITCOUNT sign:10086:202405 0 1 # 参数是字节索引0-1字节覆盖了偏移量0-15位更高级的用法统计全平台某天的活跃用户数。假设我们有1亿用户用户ID是连续的数字。我们可以为每一天创建一个Bitmap键名为active:20240501如果用户ID为10086的用户在当天活跃就执行SETBIT active:20240501 10086 1。那么查询某用户某天是否活跃GETBIT active:20240501 10086查询某天总活跃用户数BITCOUNT active:20240501查询用户10086在5月份哪些天活跃了连续签到这需要遍历active:20240501到active:20240531这31个Bitmap对每个执行GETBIT或者用BITOP OR合并后再分析。3.3 实战场景二利用BITOP进行人群圈选这是Bitmap更强大的能力。假设我们有三个标签Bitmaptag:male记录所有男性用户的ID位。tag:vip记录所有VIP用户的ID位。tag:beijing记录所有北京用户的ID位。我们想找出“北京地区的男性VIP用户”这个人群。# 第一步求交集。将 male 和 vip 的Bitmap做AND运算结果存到 temp1 BITOP AND temp1 tag:male tag:vip # 第二步将上一步结果再与 beijing 的Bitmap做AND运算得到最终人群 BITOP AND result_population temp1 tag:beijing # 第三步统计这个人群的数量 BITCOUNT result_population # 第四步可选如果你想得到具体的用户ID列表可以编写一个脚本遍历result_population中所有为1的位offset这个offset就是用户ID。这个操作的速度极快即使面对亿级用户也能在毫秒级完成复杂的多标签人群圈选非常适合用于实时营销、广告投放等场景。3.4 注意事项与性能考量偏移量Offset的选择偏移量必须是非负整数。通常直接使用用户ID、日期序号等作为offset。如果用户ID不是连续数字或数值过大比如UUID需要先通过一个映射表例如另一个Redis Hash将其转换为一个连续的整数ID这样才能高效使用Bitmap。否则一个稀疏的、offset值巨大的Bitmap会浪费大量内存。大Key问题一个覆盖1亿用户的Bitmap大小约12MB。如果这样的Key非常多比如存储365天的日活Bitmap总内存消耗会很大。需要有合理的过期策略例如只保留最近30天的日活数据或归档到其他存储。BITOP的性能BITOP操作是O(N)复杂度N是参与运算的最长Bitmap的长度以字节计。对非常大的Bitmap进行运算尤其是OR或XOR可能会阻塞Redis较长时间百毫秒甚至秒级。在集群模式下BITOP要求所有源key必须在同一个slot上这增加了使用的复杂度。内存碎片Bitmap在首次设置某个偏移量的位时Redis会分配足够的内存空间。如果设置的offset跨度很大比如先SETBIT key 1000000000 1Redis会立即分配约125MB的内存即使你只设置了一个位。后续的位设置会在这个已分配的空间内进行不会引起再次扩容。个人心得在用户行为标记、签到、简单标签系统上Bitmap是无敌的。但在设计之初一定要规划好“用户ID到连续偏移量”的映射关系这是用好Bitmap的前提。对于需要频繁进行复杂BITOP运算的场景建议在从节点上执行或者将运算任务移到客户端避免阻塞主节点。同时监控Bitmap Key的大小防止其无限制增长。4. 第三重宝藏Sorted Set —— 排行榜与滑动窗口统计的“多面手”Sorted Set有序集合是Redis里功能最丰富的数据结构之一。每个成员member都关联一个分数scoreRedis根据分数对成员进行从小到大的排序。分数可以重复但成员必须唯一。这特性让它天然适合做排行榜。4.1 基础排行榜与聚合统计# 添加玩家得分 ZADD leaderboard:20240501 1500 “player_A” 3200 “player_B” 2800 “player_C” # 获取Top 3 ZREVRANGE leaderboard:20240501 0 2 WITHSCORES # 获取玩家B的排名从高到低0起始 ZREVRANK leaderboard:20240501 “player_B” # 获取分数在2500到3500之间的玩家 ZRANGEBYSCORE leaderboard:20240501 2500 3500 WITHSCORES除了排行榜ZCOUNT命令可以快速统计某个分数区间的成员数量这本身就是一个高效的统计操作。4.2 高级用法滑动时间窗口统计这是Sorted Set一个非常巧妙的用法用于统计最近一段时间内例如最近1小时发生的某类事件的数量。核心思想是用时间戳作为分数事件ID或内容作为成员。假设我们要统计用户user_123最近一小时内的登录次数。# 用户每次登录时记录一个事件。成员内容不重要可以用固定值如“login”分数用当前时间戳。 ZADD login:user_123 1714550400 “login” # 假设时间戳为1714550400 # 统计最近一小时内的登录次数 # 1. 获取当前时间戳 current_time # 2. 计算一小时前的时间戳 one_hour_ago current_time - 3600 # 3. 使用 ZCOUNT 统计分数在 [one_hour_ago, current_time] 之间的成员数 ZCOUNT login:user_123 one_hour_ago current_time这个ZCOUNT操作的时间复杂度是O(log(N))速度非常快。为了清理旧数据可以启动一个定时任务定期执行ZREMRANGEBYSCORE来删除一小时前的数据ZREMRANGEBYSCORE login:user_123 -inf (one_hour_ago # 删除严格小于 one_hour_ago 的数据(one_hour_ago表示不包含边界确保正在使用的窗口边界数据不被误删。这个模式的变体可以用于很多场景API调用频率限制记录用户每次API调用用ZCOUNT检查最近1秒/1分钟的调用次数是否超限。最近N条操作记录结合ZREVRANGE可以轻松获取用户最近的操作日志。实时在线用户列表用户心跳时用ZADD更新其时间戳ZCOUNT最近60秒内有心跳的用户即为在线用户。4.3 Sorted Set统计的局限性虽然强大但也要注意其局限性内存成本Sorted Set为了维护排序内部使用了跳跃表skiplist和哈希表存储每个成员及其分数内存开销比简单的String或Bitmap要大。如果成员内容很长比如一个长的URL开销会更大。可以考虑对成员进行哈希如MD5后取前几位来缩短长度但要小心哈希冲突。精度与范围分数score是64位浮点数对于时间戳这种大整数完全够用。但进行范围统计ZCOUNT,ZRANGEBYSCORE时如果范围区间内的数据量非常大例如上千万虽然时间复杂度仍是O(log(N))但实际耗时和返回的数据量可能会带来压力。大Key删除使用ZREMRANGEBYSCORE清理旧数据时如果一次删除的量非常大比如上百万可能会引起Redis短暂的阻塞。建议在业务低峰期执行或者将大的删除拆分成多次小批量删除。个人心得用Sorted Set做滑动窗口统计时键的设计很重要。我通常按业务:对象:时间粒度来设计例如api_call:user_123:minute。如果统计维度很多要警惕Key的数量爆炸。对于超大规模的滑动窗口统计比如全站每秒的请求量可能需要结合其他方案如Redis Cell模块基于漏斗算法或直接使用监控系统如Prometheus。5. 第四重宝藏String的INCR家族与Hash —— 简单计数与多维统计的“基本功”不要小看最基础的String类型它的INCR、INCRBY、DECR、DECRBY命令是原子计数器是实时统计的基石。而Hash类型则适合存储一个对象的多个关联计数字段。5.1 原子计数器与INCR的妙用INCR命令将字符串值解析成整型将其加一最后将结果保存为新的字符串。这个操作是原子的即使在多客户端并发下也绝不会出现计数错误。经典场景文章阅读量/点赞数/收藏数INCR article:12345:view_count INCRBY article:12345:like_count 1 # 与INCR效果相同但可以指定步长全局ID生成器INCR global:user_id # 返回一个自增的ID限流器简单版结合EXPIRE命令可以实现固定时间窗口的限流。# 用户 user_123 在1分钟内最多调用10次API KEY “rate_limit:api_name:user_123:minute” current INCR KEY if current 1 then EXPIRE KEY 60 # 第一次调用时设置过期时间为60秒 end if current 10 then # 拒绝请求 end这个方案有个小问题如果用户在时间窗口的最后一秒和下一秒连续请求可能会在2秒内被允许20次请求两个窗口各10次。对于要求严格的限流需要使用更高级的算法如滑动窗口可用前面讲的Sorted Set实现或Redis Cell模块。5.2 Hash管理一组关联计数器当需要统计一个对象的多个指标时使用多个独立的String Key如article:12345:views,article:12345:likes虽然可以但在批量获取和内存管理上不够高效。Hash类型可以将这些字段组织在一起。# 初始化或更新文章统计哈希 HSET article:stats:12345 views 1524 likes 89 collections 32 # 阅读量1 HINCRBY article:stats:12345 views 1 # 批量获取所有统计 HGETALL article:stats:12345 # 获取特定字段 HMGET article:stats:12345 views likes使用Hash的好处内存优化Redis的Hash在字段数较少时使用一种称为ziplist的紧凑编码比使用多个独立的String Key更节省内存。操作原子性HINCRBY等命令也是原子的。批量操作HMGET和HGETALL可以一次性获取所有相关计数减少网络往返。注意事项当Hash内部的字段数量或值大小超过一定阈值时编码会从ziplist转换为hashtable内存占用会有所增加但访问效率依然很高。HGETALL在字段非常多时可能会返回一个很大的响应体阻塞客户端或Redis服务器如果值非常大。在这种情况下更推荐使用HSCAN进行迭代获取或者只获取需要的字段HMGET。5.3 组合运用实现带时间维度的复杂统计在实际项目中我们经常需要组合这些数据结构。例如要统计一篇文章每小时的阅读趋势并需要去重UV。总阅读数PV使用String的INCR。键如article_pv:12345。独立访客数UV使用HyperLogLog。键如article_uv:12345每次访问PFADD用户ID。每小时阅读趋势使用Hash。键如article_hourly_pv:12345字段为小时时间戳如2024050114代表5月1日14点值为该小时的阅读数使用HINCRBY来更新。# 假设当前时间是2024-05-01 14:30:00 hour_key “2024050114” HINCRBY article_hourly_pv:12345 hour_key 1实时排行榜今日阅读Top 10文章使用Sorted Set。键如article_rank:daily:20240501成员为文章ID分数为今日阅读数。每次文章PV增加时同步更新这个Sorted Set。ZINCRBY article_rank:daily:20240501 1 12345这样我们就用Redis的多种数据结构构建了一个轻量级但功能强大的实时文章统计系统。所有操作都是内存操作速度极快能够轻松应对高并发场景。6. 实战避坑与架构思考掌握了这“四重宝藏”后在实际生产环境中应用时还有一些更深层次的坑和架构考量需要警惕。6.1 数据持久化与一致性风险Redis的数据在内存中虽然有RDB和AOF持久化机制但在极端情况下如服务器宕机且AOF未及时刷盘最近一段时间的数据可能会丢失。对于“文章阅读数”这种允许少量丢失的统计这或许可以接受。但对于“订单支付成功计数”这种关键数据则绝对不行。解决方案重要数据双写对于不能丢失的计数在更新Redis的同时必须异步或同步地写入一个持久化存储如MySQL。可以采用“先写数据库再删缓存”或“消息队列异步更新”等模式。此时Redis的角色是高性能的实时缓存持久化由数据库保证。利用Redis的原子性做缓冲例如可以用Redis的INCR累积计数然后定时比如每分钟将当前计数值通过GETSET命令取出并重置为0再将这个值累加到数据库中。这样即使Redis崩溃最多丢失一分钟的数据。6.2 大Key与热Key问题大Key一个存储了上千万用户签到状态的Bitmap上百MB、一个成员数量巨大的Sorted Set都是大Key。它们会导致持久化bgsave时子进程内存占用高、主从同步延迟、网络传输阻塞甚至DEL删除时都会长时间阻塞服务。规避拆分大Key。例如按用户ID范围拆分Bitmapsign:202405:shard1存储ID 1-100万的用户按时间拆分Sorted Set每天一个Key。热Key一个计数器的Key如article_pv:超级热门文章ID在短时间内被超高并发访问会导致该Key所在Redis实例的CPU负载激增甚至打满单核。规避本地缓存在应用层使用Guava Cache或Caffeine短期缓存该计数定期去Redis同步。Key拆分将一个大热Key拆分成多个子Key然后在应用层做聚合。例如将article_pv:12345拆成article_pv:12345:shard1、article_pv:12345:shard2等每次访问随机选择一个子Key进行INCR统计时对所有子Key求和。使用Redis集群将热Key通过哈希标签{}强制分配到不同的slot从而分散到不同的集群节点上但这需要客户端支持并谨慎设计。6.3 统计数据的过期与归档Redis统计数据的生命周期管理至关重要。UV/HLL数据可能保留7天或30天每日的Bitmap签到数据在月底汇总后可能需要归档滑动窗口的Sorted Set需要不断清理旧数据。设置TTL在写入Key时就预估其生命周期并设置EXPIRE。这是最有效和自动化的方式。定期清理脚本对于没有固定TTL但需要按模式清理的Key如article_hourly_pv:*需要编写Lua脚本或外部程序使用SCAN命令扫描匹配模式的Key根据Key名中的时间信息判断是否过期并删除。数据归档对于需要长期保存但访问频率低的统计数据如一年前的每日UV可以定期将其从Redis中导出转存到成本更低的存储中如MySQL、HBase或对象存储并在Redis中删除原Key。6.4 监控与告警没有监控的系统是危险的。对于Redis统计需要重点关注内存使用率警惕因Bitmap或Sorted Set无序增长导致的内存打满。慢查询监控slowlog警惕大范围的BITOP、ZUNIONSTORE、ZINTERSTORE或对超大Key的HGETALL操作。命令统计通过INFO commandstats关注INCR、PFADD、SETBIT等关键命令的调用频率和耗时及时发现异常流量。Key数量监控Key总数的增长趋势防止因程序Bug导致Key无限创建。个人心得在设计任何基于Redis的统计方案前一定要问自己几个问题数据可以接受丢失吗数据量有多大增长有多快查询的频率和模式是怎样的需要多高的实时性回答清楚这些问题才能选择最合适的数据结构和架构避免后期重构的阵痛。记住Redis是利器但滥用也会伤到自己。