Files
cs-note/hhs/Redis/16-GEO.md
T
2026-05-26 00:03:40 +08:00

255 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [Redis, 缓存, 数据结构, GEO]
create time: 2026-05-24 10:00
---
# Redis GEO 地理位置
## 概述
GEO 是 Redis 3.2 引入的地理位置数据类型,底层基于 Sorted Set 实现,通过 GeoHash 编码将经纬度转换为可排序的分数值。它让我们能用简洁的命令完成"附近的人/门店"等 LBS(Location-Based Service)场景,无需依赖外部地理数据库。
## 正文
### 1. 底层原理
> [!question] GEO 既然是独立类型,为什么说它"没有自己的数据结构"?
> 因为 GEO 底层就是 Sorted Set。Redis 只是在 ZSet 之上封装了一层经纬度编解码逻辑,并没有新增底层数据结构。
核心编码流程:
```
经纬度 (latitude, longitude)
↓ GeoHash 编码
Base32 字符串
↓ 转换
52-bit 整数 → 存为 ZSet 的 score
↓
member 名称 → 存为 ZSet 的 value
```
GeoHash 的本质是将二维坐标递归二分,交替切分经度和纬度,得到一个二进制串,再编码为 Base32 字符串。切分越细,精度越高(字符串越长)。
> [!tip] 为什么 GeoHash 能支持范围查询?
> GeoHash 具有 **前缀匹配特性**:地理上越近的点,其 GeoHash 前缀越相似。将 GeoHash 转为 52-bit 整数后,相邻地理位置的分数也相近,这使得 ZSet 的 `ZRANGEBYSCORE` 天然支持范围查询。
**GeoHash 精度表**:
| 字符串长度 | 精度 (km) | 适用场景 |
|-----------|----------|---------|
| 4 | ~40 | 城市级 |
| 5 | ~5 | 区县级 |
| 6 | ~0.6 | 街道级 |
| 7 | ~0.076 | 建筑级 |
| 8 | ~0.019 | 高精度 |
Redis GEO 将 GeoHash 编码为 **52-bit 整数**(对应约 10 位字符精度),水平方向误差约 **±1m**,垂直方向约 **±0.6m**,足以满足绝大多数 LBS 场景。
```mermaid
graph TD
A["输入经纬度"] --> B["GeoHash 编码"]
B --> C["52-bit 整数"]
C --> D["存入 ZSet score"]
D --> E["范围查询时按 score 排序"]
E --> F["解码还原经纬度并计算实际距离"]
```
### 2. 核心命令
> [!question] GEORADIUS 命令好用,为什么 Redis 6.2 要废弃它?
> `GEORADIUS` / `GEORADIUSBYMEMBER` 参数组合繁多,语义复杂。Redis 6.2 引入 `GEOSEARCH` 和 `GEOSEARCHSTORE`,用更清晰的接口统一了圆形和矩形搜索。
#### 常用命令速查
| 命令 | 说明 |
|------|------|
| `GEOADD key lng lat member` | 添加一个或多个地理位置 |
| `GEOPOS key member` | 获取成员的经纬度 |
| `GEODIST key m1 m2 [unit]` | 计算两个成员之间的距离 |
| `GEOSEARCH key FROMMEMBER/FROMLONLAT ... BYRADIUS/BYBOX ...` | 搜索附近成员 (6.2+) |
| `GEOSEARCHSTORE dst src ...` | 搜索结果存入目标 key (6.2+) |
| `GEOHASH key member` | 返回成员的 GeoHash 字符串 |
**已废弃命令**(6.2+):
- `GEORADIUS` — 用 `GEOSEARCH` 替代
- `GEORADIUSBYMEMBER` — 用 `GEOSEARCH ... FROMMEMBER` 替代
#### 基本操作示例
```bash
# 添加门店坐标
GEOADD stores 116.405285 39.904989 "store:1001"
GEOADD stores 121.473701 31.230416 "store:1002"
# 获取坐标
GEOPOS stores "store:1001"
# 计算距离(默认单位:米)
GEODIST stores "store:1001" "store:1002" km
# 搜索北京某点 5km 内的门店,返回距离,最多 10 个
GEOSEARCH stores FROMLONLAT 116.405285 39.904989 \
BYRADIUS 5 km ASC COUNT 10 WITHDIST
```
### 3. 附近门店实战
以下是一个完整的 Go 示例,使用 `go-redis/v9` 实现"查找附近门店"功能。
```go
package main
import (
"context"
"fmt"
"log"
"github.com/redis/go-redis/v9"
)
// Store 门店信息
type Store struct {
Name string
Dist float64 // 距离,单位:米
}
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer rdb.Close()
// ---- 1. 批量添加门店坐标 ----
locations := []*redis.GeoLocation{
{Name: "store:1001", Longitude: 116.405285, Latitude: 39.904989},
{Name: "store:1002", Longitude: 116.407496, Latitude: 39.908712},
{Name: "store:1003", Longitude: 116.397827, Latitude: 39.906419},
}
_, err := rdb.GeoAdd(ctx, "stores", locations...).Result()
if err != nil {
log.Fatal(err)
}
// ---- 2. 搜索附近 3km 内的门店 ----
// 按距离升序排列,最多返回 5 个
searchOpts := &redis.GeoSearchLocationQuery{
GeoSearchQuery: redis.GeoSearchQuery{
Longitude: 116.405285,
Latitude: 39.904989,
Radius: 3,
RadiusUnit: "km",
Sort: "ASC",
Count: 5,
},
WithCoord: true,
WithDist: true,
}
results, err := rdb.GeoSearchLocation(ctx, "stores", searchOpts).Result()
if err != nil {
log.Fatal(err)
}
// ---- 3. 处理结果 ----
for _, r := range results {
fmt.Printf("门店: %s, 距离: %.2f km, 坐标: (%.6f, %.6f)\n",
r.Name, r.Dist, r.Longitude, r.Latitude)
}
}
```
> [!tip] `COUNT` 参数是软限制
> `COUNT 5` 表示最多返回 5 个结果,但 Redis 内部仍会遍历所有候选点再做距离校验。如果业务数据量很大,建议结合分页或缩小搜索半径。
### 4. 距离计算
`GEODIST` 和 `GEOSEARCH` 的距离计算基于 **Haversine 公式**,该公式通过球面三角学计算地球表面两点之间的大圆距离。
Haversine 公式核心:
$$a = \sin^2\left(\frac{\Delta\phi}{2}\right) + \cos\phi_1 \cdot \cos\phi_2 \cdot \sin^2\left(\frac{\Delta\lambda}{2}\right)$$
$$d = 2R \cdot \arcsin(\sqrt{a})$$
其中 $R \approx 6371$ km(地球半径)。
> [!question] Redis 的距离计算精度如何?
> Redis 使用 WGS84 参考椭球体近似为正球体,最大误差约 **0.5%**。对于 LBS 场景(几公里范围),误差通常在几十米以内,完全可接受。
### 5. 围栏检测
电子围栏(Geofencing)判断某个点是否在指定范围内。对于简单的圆形围栏,可以直接用 `GEODIST`;对于多边形围栏,需要自定义计算。
#### 圆形围栏
```bash
# 判断 member 是否在圆心 1km 范围内
GEOSEARCH fence FROMLONLAT 116.405285 39.904989 \
BYRADIUS 1 km ASC COUNT 1
```
#### 多边形围栏(射线法)
```go
// PointInPolygon 射线法判断点是否在多边形内
func PointInPolygon(lat, lng float64, polygon [][2]float64) bool {
inside := false
j := len(polygon) - 1
for i := 0; i < len(polygon); i++ {
xi, yi := polygon[i][1], polygon[i][0]
xj, yj := polygon[j][1], polygon[j][0]
if (yi > lat) != (yj > lat) &&
lng < (xj-xi)*(lat-yi)/(yj-yi)+xi {
inside = !inside
}
j = i
}
return inside
}
```
> [!tip] 围栏方案选择
> - **圆形围栏**:直接使用 GEO 命令,零额外开发成本。
> - **矩形围栏**:使用 `GEOSEARCH ... BYBOX` 指定宽高。
> - **不规则多边形**:先用 GEO 圈定一个粗略范围缩小候选集,再用射线法精确判断,兼顾性能与精度。
### 6. 附近门店搜索流程
```mermaid
graph TD
A["客户端发送请求"] --> B["获取用户经纬度"]
B --> C["GEOSEARCH 搜索半径 R"]
C --> D{"结果数量 > 0?"}
D -->|No| E["扩大半径 R += step"]
E --> C
D -->|Yes| F["返回门店列表及距离"]
F --> G["客户端渲染附近门店"]
```
### 7. 性能与限制
> [!question] GEO 能存多少数据?
> GEO 底层是 ZSet,受 ZSet 最大容量限制:单个 key 最大约 **150GB**(约 $2^{32}$ 个元素),足以存储数十亿个地理位置。
| 限制项 | 说明 |
|-------|------|
| 单 key 最大元素数 | ~$2^{32}$(约 43 亿) |
| 单 key 最大内存 | ~150GB(ZSet 限制) |
| GeoHash 精度 | 52-bit,约 0.019m |
| 最小搜索半径 | 0.000001(单位取决于指定) |
| 经度范围 | -180 ~ 180 |
| 纬度范围 | -85.05112878 ~ 85.05112878 |
**性能建议**:
- 搜索操作时间复杂度为 $O(N + \log M)$,其中 $N$ 为结果集大小,$M$ 为 key 中元素总数。大 key 下应控制搜索半径和 COUNT。
- 如果只需坐标而不需要距离计算,可用 `GEOPOS` 代替 `GEOSEARCH` 的 `WITHDIST`,减少计算开销。
- 对于超高并发场景,考虑将 GEO 数据加载到应用进程内存中做本地计算,减轻 Redis 压力。
## 关联笔记
- [[hhs/Redis/02-核心数据类型]]
- [[hhs/Redis/08-SortedSet精解]]
- [[hhs/Redis/README]]