Skip to content

第 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             # 月 UV

3.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 估算误差
1001000%
10,0009,9890.11%
1,000,0001,003,4760.35%
100,000,00099,894,2120.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"  ← 底层确实是 ZSet

3.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 误差实测 + 内存对比 Set
  • 03_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 基于「伯努利试验 + 调和平均」的概率统计:

  1. 把元素 hash 成 64 位二进制;
  2. 数前缀连续 0 的最大数量 k,估算元素数 ≈ 2^k;
  3. 单次估算误差大,HLL 用 16384 个桶,每个桶独立统计;
  4. 对所有桶取调和平均(Harmonic Mean),消除极端值影响;
  5. 加上偏差修正系数。

内存计算: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,没有新数据结构:

  1. 把 (经度, 纬度) 用 GeoHash 算法编码为 52 位整数(经纬交叉二分);
  2. 用这个整数作为 ZSet 的 score,地点名称作为 member;
  3. 关键性质:地理位置接近的点,GeoHash 数值也接近,所以可以用 ZRANGEBYSCORE 找邻居;
  4. 实际查询时还要做距离过滤(GeoHash 邻居不完全等同于地理邻居,存在边界效应)。

验证ZSCORE geokey member 能直接看到 GeoHash 整数值,TYPE geokey 显示 zset。

加分项:集群环境下要把同区域 POI 放到同一个 slot(HashTag),避免跨槽查询。


Q4:1 亿 UV 统计用 Set 还是 HyperLogLog?什么情况下用 Set?

考察点:场景判断。

标准答案

维度SetHyperLogLog
1 亿元素内存~16 GB12 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

高并发优化

  1. 本地缓存热点 POI:城市级商铺列表变化少,CDN/本地缓存承担大头;
  2. 分级缓存:先按区/网格预聚合,再做精确查询;
  3. 限制半径和 COUNT:避免一次返回数万结果;
  4. 集群 HashTag:同城 POI 放同 slot,避免跨节点查询;
  5. 读写分离:用从节点扛查询。

加分项:超大规模场景(千万 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 ↗