Skip to content

Redis 高级数据类型:Bitmap、HyperLogLog 与 GEO 的原理与业务场景

为什么需要高级数据类型

Redis 的五种基本类型(String、Hash、List、Set、ZSet)能解决大部分缓存场景,但遇到"亿级用户签到统计""UV 去重""附近的人"这类需求时,直接用基本类型要么内存爆炸,要么性能不够。BitMap、HyperLogLog、GEO 就是 Redis 针对这三个典型场景给出的专用解法。面试也常把它们串成一套题:"Redis 有哪些数据结构?UV 怎么统计?附近的人怎么实现?"

Bitmap:位图并非独立类型,而是 String 的位操作

本质

Bitmap 不是 Redis 的独立数据类型,它底层就是 String(即 SDS),只不过 Redis 在 String 上暴露了一套位级操作接口。一个 String 最长 512MB,所以 Bitmap 最多能存 2^32 个位(512MB × 8 位)。

核心命令

命令作用复杂度
SETBIT key offset value将第 offset 位设为 0/1O(1)
GETBIT key offset获取第 offset 位的值O(1)
BITCOUNT key [start end]统计 1 的个数O(n)
BITOP op dest key [key ...]位运算(AND/OR/XOR/NOT)O(n)
BITPOS key bit [start end]查找第一个 0 或 1 的位置O(n)

业务场景

用户签到

java
// 签到:用户 1001 在 2026-09-09 签到
// 用当天的第几天作为 offset,比如 9 月 9 日 = 一年中第 252 天
jedis.setbit("sign:2026:1001", 251, 1);  // offset 从 0 开始

// 查询今天是否签到
boolean signed = jedis.getbit("sign:2026:1001", 251);

// 统计本月签到天数
// 假设 9 月,offset 范围 243-272(9 月 1 日 = 第 243 天)
long count = jedis.bitcount("sign:2026:1001", 243, 272);
// 注意:BITCOUNT 的 start/end 是字节范围,不是位范围,需要换算
// 243 位 ÷ 8 = 第 30 字节起,272 位 ÷ 8 = 第 34 字节止

字节偏移这个坑容易踩——BITCOUNTstart/end 是字节索引(byte offset),不是位索引。要统计 243~272 位,需要算 243/8=30.375 取整 30,272/8=34,结果是粗略近似。精确统计需要自己切片或配合 BITFIELD

亿级用户在线状态

一个用户占 1 位,1 亿用户 ≈ 12MB(1 亿 bit ÷ 8 ÷ 1024 ÷ 1024 ≈ 11.9MB)。内存成本极低,单机就能存下全量用户状态。

java
// 用户上线
jedis.setbit("online:20260909", userId, 1);
// 用户下线
jedis.setbit("online:20260909", userId, 0);
// 统计当天在线人数
long onlineCount = jedis.bitcount("online:20260909");
// 连续 7 天都在线的用户
jedis.bitop(BitOP.AND, "online:7days", "online:20260903", "online:20260904",
    "online:20260905", "online:20260906", "online:20260907", "online:20260908", "online:20260909");

什么时候别用 Bitmap:用户 ID 稀疏的场景。如果你的用户 ID 是 UUID 或自增步长不连续,用 Bitmap 会浪费大量空间。比如只有 100 个活跃用户,但 ID 分布到 1 亿号段,那 1 亿位数组里只有 100 个 1,内存浪费 99.9999%。这时应该用 Set 或 HyperLogLog。

HyperLogLog:12KB 搞定亿级 UV

原理一句话

HyperLogLog 是概率性数据结构,用调和平均 + 分桶估算基数。核心思想:一个好的哈希函数输出分布均匀,那么"哈希值二进制表示中前缀 0 的个数"服从伯努利分布。统计每个桶里最大前缀 0 长度,再取调和平均,就能以 0.81% 的标准误差估算出总基数。

空间固定

无论你往里面塞了 1 个元素还是 10 亿个元素,HyperLogLog 始终占用 12KB(16384 个桶 × 6 位)。这是它最迷人的特性——内存固定。

核心命令

bash
PFADD uv:20260909 user:1001 user:1002 user:1003
PFCOUNT uv:20260909           # 返回近似基数
PFMERGE uv:total uv:20260908 uv:20260909  # 合并多天的 UV

Java 使用

java
// 记录一次 UV
jedis.pfadd("uv:page:home", "user_1001", "user_1002");

// 获取 UV 统计
long uv = jedis.pfcount("uv:page:home");

// 合并多日 UV(去重)
jedis.pfmerge("uv:total", "uv:day1", "uv:day2", "uv:day3");

选型对比

方案内存精确度查询复杂度适合场景
Set高(1 亿个 ID ≈ 800MB)精确O(1)小规模精确去重
Bitmap低(1 亿位 ≈ 12MB)精确O(n)ID 连续的全量位图
HyperLogLog极低(固定 12KB)近似(0.81% 误差)O(1)亿级 UV,接受误差

什么时候别用 HyperLogLog

  • 需要精确去重(比如实际用户数不能差 0.81%)→ 用 Set 或 Bitmap
  • 做标签、交集、并集需要精确结果 → HyperLogLog 的 PFMERGE 只是近似合并
  • 需要判断"某个元素是否存在" → HyperLogLog 不支持元素查询,用布隆过滤器

GEO:附近的人功能的数据基础

原理

GEO 底层就是一个 ZSet(有序集合)。Redis 将经纬度通过GeoHash 算法编码成一个 52 位的整数,作为 ZSet 的 score。

GeoHash 做法:把地球平面二分,经度范围 [-180, 180] 二分得到 0/1,纬度范围 [-90, 90] 二分得到 0/1,交替编码——经度取一位、纬度取一位,依次类推。编码越长,位置越精确(约 5 位 = 4.9km 精度,12 位 ≈ 几厘米)。最后把这个二进制串 Base32 编码成字符串,就是 GeoHash 字符串。

关键问题:GeoHash 的边界上,两个很近的点可能落在不同网格,编码前缀完全不同。所以 Redis 在做 GEORADIUS 时,查询目标网格的同时会查周围 8 个邻域网格,合并结果再按距离排序。

核心命令

bash
# 添加门店位置
GEOADD shops:beijing 116.397128 39.916527 "shop:1001"
GEOADD shops:beijing 116.407128 39.926527 "shop:1002"

# 查询附近门店(返回 5km 内的门店)
GEORADIUS shops:beijing 116.400000 39.920000 5 km WITHDIST WITHCOORD ASC

# 计算两个门店距离
GEODIST shops:beijing "shop:1001" "shop:1002" km

# 获取门店经纬度
GEOPOS shops:beijing "shop:1001"

Java 实现附近的人

java
// 添加用户位置
Map<String, Object> memberMap = new HashMap<>();
memberMap.put("user:1001", new Point(116.397128, 39.916527));
jedis.geoadd("users:nearby", memberMap);

// 查询附近 3km 的人
GeoRadiusParam param = GeoRadiusParam.geoRadiusParam()
    .withDist()      // 返回距离
    .withCoord()     // 返回坐标
    .sortAscending() // 按距离升序
    .count(50);      // 最多返回 50 个

List<GeoRadiusResponse> results = jedis.georadius(
    "users:nearby",           // key
    116.400000, 39.920000,    // 中心点
    3, GeoUnit.KM,            // 半径 3km
    param
);

for (GeoRadiusResponse r : results) {
    System.out.println(r.getMemberByString() + " - " + r.getDistance() + "km");
}

GEO 的坑

  • GEOADD 内部是 ZSet,所以 ZSet 的通用命令也可以操作(ZREM 删除、ZRANGE 遍历),但别乱用,Redis 6.2 以后有 GEOADD 专门支持多元素批量添加。
  • Redis 6.2 之前一次只能加一个成员,6.2 之后支持 GEOADD key [NX|XX|CH] longitude latitude member [longitude latitude member ...]
  • GEORADIUS 在 6.2 之后标记为废弃,改用 GEORADIUS_RO(只读)和 GEOSEARCH。哨兵/集群模式下,只读命令可以走副本,降低主库压力。
  • 精度问题:极地附近(纬度靠近 90°)GeoHash 编码会失真,但国内业务几乎不受影响。

什么时候别用 GEO:需要存储完整的地理围栏形状(多边形、路径)时,GEO 只支持点级别,不支持多边形判断。这时可以用 Redis 的 GEOADD 配合 GEORADIUSBYMEMBER 做近似,但严格来说需要引入 PostGIS 或 Elasticsearch 的地理功能。

总结

类型解决什么问题本质空间注意
Bitmap签到、在线状态、布尔统计String 的位操作1 亿位 ≈ 12MB稀疏 ID 别用,BITCOUNT 字节偏移
HyperLogLog亿级 UV 去重概率性基数估算固定 12KB,无关数据量0.81% 误差,不支持元素查询
GEO附近的人、门店ZSet + GeoHash 编码与 ZSet 相同边界跳变,6.2 后 GEORADIUS_RO

面试时不要只背命令,能结合场景讲清楚"为什么选它不选别的"才是区分度所在。

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1