主题
第 3 章 三大特殊数据类型
学习目标:搞懂 Bitmap / HyperLogLog / Geo 这三个「省内存的神器」是怎么用、怎么实现的。看完之后能解释「为什么 12 KB 的 HLL 能估算 2^64 个元素」「Geo 是怎么用一个 ZSet 实现『附近的人』的」。
3.0 概览:为什么叫「特殊」类型?
「五大基础类型」(String/List/Hash/Set/ZSet)是用户直接感知到的类型;而本章的三大特殊类型,底层其实复用了 String 或 ZSet,只是 Redis 提供了一组特殊的命令,让你能用极少的内存解决特定问题。
┌──────────────┬────────────────┬─────────────────────────────────┐
│ 特殊类型 │ 底层实际是 │ 解决什么问题 │
├──────────────┼────────────────┼─────────────────────────────────┤
│ Bitmap │ String │ 亿级布尔状态(签到/在线/活跃) │
│ HyperLogLog │ String (12KB) │ 超大基数估算(UV/独立访客) │
│ Geo │ ZSet │ 地理位置(附近的人/POI 搜索) │
└──────────────┴────────────────┴─────────────────────────────────┘3.1 Bitmap:用 1 bit 记一个状态
3.1.1 是什么 + 生活类比
Bitmap 不是一种新类型,而是把 String 当成一串二进制位来操作。每个位(bit)只能是 0 或 1。
📒 生活类比:班级签到表。每个同学有一个固定座位号(0、1、2、...),签到了就在他的格子里画个勾(=1),没签到就空着(=0)。
学号: 0 1 2 3 4 5 6 7 8 9 ...
状态: 0 1 1 0 1 0 0 1 0 1 ...
↑
2 号同学今天签到了
只用 N 个 bit 就能记 N 个同学的签到状态,
1 KB 内存能记 8192 个同学,1 MB 能记 800 多万。3.1.2 核心命令
bash
# SETBIT key offset 0/1
SETBIT online:20260417 100 1 # 将 100 号位设为 1
SETBIT online:20260417 200 1
SETBIT online:20260417 300 1
GETBIT online:20260417 100 # → 1
GETBIT online:20260417 999 # → 0(未设置默认是 0)
BITCOUNT online:20260417 # 统计 1 的总数 → 3
BITCOUNT online:20260417 0 0 # 统计第 0 个字节内的 1 → 0
BITCOUNT online:20260417 0 -1 # 全范围(默认)
# 找第一个 0 / 1 的位置
BITPOS online:20260417 1 # → 100
BITPOS online:20260417 0 # → 0
# 位运算(多个 Bitmap 之间)
SETBIT day1 100 1
SETBIT day2 100 1
SETBIT day1 200 1
BITOP AND result day1 day2 # 两天都在线的人 → result 中 100=1, 200=0
BITOP OR result day1 day2 # 任意一天在线
BITOP XOR result day1 day2 # 状态不一样的位
BITOP NOT result day1 # 取反
# 6.0+ 批量操作
BITFIELD mybit SET u8 #0 255 # 把第 0 个无符号 8 位整数设为 255
BITFIELD mybit GET u8 #0 # 读出来 → 255
BITFIELD mybit INCRBY u8 #0 10 # 第 0 个 u8 加 10(自动模 256)3.1.3 内存有多省?
假设统计某 App 1 亿用户的「今天是否登录」:
方案 A: Set
每个 user_id 假设 8 字节(long)+ Set 元素开销 ≈ 80 字节
1 亿用户 × 80 字节 = 8 GB ❌
方案 B: Hash(user_id → "1")
每个键值对约 50~80 字节
1 亿用户 × 60 字节 = 6 GB ❌
方案 C: Bitmap
每个用户 1 bit
1 亿 bit = 12.5 MB ✅
→ 内存节省 500 倍!3.1.4 经典场景:用户连续签到统计
bash
# 用户 1001 的签到记录(一个月一个 Key)
SETBIT sign:1001:2026-04 0 1 # 4 月 1 号签到
SETBIT sign:1001:2026-04 1 1 # 4 月 2 号签到
SETBIT sign:1001:2026-04 2 1 # 4 月 3 号签到
SETBIT sign:1001:2026-04 4 1 # 4 月 5 号签到(4 号没签)
BITCOUNT sign:1001:2026-04 # 本月共签 → 4 天
# 4 月 1~3 号是否连签 3 天?取出位 → 检查
BITCOUNT sign:1001:2026-04 0 0 BIT # 仅看第 0 位 → 1
GETBIT sign:1001:2026-04 0
GETBIT sign:1001:2026-04 1
GETBIT sign:1001:2026-04 2
# 都是 1 → 连签 3 天⚠️ 注意:
SETBIT key 1000000000 1会立即分配 ~125 MB 内存(要把整个 String 拉到 1 亿位的长度)。避免使用过大的 offset,否则会瞬间打爆内存。
3.2 HyperLogLog:用 12 KB 估算 2^64 个元素
3.2.1 解决什么问题:基数统计
「基数(Cardinality)」就是一个集合里不重复元素的个数。最常见的场景是网站 UV(独立访客数)统计:
今天访问网站的 IP 列表(可能重复):
1.2.3.4, 5.6.7.8, 1.2.3.4, 9.9.9.9, 5.6.7.8, ...
UV = 不同 IP 的数量 = 3精确算法的痛点:
方案 A: Set(精确)
内存:每个 IP 16~80 字节
10 亿 UV → 至少 16 GB ❌
方案 B: 数据库 SELECT COUNT(DISTINCT ip)
实时性差,大数据慢得要死 ❌
方案 C: HyperLogLog(估算)
固定 12 KB ✅
误差率 ~ 0.81%(万分之 81)3.2.2 是什么 + 生活类比
HyperLogLog(HLL)是一种概率性基数估算算法。它不存原始元素,只存「统计指标」,所以无论你塞多少数据,它永远只占 12 KB。
🪙 生活类比:抛硬币猜「这群人扔了多少次」。
- 一群人在扔硬币,你不知道总共扔了多少次。
- 但有人告诉你:「我连续扔出了 10 次正面才停。」
- 你能推断出:他们大概扔了 2^10 ≈ 1024 次硬币。
HLL 的思路类似:把元素哈希成一串二进制,统计「连续前导 0 的最大数量」,反推总元素数。
3.2.3 算法直觉(不严谨但好懂)
1. 元素 → hash 成 64 位二进制
"user_alice" → 00010110 11010101 10110010 ...
"user_bob" → 00000010 11110001 00010110 ...
"user_alice" → 00010110 11010101 10110010 ... ← 重复 hash 一致
2. 数前导 0 的个数(粗略地):
alice: 3 个前导 0
bob: 6 个前导 0
alice 又来一次:3 个前导 0(重复不影响)
3. 取最大值 max_zeros = 6
估算总数 ≈ 2^6 = 64
4. 但单次估算误差大!HLL 用 16384 个「桶」,每个桶各自统计前导 0 的最大值,
最后做调和平均 + 偏差修正。这样 12 KB(16384 × 6 bit ≈ 12KB)就能估算 2^64。3.2.4 核心命令
bash
# 添加元素(不存原值,只更新内部统计)
PFADD uv:2026-04-17 user1 user2 user3
PFADD uv:2026-04-17 user1 # 重复添加无效果
# 估算基数
PFCOUNT uv:2026-04-17 # → 3
# 合并多个 HLL(无误差累计)
PFADD uv:2026-04-16 user2 user5
PFMERGE uv:2026-04 uv:2026-04-17 uv:2026-04-16
PFCOUNT uv:2026-04 # 月 UV3.2.5 误差实测
python
import redis, random
r = redis.Redis(decode_responses=True)
r.delete("hll")
for i in range(100_000):
r.pfadd("hll", f"user_{i}")
print(r.pfcount("hll")) # 实际 100000
# HLL 估算 ≈ 99537(误差 0.46%)| 真实基数 | HLL 估算 | 误差 |
|---|---|---|
| 100 | 100 | 0% |
| 10,000 | 9,989 | 0.11% |
| 1,000,000 | 1,003,476 | 0.35% |
| 100,000,000 | 99,894,212 | 0.11% |
💡 结论:HLL 在「能容忍 < 1% 误差」的场景下,是 UV 统计的银弹。淘宝、微博、头条都在大规模使用。
3.2.6 局限性
- ❌ 不能拿出原始元素(PFCOUNT 只能给数量,不能 SMEMBERS)
- ❌ 小数据反而不省(< 几百个元素时,Set 更省内存)
- ❌ 有误差(极少数场景如「精确计费」不能用)
3.3 Geo:附近的人 / 附近的店
3.3.1 是什么 + 生活类比
Geo 让你用经纬度存「点位」,并能高效查询「半径 X 公里内的所有点」「两点距离」。
📍 生活类比:美团上「距离我 500 米内的奶茶店」、滴滴「附近 2 公里的可用司机」、微信「附近的人」——背后都是这类几何计算。
3.3.2 核心命令
bash
# 添加位置(经度 纬度 名称)
GEOADD shops 116.397 39.916 "故宫"
GEOADD shops 116.418 39.913 "三里屯" 116.405 39.904 "天安门"
# 查坐标
GEOPOS shops "故宫"
# 1) 1) "116.39699..."
# 2) "39.91599..."
# 两点距离
GEODIST shops "故宫" "三里屯" km # → "1.7732"
# 找半径范围内的点(核心命令!)
GEOSEARCH shops FROMMEMBER "故宫" BYRADIUS 5 km ASC COUNT 10
# 1) "故宫"
# 2) "天安门"
# 3) "三里屯"
# 也可以从任意经纬度搜索
GEOSEARCH shops FROMLONLAT 116.4 39.9 BYRADIUS 3 km WITHCOORD WITHDIST ASC
# 返回名称 + 距离 + 坐标
# 矩形范围
GEOSEARCH shops FROMLONLAT 116.4 39.9 BYBOX 5 5 km
# GeoHash 字符串(11 字符,可截短做前缀匹配)
GEOHASH shops "故宫" # → "wx4g0bv03h0"⚠️ GEORADIUS / GEORADIUSBYMEMBER 已被官方标记为 DEPRECATED,新代码统一用
GEOSEARCH。
3.3.3 底层实现:GeoHash + ZSet
Geo 的实现非常巧妙,没有引入新数据结构,完全建立在 ZSet 之上:
1. GeoHash 编码:
把二维的 (经度, 纬度) 通过「交叉位编码」映射成一个 52 位整数。
例如经度 116.397,纬度 39.916:
- 经度二分:[-180, 180] → 半 → 半 → 半 ...,得到 26 位
- 纬度二分:[-90, 90] → 半 → 半 → 半 ...,得到 26 位
- 交叉拼接:经[0]纬[0]经[1]纬[1] ... → 52 位整数
关键性质:地理位置接近的点,GeoHash 数值也接近!
(二分树相邻叶子,前缀长度相同)
2. 存到 ZSet:
ZADD shops <geohash_int> "故宫"
3. 查附近:
把搜索中心也算出 GeoHash,然后用 ZRANGEBYSCORE 取相邻 score 的成员,
再用真实经纬度过滤距离。可以验证:
bash
GEOADD shops 116.397 39.916 "故宫"
ZSCORE shops "故宫" # → "4069885519988585" ← 这就是 GeoHash 整数
TYPE shops # → "zset" ← 底层确实是 ZSet3.3.4 应用场景
| 场景 | 实现 |
|---|---|
| 附近的人 | GEOSEARCH users:online FROMMEMBER user_id BYRADIUS 1 km |
| 共享单车 | 车辆 GPS 上报到 Redis,查附近车 |
| 滴滴派单 | GEOSEARCH drivers FROMLONLAT ... BYRADIUS 2 km |
| POI 搜索 | 商圈、餐厅、加油站列表 |
⚠️ 集群下注意:所有 Geo 元素必须在同一个 hash slot 里(用 HashTag 强制:
GEOADD {city:bj}:shops)。
3.4 三大类型对比速查
┌──────────────┬───────────────┬──────────────────┬──────────────┐
│ 类型 │ 内存 │ 精度 │ 典型场景 │
├──────────────┼───────────────┼──────────────────┼──────────────┤
│ Bitmap │ 超省(1 位/元素)│ 精确 │ 签到/在线状态 │
│ HyperLogLog │ 固定 12 KB │ ~0.81% 误差 │ UV 统计 │
│ Geo │ 和 ZSet 一样 │ 精确(米级) │ 附近的人 │
└──────────────┴───────────────┴──────────────────┴──────────────┘3.5 实操:跑一遍配套代码
实战代码见 03_special_types/code/:
01_bitmap_signin.py:30 天连续签到统计02_hyperloglog_uv.py:HLL 误差实测 + 内存对比 Set03_geo_nearby.py:「附近的奶茶店」实现
浏览器演示见 03_special_types/demo.html:
- ① Bitmap 像素画板(点击格子设位)+ BITCOUNT/BITOP 实时计算
- ② HyperLogLog 误差曲线(输入元素数,看 HLL 估算 vs 真实值)
- ③ Geo 地图(点击地图加点,画半径搜索圈)
3.6 本章小结
┌────────────────────────────────────────────────────────┐
│ 本章核心要点 │
├────────────────────────────────────────────────────────┤
│ │
│ ① Bitmap = String 的位级操作,省内存到极致 │
│ 注意 offset 不要太大,否则爆内存 │
│ │
│ ② HyperLogLog = 12 KB 估算 2^64 基数,误差 ~0.81% │
│ 不能取出原始元素,但能 PFMERGE 多个 │
│ │
│ ③ Geo = 底层是 ZSet + GeoHash 整数 score │
│ 新代码统一用 GEOSEARCH,集群下用 HashTag │
│ │
└────────────────────────────────────────────────────────┘3.7 面试高频题
Q1:1 亿用户的签到状态用什么类型存?为什么?
考察点:内存敏感场景的类型选型。
标准答案:
用 Bitmap。每个用户用 1 bit 表示「今天是否签到」,1 亿 bit = 12.5 MB,相比 Set/Hash 节省 几百倍。
bash
SETBIT sign:2026-04-17 <user_id> 1
GETBIT sign:2026-04-17 <user_id>
BITCOUNT sign:2026-04-17 # 当日签到总人数加分项:
- 提到
BITOP AND求两天都签到的用户(连续签到统计) - 提到
SETBIT不要使用过大 offset,否则瞬间分配大内存 - 提到 6.0+ 的
BITFIELD支持多位字段(例如 8 bit 存连签天数)
Q2:HyperLogLog 的原理是什么?为什么 12 KB 能估算 2^64?
考察点:算法理解。
标准答案:
HLL 基于「伯努利试验 + 调和平均」的概率统计:
- 把元素 hash 成 64 位二进制;
- 数前缀连续 0 的最大数量 k,估算元素数 ≈ 2^k;
- 单次估算误差大,HLL 用 16384 个桶,每个桶独立统计;
- 对所有桶取调和平均(Harmonic Mean),消除极端值影响;
- 加上偏差修正系数。
内存计算:16384 桶 × 6 bit/桶 ≈ 12 KB(6 bit 能记 0~63,足够存 64 位 hash 的最大前缀 0 数)。
误差:标准误差 ≈ 1.04 / sqrt(16384) ≈ 0.81%。
加分项:
- 提到 Redis 用稀疏存储,小数据时实际占用更小;
- 提到
PFMERGE不会累积误差,因为合并的是桶级最大值; - 局限性:不能拿原始元素、小数据不如 Set 省。
Q3:Redis Geo 是怎么实现的?为什么用 ZSet?
考察点:底层原理。
标准答案:
Geo 底层就是 ZSet,没有新数据结构:
- 把 (经度, 纬度) 用 GeoHash 算法编码为 52 位整数(经纬交叉二分);
- 用这个整数作为 ZSet 的 score,地点名称作为 member;
- 关键性质:地理位置接近的点,GeoHash 数值也接近,所以可以用
ZRANGEBYSCORE找邻居; - 实际查询时还要做距离过滤(GeoHash 邻居不完全等同于地理邻居,存在边界效应)。
验证:ZSCORE geokey member 能直接看到 GeoHash 整数值,TYPE geokey 显示 zset。
加分项:集群环境下要把同区域 POI 放到同一个 slot(HashTag),避免跨槽查询。
Q4:1 亿 UV 统计用 Set 还是 HyperLogLog?什么情况下用 Set?
考察点:场景判断。
标准答案:
| 维度 | Set | HyperLogLog |
|---|---|---|
| 1 亿元素内存 | ~16 GB | 12 KB |
| 准确性 | 精确 | ~0.81% 误差 |
| 能取原始数据 | ✅ | ❌ |
| 适用场景 | 小规模 / 需要精确 / 需要遍历元素 | 大规模 / 容忍误差 |
选择:
- Set:财务对账、付费用户去重、需要 SMEMBERS 看具体是哪些用户。
- HLL:网站 UV、广告曝光去重、活跃用户估算(这些场景天然容忍小误差)。
加分项:可以 Set + HLL 双写,Set 用于精确查询时间窗口内的元素列表,HLL 用于跨时间窗口的合并统计(PFMERGE 周/月/年)。
Q5:Bitmap 适合做什么?什么时候不能用?
考察点:场景边界。
标准答案:
适合:
- 状态判断(在线/离线、签到/未签)
- 海量布尔统计(亿级用户日活、活跃天数)
- 简单去重(offset = 用户 ID)
不适合:
- offset 稀疏(如 user_id 是 UUID,无法映射到密集整数空间,会浪费内存)
- 元素是字符串而非数字(要先映射 ID)
- 需要存储元素的额外属性(Bitmap 只能存 0/1)
典型坑:
SETBIT key 1000000000 1立即分配 125 MB;- 用 GUID/UUID 直接当 offset 会撑爆内存。
Q6:怎么实现「附近 1 公里的店铺」?高并发下要注意什么?
考察点:综合应用。
标准答案:
bash
# 商家入驻时
GEOADD shops:beijing 116.397 39.916 "shop_001"
# 用户搜索附近
GEOSEARCH shops:beijing FROMLONLAT 116.4 39.91 BYRADIUS 1 km
WITHCOORD WITHDIST ASC COUNT 50高并发优化:
- 本地缓存热点 POI:城市级商铺列表变化少,CDN/本地缓存承担大头;
- 分级缓存:先按区/网格预聚合,再做精确查询;
- 限制半径和 COUNT:避免一次返回数万结果;
- 集群 HashTag:同城 POI 放同 slot,避免跨节点查询;
- 读写分离:用从节点扛查询。
加分项:超大规模场景(千万 POI、十万 QPS)通常会用专门的 GIS 引擎(PostGIS、ES geo_point、Tile38),Redis Geo 适合中小规模或作为热点缓存层。
📌 下一章预告:第 4 章我们讲 Key 设计与命令进阶——Key 怎么命名才不踩坑?为什么不能用
KEYS *,应该用什么替代?过期机制是怎么工作的?
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
python
"""
Ch3 配套代码 1 / 3 —— Bitmap 实战:30 天签到 + 连续签到统计
- 用一个 Bitmap 记录某用户某月的签到情况(每天 1 bit)
- 用 BITOP 计算「连续签到」的最大天数
"""
import datetime
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
def section(t): print("\n" + "=" * 60 + f"\n{t}\n" + "=" * 60)
def sign_in(user_id: int, date: datetime.date) -> None:
"""签到:把当月对应天数的位置 1。"""
key = f"sign:{user_id}:{date.strftime('%Y-%m')}"
r.setbit(key, date.day - 1, 1)
def is_signed(user_id: int, date: datetime.date) -> bool:
key = f"sign:{user_id}:{date.strftime('%Y-%m')}"
return r.getbit(key, date.day - 1) == 1
def month_count(user_id: int, year: int, month: int) -> int:
key = f"sign:{user_id}:{year:04d}-{month:02d}"
return r.bitcount(key)
def max_continuous(user_id: int, year: int, month: int) -> int:
"""统计本月最大连续签到天数。"""
key = f"sign:{user_id}:{year:04d}-{month:02d}"
bits = []
for day in range(31):
bits.append(r.getbit(key, day))
# 求最长连续 1
cur = best = 0
for b in bits:
if b == 1: cur += 1; best = max(best, cur)
else: cur = 0
return best
def demo() -> None:
section("Demo: 用户 1001 在 2026-04 月的签到")
# 模拟签到:1, 2, 3, 5, 6, 7, 8, 10
user = 1001
for day in [1, 2, 3, 5, 6, 7, 8, 10]:
sign_in(user, datetime.date(2026, 4, day))
print(f" 4 月 1 号签到了吗? {is_signed(user, datetime.date(2026, 4, 1))}")
print(f" 4 月 4 号签到了吗? {is_signed(user, datetime.date(2026, 4, 4))}")
print(f" 本月签到总天数: {month_count(user, 2026, 4)}")
print(f" 本月最大连签: {max_continuous(user, 2026, 4)} 天")
# BITOP:求两个用户都签到的天数
user2 = 1002
for day in [1, 3, 5, 7, 9]:
sign_in(user2, datetime.date(2026, 4, day))
r.bitop("AND", "sign:both:2026-04",
f"sign:{user}:2026-04", f"sign:{user2}:2026-04")
print(f"\n 用户 {user} 和 {user2} 同一天都签到的天数:"
f" {r.bitcount('sign:both:2026-04')}")
# 内存对比
print(f"\n Bitmap 占用:{r.memory_usage(f'sign:{user}:2026-04')} 字节")
r.delete(f"sign:{user}:2026-04", f"sign:{user2}:2026-04", "sign:both:2026-04")
if __name__ == "__main__":
try:
demo()
except redis.ConnectionError as e:
print(f"❌ Redis 连接失败: {e}")python
"""
Ch3 配套代码 2 / 3 —— HyperLogLog 实战:UV 误差实测 + 内存对比
- 同样的数据分别用 Set 和 HyperLogLog 统计
- 对比内存占用和误差
"""
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
def section(t): print("\n" + "=" * 60 + f"\n{t}\n" + "=" * 60)
def compare(n: int) -> None:
set_key = f"uv:set:{n}"
hll_key = f"uv:hll:{n}"
r.delete(set_key, hll_key)
# 用 pipeline 批量塞数据,避免逐条 RTT
pipe = r.pipeline(transaction=False)
for i in range(n):
pipe.sadd(set_key, f"user_{i}")
pipe.pfadd(hll_key, f"user_{i}")
pipe.execute()
set_count = r.scard(set_key)
hll_count = r.pfcount(hll_key)
err = abs(hll_count - n) / n * 100
set_mem = r.memory_usage(set_key)
hll_mem = r.memory_usage(hll_key)
print(f" 插入元素数: {n:>10,}")
print(f" Set 精确: {set_count:>10,} 内存: {set_mem:>9,} B")
print(f" HLL 估算: {hll_count:>10,} 内存: {hll_mem:>9,} B 误差: {err:.2f}%")
print(f" 内存节省: {set_mem / hll_mem:.1f}x")
print()
r.delete(set_key, hll_key)
def demo_pfmerge() -> None:
section("Demo: PFMERGE 合并多天 UV,估算月 UV")
for day in range(1, 8):
key = f"demo:uv:day{day}"
r.delete(key)
# 每天 1 万独立用户,相邻几天有 30% 重叠
for i in range(10000):
r.pfadd(key, f"user_{(i + day * 7000) % 30000}")
print(f" Day {day} UV ≈ {r.pfcount(key)}")
r.pfmerge("demo:uv:week", *[f"demo:uv:day{d}" for d in range(1, 8)])
print(f" 📊 周 UV(PFMERGE)≈ {r.pfcount('demo:uv:week')}")
print(f" (理论上应接近 30000,因为我们循环用 30000 个 user_id)")
r.delete("demo:uv:week", *[f"demo:uv:day{d}" for d in range(1, 8)])
if __name__ == "__main__":
try:
section("Demo 1: HLL vs Set —— 不同规模下的对比")
for n in [100, 1_000, 10_000, 100_000]:
compare(n)
demo_pfmerge()
except redis.ConnectionError as e:
print(f"❌ Redis 连接失败: {e}")python
"""
Ch3 配套代码 3 / 3 —— Geo 实战:附近的奶茶店
- GEOADD 录入店铺位置(北京几个真实坐标)
- GEOSEARCH 查找附近 + 距离排序
- 验证底层就是 ZSet
"""
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
KEY = "demo:shops:beijing"
def section(t): print("\n" + "=" * 60 + f"\n{t}\n" + "=" * 60)
def setup() -> None:
r.delete(KEY)
# 几个真实地点的近似经纬度
shops = [
(116.397, 39.916, "故宫"),
(116.405, 39.904, "天安门"),
(116.418, 39.913, "三里屯"),
(116.323, 39.989, "中关村"),
(116.276, 39.978, "颐和园"),
(116.470, 39.997, "望京"),
(116.343, 39.992, "清华大学"),
]
for lon, lat, name in shops:
r.geoadd(KEY, [lon, lat, name])
print(f" 录入 {len(shops)} 个地点")
def demo_basic() -> None:
section("Demo 1: 基本操作")
print(f" 天安门坐标: {r.geopos(KEY, '天安门')}")
print(f" 故宫到三里屯距离: {r.geodist(KEY, '故宫', '三里屯', 'km')} km")
print(f" 天安门到颐和园距离: {r.geodist(KEY, '天安门', '颐和园', 'km')} km")
print(f" 故宫的 GeoHash 字符串: {r.geohash(KEY, '故宫')}")
def demo_search() -> None:
section("Demo 2: 找天安门附近 5 公里的地点(距离升序)")
results = r.geosearch(
KEY,
member="天安门",
radius=5, unit="km",
sort="ASC",
withcoord=True, withdist=True,
)
for name, dist, coord in results:
print(f" {name:<10} 距离 {dist:.2f} km ({coord[0]:.4f}, {coord[1]:.4f})")
def demo_search_lonlat() -> None:
section("Demo 3: 从任意经纬度搜索(模拟用户当前位置)")
user_lon, user_lat = 116.40, 39.95
print(f" 用户当前位置: ({user_lon}, {user_lat})")
print(" 附近 8 km 内的地点:")
results = r.geosearch(
KEY,
longitude=user_lon, latitude=user_lat,
radius=8, unit="km",
sort="ASC",
withdist=True, count=5,
)
for name, dist in results:
print(f" {name:<10} {dist:.2f} km")
def demo_underlying() -> None:
section("Demo 4: 验证 Geo 底层就是 ZSet")
print(f" TYPE {KEY} = {r.type(KEY)}")
print(f" ZSCORE 故宫 = {r.zscore(KEY, '故宫')} ← 这就是 GeoHash 整数")
print(f" ZRANGE 0 2 = {r.zrange(KEY, 0, 2, withscores=True)}")
if __name__ == "__main__":
try:
setup()
demo_basic()
demo_search()
demo_search_lonlat()
demo_underlying()
r.delete(KEY)
except redis.ConnectionError as e:
print(f"❌ Redis 连接失败: {e}")01_bitmap_signin.py ↗ · 02_hyperloglog_uv.py ↗ · 03_geo_nearby.py ↗