Files
cs-note/hhs/Redis/16-GEO.md
T

255 lines
8.1 KiB
Markdown
Raw Normal View History

2026-05-24 21:18:14 +08:00
---
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 | 高精度 |
2026-05-26 00:03:40 +08:00
Redis GEO 将 GeoHash 编码为 **52-bit 整数**(对应约 10 位字符精度),水平方向误差约 **±1m**,垂直方向约 **±0.6m**,足以满足绝大多数 LBS 场景。
2026-05-24 21:18:14 +08:00
```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]]