从 Hash 到 HyperLogLog:Redis 海量 UV 统计的 3 种高阶玩法

38 阅读4分钟

真实业务场景

假设我们正在开发一个电商大促活动页,产品经理提了一个“简单”的需求:我们需要实时显示“当前正在浏览商品的用户数”。这个数字每秒可能变化数万次。在 10 万级并发下,传统关系型数据库会面临什么问题?

经典错误场景:

// Java 初学者的常见做法
public synchronized void addCount() {
    int count = getFromMySQL();
    updateMySQL(count + 1);
}

上述代码在高并发下会导致:

  1. 数据库连接池过载(超时错误)
  2. 行锁竞争(死锁风险)
  3. CPU 飙升至 100%(性能崩溃)

这正是我们需要设计 Redis 统计方案的核心原因——在这种场景下,关系型数据库的 ACID 特性反而成了负担。

Redis 方案的演进

阶段 1:Hash 方案——精确统计的起点

原理: 使用哈希表记录每个独立访客,适用于对精度要求高、中小型规模的场景。

// 将用户访问记录添加到 Hash
Jedis jedis = new Jedis("localhost"6379);
String pageKey"page_visit_count:examplePage"; // 页面访问计数 Key
String userId"user_123"; // 已登录用户
String visitorId"visitor_abc123"; // 未登录用户

// 使用 HSET 命令添加数据,值简单设为 "1"
jedis.hset(pageKey, userId, "1");
jedis.hset(pageKey, visitorId, "1");

// 统计访问量,直接使用 HLEN 命令获取字段数量
long visitCount = jedis.hlen(pageKey);
System.out.println("The page visit count is: " + visitCount);

这种方法的优点是简单直接——实现容易,查询方便,且精度极高。但缺点也显而易见。

随着访问页面的增加,Key 像滚雪球一样膨胀,链表变长,内存占用迅速飙升,性能逐渐下降。

因此,这种方法更适合流量相对较低的页面,作为初期的尝试。

阶段 2:Bitmap 方案——空间与性能的平衡

当用户基数变大时,使用 Hash 在空间上就显得有些浪费了。

这正是 Bitmap 大显身手的时候,它是空间效率的大师。它巧妙地利用了 32 位 int 类型——不再存储一个完整的用户 ID,而是拆解 32 位,每一位代表一个用户,直接节省了 32 倍的空间。

对于已登录用户,其 ID 直接映射到 Bitmap 中的特定位。对于未登录用户,通过哈希算法将随机字符串标识符转换为哈希值**,再映射到特定位。例如,用户 ID 为 5,对应 Bitmap 第 5 位;随机字符串哈希值为 10,对应第 10 位。

// 使用 Bitmap 统计页面访问量
Jedis jedis = new Jedis("localhost"6379);
String pageKey"bitmap_visit_count:examplePage"; // Bitmap 访问计数 Key
String loginUserId"5"; // 已登录用户 ID
String nonLoginUserKey"random_str_123"; // 未登录用户的随机 Key

// 对于已登录用户,直接设置对应位
jedis.setbit(pageKey, Long.parseLong(loginUserId), true);

// 对于未登录用户,先对 Key 进行哈希,再设置位
String hashValue = DigestUtils.md5DigestAsHex(nonLoginUserKey.getBytes());
long hashBitIndex = Long.parseLong(hashValue.substring(016), 16) % Long.MAX_VALUE;
jedis.setbit(pageKey, hashBitIndex, true);

// 使用 BITCOUNT 命令统计访问量
long visitCount = jedis.bitcount(pageKey);
System.out.println("The page visit count is: " + visitCount);

这种方法的优势在于极小的内存占用和便捷的查询,甚至可以检查特定用户是否访问过页面。

但也存在明显的缺点。多个用户可能会碰撞到同一位上,导致统计略有偏差。此外,如果用户分布稀疏——例如用户 ID 是 1 亿——则需要分配 1 亿个位,可能会造成内存浪费。

因此,这种方法更适合用户分布相对密集的场景。

阶段 3:HyperLogLog 方案——超大数据量的“量子”统计

突破性思维: 通常,系统不需要绝对精确的数据,可以用可控的误差换取内存和性能的巨大提升。

它不再追求记录每个用户的确切计数,而是使用概率算法估算近似访问量,误差控制在 0.81% 以内,这在实际应用中通常已经足够。

// 使用 HyperLogLog 统计页面访问量
Jedis jedis = new Jedis("localhost"6379);
String pageKey"hyperloglog_visit_count:examplePage"; // HyperLogLog 访问计数 Key
String userId"user_456"; // 用户标识,可以是 ID 或其他唯一标识

// 使用 PFADD 命令添加用户访问记录
jedis.pfadd(pageKey, userId);

// 使用 PFCOUNT 命令统计访问量
long approxVisitCount = jedis.pfcount(pageKey);
System.out.println("The approximate page visit count is: " + approxVisitCount);

总结

这三种方案各有所长。

Hash 就像一本细致的账本,适用于小规模、高精度的统计。Bitmap 是空间压缩的大师,适合用户分布密集的才中等规模场景。HyperLogLog 则是概率统计专家,专为超高并发、高流量的网站设计。

在实际项目中,我们需要根据业务场景灵活选择,从而在并发战场中掌控全局,精准高效地统计用户访问量。