255 lines
8.1 KiB
Markdown
255 lines
8.1 KiB
Markdown
---
|
||
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]]
|