第 10 章
Bitmap、HyperLogLog 与 GEO:三种特殊数据类型
第十章:Bitmap、HyperLogLog 与 GEO——三种特殊数据类型
10.1 Bitmap:位图操作
10.1.1 底层存储原理
Bitmap 在 Redis 中并非独立的数据类型,而是建立在 String 类型的 SDS 之上的位操作接口。
Redis String 内存结构(存储 Bitmap 时):
SDS 结构:
┌──────────────┬───────┬───────────────────────────────────┐
│ sdshdr8 │ len │ buf[] │
│ alloc │ free │ [byte0][byte1][byte2]...[byteN] │
└──────────────┴───────┴───────────────────────────────────┘
每个字节存储8个bit,采用大端(Big-Endian)位序:
byte0: bit7 bit6 bit5 bit4 bit3 bit2 bit1 bit0
↑最高位=offset 0 ↑最低位=offset 7
SETBIT key 7 1 → byte0 的 bit0 设为1
SETBIT key 0 1 → byte0 的 bit7 设为1
SETBIT key 8 1 → byte1 的 bit7 设为1
字节偏移计算:
/* bitops.c — SETBIT/GETBIT 核心逻辑 */
void setBitCommand(client *c) {
robj *o;
char *err = "bit is not an integer or out of range";
uint64_t bitoffset;
ssize_t byte, bit;
int byteval, bitval;
if (getUnsignedLongLongFromObjectOrReply(c, c->argv[2], &bitoffset, err))
return;
byte = bitoffset >> 3; /* bitoffset / 8 → 字节索引 */
bit = 7 - (bitoffset & 0x7); /* 7 - (bitoffset % 8) → 字节内位偏移(大端) */
/* 如果字符串需要扩展,扩展到 byte+1 字节 */
o = lookupKeyWrite(c->db, c->argv[1]);
if (!o) {
o = createObject(OBJ_STRING, sdsnewlen(NULL, byte+1));
dbAdd(c->db, c->argv[1], o);
} else {
/* 扩展 SDS */
if (sdslen(o->ptr) <= (size_t)byte) {
o->ptr = sdsgrowzero(o->ptr, byte+1);
}
}
/* 读取当前字节,修改指定位,写回 */
byteval = ((uint8_t*)o->ptr)[byte];
bitval = (byteval >> bit) & 1; /* 读取原值 */
((uint8_t*)o->ptr)[byte] &= ~(1 << bit); /* 清零该位 */
((uint8_t*)o->ptr)[byte] |= (newbitval << bit); /* 设置新值 */
addReply(c, bitval ? shared.cone : shared.czero);
}
10.1.2 BITCOUNT:SWAR 算法统计 1 的个数
/* bitops.c — 统计1的个数,使用 SWAR(SIMD Within A Register)算法 */
/* 标准的 popcount 技巧 — 每次处理 8 字节 */
static long popcount(void *s, long count) {
long bits = 0;
unsigned char *p = s;
uint64_t *p8 = (uint64_t *)s;
/* 主循环:每次处理8字节(64位) */
while (count >= 8) {
uint64_t x = *p8;
/* SWAR 算法:并行计算64位中每个位的1的个数 */
x = x - ((x >> 1) & 0x5555555555555555ULL);
x = (x & 0x3333333333333333ULL) + ((x >> 2) & 0x3333333333333333ULL);
x = (x + (x >> 4)) & 0x0f0f0f0f0f0f0f0fULL;
/* 将每个字节的4bit计数累加 */
bits += (x * 0x0101010101010101ULL) >> 56;
p8++;
count -= 8;
}
/* 处理剩余字节(< 8字节)—— 使用查表法 */
p = (unsigned char *)p8;
while (count--) bits += _bitsinbyte[*p++];
return bits;
}
SWAR 算法解析(以 16bit 为例):
原始数:1011 0110 1101 0011 = 9个1
步骤1:相邻位相加(2个一组)
x = x - ((x >> 1) & 0x5555) = 0x5555 = 0101 0101 0101 0101
1011 0110 1101 0011
- 0101 0011 0110 0001 (x >> 1) & 0x5555
= 0110 0011 0111 0010 每2bit存储该组1的个数
步骤2:相邻2bit组相加(4个一组)
x & 0x3333 + (x>>2) & 0x3333
= 0010 0011 0011 0010 每4bit存储该组1的个数(0,2,3,2)
↑0 ↑2 ↑3 ↑2 = 7+2=9 ✓
步骤3:相邻4bit组相加(8个一组,即每字节)
(x + (x >> 4)) & 0x0f0f = 每字节存储1的个数
步骤4:乘以 0x0101...0101 后取最高字节 = 所有字节1的数量之和
Redis 7.0 还支持 BITCOUNT key start end BYTE|BIT 语法,其中 BIT 模式允许指定精确的位范围而非字节范围:
BITCOUNT key 0 7 → 统计第0-7字节中1的个数(整个key)
BITCOUNT key 0 7 BYTE → 同上(显式指定字节模式)
BITCOUNT key 0 63 BIT → 统计第0-63个bit中1的个数(前8字节)
10.1.3 BITOP:位运算操作
/* bitops.c — 对多个字符串做 AND/OR/XOR/NOT */
void bitopCommand(client *c) {
char *opname = c->argv[1]->ptr;
/* op:AND=0, OR=1, XOR=2, NOT=3 */
/* 找到最长字符串的长度 */
maxlen = 0;
for (j = 0; j < numkeys; j++) {
if (strlen(o[j]) > maxlen) maxlen = strlen(o[j]);
}
/* 分配结果缓冲区,O(maxlen) 时间复杂度 */
unsigned char *res = zmalloc(maxlen);
/* 按字节逐字节计算 */
for (j = 0; j < maxlen; j++) {
unsigned char output = (j < strlen(src[0])) ? src[0][j] : 0;
if (op == BITOP_NOT) output = ~output;
for (i = 1; i < numkeys; i++) {
unsigned char byte = (j < strlen(src[i])) ? src[i][j] : 0;
switch (op) {
case BITOP_AND: output &= byte; break;
case BITOP_OR: output |= byte; break;
case BITOP_XOR: output ^= byte; break;
}
}
res[j] = output;
}
/* 写入目标 key */
}
时间复杂度:O(N),N 为最长字符串字节数。注意:若两个字符串长度差异大,BITOP AND 会用0填充短的那个。
10.1.4 实际应用案例
案例一:用户签到系统
设计:
key 格式: "sign:{year}:{month}:{userid}"
offset = day - 1(1号对应0,31号对应30)
操作:
SETBIT sign:2024:01:10086 0 1 # 1月1日签到
SETBIT sign:2024:01:10086 14 1 # 1月15日签到
BITCOUNT sign:2024:01:10086 # 统计1月签到总天数
内存计算:
1个月:ceil(31/8) = 4字节 per user per month
1亿用户 × 12个月 × 4字节 = 4.8GB(可接受)
BITPOS 应用——查找最早未签到日:
BITPOS sign:2024:01:10086 0 # 找第一个0位(未签到的天)
→ 返回位偏移,即第几天(0-indexed)
案例二:用户在线状态
key: "online:{date}"
offset: user_id(假设 user_id 为整数)
SETBIT online:2024-01-15 12345678 1 # 用户12345678上线
GETBIT online:2024-01-15 12345678 # 查询是否在线
BITCOUNT online:2024-01-15 # 当日在线用户数
内存计算(1亿用户):
最大 user_id = 100000000
ceil(100000000 / 8) = 12.5MB per day
远比 Hash 或 Set 存储用户ID高效(Set:每个ID 8字节 = 800MB)
案例三:手动实现布隆过滤器
布隆过滤器原理:
k 个哈希函数,每个函数将元素映射到 m 个 bit 位之一
Redis 实现:
key: "bloom:filter"
插入元素 x:
h1 = hash1(x) % m → SETBIT bloom:filter h1 1
h2 = hash2(x) % m → SETBIT bloom:filter h2 1
h3 = hash3(x) % m → SETBIT bloom:filter h3 1
查询元素 x:
h1, h2, h3 三个位置均为1 → 可能存在(有误判率)
任一位置为0 → 一定不存在
推荐:直接使用 RedisBloom 模块(BLOOM.ADD/BLOOM.EXISTS)
提供比手动实现更优的误判率和内存效率
10.2 HyperLogLog:基数估算
10.2.1 基数估算问题
问题定义:给定一个数据流,统计其中不重复元素的数量(基数)。
精确方法 vs 近似方法:
精确方法(使用 Set):
SADD uv:page user1 user2 user3 ...
SCARD uv:page → 精确基数
内存:每个元素 ~60字节 → 1亿 UV ≈ 6GB
HyperLogLog(误差 0.81%):
PFADD uv:page user1 user2 user3 ...
PFCOUNT uv:page → 近似基数
内存:最大 12KB(固定)→ 节省 99.99%+ 的内存
10.2.2 LogLog 算法原理
基础 LogLog 直觉:
均匀随机哈希函数 h:element → [0, 2^64)
考察哈希值的前导零个数(leading zeros):
P(前导零 >= k) = 1/2^k
如果我们见过最长的前导零序列为 k:
→ 估算:大约遇到了 2^k 个不同元素
例子:
hash(user1) = 0b0001xxxx... → 3个前导零
hash(user2) = 0b01xxxxxx... → 1个前导零
hash(user3) = 0b00001xxx... → 4个前导零
max_leading_zeros = 4
估算基数 ≈ 2^4 = 16(实际只有3个,误差大!)
问题:单个估算器方差太大。
HyperLogLog 改进:
将元素分成 m = 16384 个桶
用哈希值的前 14bit 决定桶编号
用剩余 bit 计算前导零
对所有桶的估算值取调和平均(Harmonic Mean)
标准误差 = 1.04 / sqrt(m) = 1.04 / sqrt(16384) ≈ 0.81%
10.2.3 Redis HyperLogLog 实现
/* hyperloglog.c — HyperLogLog 的核心数据结构 */
/* 稠密表示:固定 6bit/桶,16384桶 */
#define HLL_P 14 /* 桶数指数:2^14 = 16384 桶 */
#define HLL_REGISTERS (1<<HLL_P) /* = 16384 */
#define HLL_BITS 6 /* 每个寄存器 6bit(最大值63,足够存储 2^63 的前导零) */
#define HLL_DENSE_SIZE \
(HLL_REGISTERS * HLL_BITS / 8) /* = 16384 * 6 / 8 = 12288 字节 = 12KB */
/* HyperLogLog 对象头部 */
struct hllhdr {
char magic[4]; /* "HYLL" */
uint8_t encoding; /* HLL_DENSE=0 or HLL_SPARSE=1 */
uint8_t notused[3];
uint8_t card[8]; /* 缓存的基数估算(最后一次 PFCOUNT 的结果)*/
uint8_t registers[]; /* 实际的寄存器数组(稠密或稀疏) */
};
稀疏表示(Sparse Representation):
当大多数桶的值为0时(初始状态或很少数据),用变长编码节省内存:
编码字节格式:
00xxxxxx — ZERO:连续 xxxxxx+1 个桶(值为0),最多64个连续零桶
01xxxxxx — XZERO:连续 xxxxxx*256+下一字节 个桶(值为0),最多16384个
1vvvvvxx — VAL:接下来 xx+1 个桶的值均为 vvvvv
示例(16384个桶,只有3个非零):
XZERO 1000 → 桶0-999全为0
VAL 5 1 → 桶1000 = 5
XZERO 5000 → 桶1001-5999全为0
VAL 3 1 → 桶6000 = 3
...
总大小:远小于 12KB
升级到稠密表示的条件:
稀疏表示字节数 > server.hll_sparse_max_bytes (默认3000)
PFADD 操作:
int hllAdd(robj *o, unsigned char *ele, size_t elesize) {
struct hllhdr *hdr = o->ptr;
switch (hdr->encoding) {
case HLL_DENSE: return hllDenseAdd(hdr->registers, ele, elesize);
case HLL_SPARSE: return hllSparseAdd(o, ele, elesize);
}
}
/* 稠密模式:更新寄存器 */
int hllDenseAdd(uint8_t *registers, unsigned char *ele, size_t elesize) {
long index;
uint8_t count;
/* 计算元素哈希值,取前 HLL_P=14bit 作为桶索引 */
hllPatLen(ele, elesize, &index, &count);
/* count = 哈希值剩余部分的前导零个数 + 1 */
/* 如果 count > 当前寄存器值,则更新 */
HLL_DENSE_GET_REGISTER(old, registers, index); /* 读取6bit寄存器 */
if (count > old) {
HLL_DENSE_SET_REGISTER(registers, index, count); /* 写入6bit */
return 1; /* 修改了寄存器,通知调用方缓存失效 */
}
return 0; /* 无变化 */
}
/* 6bit 寄存器的读取(跨字节访问)*/
#define HLL_DENSE_GET_REGISTER(target, p, regnum) do { \
uint8_t *_p = (uint8_t*) p; \
unsigned long _byte = regnum*HLL_BITS/8; \
unsigned long _fb = regnum*HLL_BITS&7; \
unsigned long _fb8 = 8 - _fb; \
unsigned long b0 = _p[_byte]; \
unsigned long b1 = _p[_byte+1]; \
target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \
} while(0)
PFCOUNT 操作(计算调和平均):
uint64_t hllCount(struct hllhdr *hdr, int *invalid) {
double m = HLL_REGISTERS;
double E;
int ez = 0; /* 值为0的寄存器数量 */
double sum = 0;
/* 调和平均:sum = Σ(2^(-M[j])) for j = 0 to m-1 */
for (j = 0; j < HLL_REGISTERS; j++) {
HLL_DENSE_GET_REGISTER(val, hdr->registers, j);
if (val == 0) {
ez++;
sum += 1; /* 2^(-0) = 1 */
} else {
sum += 1.0 / (1LL << val); /* 2^(-val) */
}
}
/* 基本估算:E = alpha_m * m^2 / sum */
E = (0.7213/(1+1.079/m)) * m * m / sum;
/* 小范围修正(当估算值 < 2.5m 时,使用 LinearCounting) */
if (E < m * 2.5 && ez != 0) {
E = m * log(m / ez);
}
/* 大范围修正(接近 2^32 时修正哈希碰撞) */
/* ... */
return (uint64_t)E;
}
10.2.4 PFMERGE:合并多个 HyperLogLog
PFMERGE uv:week uv:monday uv:tuesday uv:wednesday
操作原理:
对每个桶 j,取所有源 HLL 中该桶值的最大值
max_register[j] = max(hll1[j], hll2[j], hll3[j])
这是 HyperLogLog 的数学特性:并集的基数估算
等价于将所有元素加入同一个 HLL 的结果
时间复杂度:O(m × k),m=16384桶,k=源HLL数量
10.2.5 适用与不适用场景
适用:
- 网站 UV 统计(每天独立访客数)
- 唯一 IP 计数
- 搜索词去重计数
- 误差 ≤ 1% 可接受的所有基数问题
不适用:
- 需要精确计数 → 用 Set(SCARD)
- 需要查询某元素是否存在 → 用 Set(SISMEMBER)或 Bloom Filter
- 需要遍历/导出元素列表 → 用 Set
- 基数很小(< 100)→ HLL 精度反而差,用 Set 更好
HyperLogLog 的限制:
- 只支持 PFADD(不支持删除元素!)
- 误差是统计性的,偶尔会有 > 1% 的误差(尾部概率)
- PFCOUNT 合并多个 key 时有额外内存开销(临时合并)
10.3 GEO:地理位置数据类型
10.3.1 GeoHash 编码原理
Redis GEO 使用 GeoHash 将二维坐标(经纬度)编码为一维整数,存入 ZSet。
GeoHash 编码过程(52bit 精度):
1. 经度范围:[-180, 180]
纬度范围:[-90, 90]
2. 二分编码(以经度 116.397128 为例):
第1次:[-180, 0] | [0, 180] → 116.397 > 0 → bit=1,范围缩小为[0, 180]
第2次:[0, 90] | [90, 180] → 116.397 > 90 → bit=1,范围缩小为[90, 180]
第3次:[90, 135] | [135, 180] → 116.397 < 135 → bit=0,范围缩小为[90, 135]
...(共26次)
3. 纬度同理:26次二分 → 26bit
4. 交织编码(Interleave):
lon_bits = b25 b24 b23 ... b1 b0 (26bit)
lat_bits = b25 b24 b23 ... b1 b0 (26bit)
geohash = lon25 lat25 lon24 lat24 ... lon0 lat0 (52bit)
5. 存入 ZSet:
member = "beijing"
score = geohash_52bit(作为 double 存储,double 有 53bit 有效精度,够用)
精度分析:
bit 精度 vs 地理精度:
52bit:精度约 0.6mm(每格约 0.6mm × 0.6mm)
实际 Redis 精度(受 double 精度限制):
经度精度:约 0.0000001 度 ≈ 11mm
纬度精度:约 0.0000001 度 ≈ 11mm
实用精度:约厘米级
10.3.2 GEO 命令
GEOADD:
/* geo.c — 将经纬度编码为 GeoHash,存入 ZSet */
void geoaddCommand(client *c) {
/* 参数:GEOADD key [NX|XX] [CH] lon lat member [lon lat member ...] */
int i;
for (i = 3; i < c->argc; i += 3) {
double xy[2];
xy[0] = atof(c->argv[i]->ptr); /* longitude */
xy[1] = atof(c->argv[i+1]->ptr); /* latitude */
/* 编码为 52bit GeoHash */
GeoHashBits hash;
geohashEncodeWGS84(xy[1], xy[0], GEO_STEP_MAX, &hash);
GeoHashFix52Bits bits = geohashAlign52Bits(hash);
/* 以 double 形式存入 ZSet */
score = (double)bits;
zsetAdd(zobj, score, member_sds, ...);
}
}
GEODIST:
GEODIST locations beijing shanghai km
计算过程:
1. 从 ZSet 中取出两个 member 的 score(GeoHash 52bit)
2. 解码 GeoHash → 经纬度
3. 用 Haversine 公式计算球面距离
Haversine 公式:
a = sin²(Δlat/2) + cos(lat1) × cos(lat2) × sin²(Δlon/2)
c = 2 × atan2(√a, √(1-a))
d = R × c (R = 6372797.560856 米,地球平均半径)
GEOSEARCH(Redis 6.2 新增,替代 GEORADIUS):
GEOSEARCH key FROMMEMBER member | FROMLONLAT lon lat
BYRADIUS radius m|km|ft|mi | BYBOX width height m|km|ft|mi
[ASC|DESC]
[COUNT count [ANY]]
[WITHCOORD] [WITHDIST] [WITHHASH]
示例:
GEOSEARCH locations FROMLONLAT 116.397 39.916 BYRADIUS 10 km ASC COUNT 10
→ 返回距离 (116.397, 39.916) 10km 内最近的10个地点
替代了已废弃的:
GEORADIUS key longitude latitude radius unit
GEORADIUSBYMEMBER key member radius unit
10.3.3 圆形范围查询原理
GEO 范围查询的核心在于 GeoHash 的空间局部性:相邻的 GeoHash 值对应地理上相近的位置。
圆形范围查询算法(GEORADIUS/GEOSEARCH):
步骤1:找到圆心对应的 GeoHash 格
步骤2:计算需要覆盖目标圆的最少 GeoHash 格集合
GeoHash 格是矩形,需要多个矩形覆盖圆形
示意(圆形被矩形 GeoHash 格覆盖):
┌───┬───┬───┐
│ │ │ │
├───┼───┼───┤
│ │ ◎ │ │ ← 中心格 + 8个相邻格 = 9个格(最少情况)
├───┼───┼───┤
│ │ │ │
└───┴───┴───┘
步骤3:将每个 GeoHash 格转换为 ZSet score 范围
[格的最小score, 格的最大score]
步骤4:对 ZSet 执行多次 ZRANGEBYSCORE,取并集
步骤5:过滤结果:计算精确距离,去掉超出半径的点(GeoHash 格是矩形,会有误判)
/* geo.c — 找到覆盖目标区域的 GeoHash 格 */
int geoGetPointsInRange(robj *zobj, double lon, double lat,
double radius, double shape_type, ...) {
GeoHashRadius georadius;
if (shape_type == CIRCULAR_TYPE) {
georadius = geohashGetAreasByRadius(lat, lon, radius);
} else { /* RECTANGLE_TYPE */
georadius = geohashGetAreasByBoxWGS84(lat, lon, height/2, width/2);
}
/* georadius 包含9个 GeoHash 格的范围 */
GeoHashRange ranges[9];
int count = geohashCalculateAreasByShape(georadius, ranges);
/* 对每个 GeoHash 格范围查 ZSet */
for (i = 0; i < count; i++) {
/* ZRANGEBYSCORE 获取该格内所有 member */
/* 对每个 member 计算精确距离,过滤 */
}
}
10.3.4 实际案例
案例一:附近的人
GEOADD users 116.397 39.916 "user:10086"
GEOADD users 116.410 39.920 "user:20086"
GEOADD users 121.473 31.230 "user:30086" # 上海用户
# 查找距离用户10086 1km内的其他用户
GEOSEARCH users FROMMEMBER "user:10086" BYRADIUS 1 km ASC WITHCOORD WITHDIST
# 返回示例:
# 1) 1) "user:20086"
# 2) "0.8532" (km)
# 3) 1) "116.41"
# 2) "39.92"
案例二:外卖配送范围
# 商家坐标
GEOADD restaurants 116.400 39.915 "kfc:001"
GEOADD restaurants 116.405 39.918 "mcdonalds:001"
# 用户下单,查找3km内的商家,按距离排序,最多返回20家
GEOSEARCH restaurants FROMLONLAT 116.397 39.916 BYRADIUS 3 km ASC COUNT 20 WITHDIST
案例三:门店距离排序
# 批量添加门店坐标
GEOADD stores
116.397 39.916 "store:beijing-01"
121.473 31.230 "store:shanghai-01"
...
# 计算用户到特定门店的距离
GEODIST stores store:beijing-01 store:shanghai-01 km
→ "1068.48" (北京到上海直线距离约1068km)
10.3.5 GEO 的局限性
1. 只支持圆形或矩形范围(GEOSEARCH BYBOX)
不支持多边形范围(如行政区划边界)
→ 需要多边形支持时,考虑 Tair GIS(阿里云 Redis 扩展)
2. 查询精度限制
GeoHash 编码后精度约 0.6mm,但实际受 double 浮点精度影响约11mm
足够日常应用,不足以用于精密测量
3. 不支持高度/海拔
GEO 只处理二维坐标
需要三维时,需自定义编码方案
4. 大范围查询性能
GEOSEARCH BYRADIUS 100 km 可能扫描很多 ZSet 成员
建议:大城市场景,radius 控制在 50km 以内以保证性能
10.4 三种类型的内存对比
场景:统计 1 亿用户中有多少人访问过某页面
方案一:Set(精确)
SADD uv:page userID1 userID2 ...
内存:1亿 × 平均60字节 ≈ 6GB
方案二:Bitmap(精确,需要用户ID是整数且范围有限)
SETBIT uv:page userID 1
内存:maxUserID/8 ≈ 100000000/8 = 12.5MB
方案三:HyperLogLog(近似,0.81% 误差)
PFADD uv:page userID1 userID2 ...
内存:最大 12KB(与数量无关!)
场景:记录 1 亿个地理位置坐标
GEO(ZSet 底层):
每个成员:SDS成员名 + ZSet节点开销 ≈ 50-100字节
1亿个:5-10GB
Bitmap 地理位置编码:
不适用(二维坐标无法直接用Bitmap表达)
结论:
精确计数,小基数 → Set
精确计数,整数ID → Bitmap(内存最省)
近似计数 → HyperLogLog(极省内存,固定12KB)
地理位置 → GEO(ZSet + GeoHash)
10.5 小结
| 类型 | 底层结构 | 典型命令 | 内存效率 | 误差 |
|---|---|---|---|---|
| Bitmap | String(SDS) | SETBIT/GETBIT/BITCOUNT | 1bit/用户 | 无(精确) |
| HyperLogLog | String + 12KB结构 | PFADD/PFCOUNT/PFMERGE | 12KB固定(任意数量) | 0.81% |
| GEO | ZSet(跳表+字典) | GEOADD/GEOSEARCH/GEODIST | ~60-100字节/点 | 坐标精度约11mm |
三种类型体现了 Redis "数据结构+算法"的设计哲学:在特定约束下(允许误差、特定数据分布),用更少的内存解决更大规模的问题。HyperLogLog 尤为典型——牺牲不到 1% 的精度,换来内存使用量从 GB 级降至 12KB,这种工程上的取舍是大数据场景的精彩设计。