Redis 地理命令为带名称的位置建立索引,可以直接回答附近有哪些点,不必把全部坐标传回应用。它适合能够接受球面距离模型的场景,但不会提供道路路线、考虑海拔的空间距离、任意多边形分析,也不会保证查询到的每个位置仍然新鲜。这些不是查询速度更快就能顺带解决的问题。
经度必须放在纬度前面
GEOADD 按经度、纬度、成员名的顺序接收参数。有效经度从负一百八十度到正一百八十度,纬度则限制在大约负 85.05112878 度到正 85.05112878 度之间,极区不属于该索引支持的范围。如果把两个各自仍合法的数值交换位置,命令可能正常成功,却把成员放到完全不同的地方。
索引底层是有序集合,分数保存五十二位地理哈希编码,不能用普通排行榜分数覆盖这些位置编码。成员是唯一的:更新同名成员只会改变位置,不会增加一份记录。GEOADD 默认统计新增成员数,加 CH 才统计新增及实际变化。删除位置使用 ZREM,读取存储坐标则使用 GEOPOS;不要把一次更新返回零当成更新失败。
坐标编码具有有限精度,因此 GEOPOS 不一定原样返回输入的小数。GEODIST 使用理想球面地球计算表面距离,不是穿过地球内部的直线弦长,更不是驾车路线。默认单位为米,也可以选择公里、英尺和英里。任何一个端点缺失时,返回空结果,而不是把不存在的位置当作距离为零的重合点。
把查询条件写成明确契约
Redis 6.2 开始提供 GEOSEARCH,可以围绕已有成员或显式经纬度查询。BYRADIUS 指定圆形范围,BYBOX 则按所选单位指定轴向对齐矩形的宽和高。新的查询应使用该命令,而不是已弃用的 GEORADIUS 系列。请求中的单位与结果解释必须一致,否则正确的数值也会被应用解读成错误的距离。
ASC 按距离从近到远排列,COUNT 限制返回数量。要找最近的若干个结果,应使用 ASC 和 COUNT,避免 ANY;ANY 允许找到足够数量后提前结束,不能保证拿到查询区域内全局最近的那些点。另外,返回数量少不代表查询一定便宜:范围巨大且点位密集时,即使只要求少量结果,也可能需要检查很多候选。
FROMMEMBER 查询可以包含作为中心的成员自身。如果业务要找“附近的其他人”,就需要处理这个结果,而不是直接把第一条当作别人。WITHDIST 返回所选单位下的距离,WITHCOORD 返回坐标。结果反映的是查询时索引里存放的数据,并不等于已经独立确认某台设备或某个商户此刻就在该位置。
坏掉的样子
文档说明,在边界情况下,球面近似与地球实际形状之间的误差可以达到约百分之零点五。因此,“城市尺度”并不自动意味着误差小于一米。应用应明确可接受误差及距离边界如何判定;测绘、安全关键接近判断、道路规划或严格地理围栏,可能需要更合适的空间模型,不能只凭一次示例距离看起来合理就验收精度。
GEOSEARCHSTORE 把查询结果写进另一个有序集合,形成一次结果快照。默认保留地理位置分数,STOREDIST 则改为保存成员到查询中心的距离分数。后者是按距离排列的集合,不能继续作为地理索引使用。源索引随后更新或删除成员,都不会自动刷新已经存下来的任一种结果。
GEOADD 不会自动为每个位置添加过期时间或新鲜度时间戳。键级过期会删除整个索引,单个成员则需要通过 ZREM 等明确的成员管理操作清理。因此,一个很快的查询完全可能正确返回陈旧位置。位置何时上报、多久失效、何时移除,需要与空间查询本身分开设计和验证,不能把“还能搜到”当成位置仍然有效。
快速验证
原有实验记录使用 macOS、Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0;本轮审阅没有重新执行服务实验,下面的结果说明保留原有观察范围。准备 POSIX 环境、C 编译器、make、curl、tar 与 Python 3.10 或更新版本。安装步骤仅用 libc 构建 Redis 核心,并把客户端装入临时虚拟环境。
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
在同一个终端中用 "$redis_lab/venv/bin/python" 执行下面代码。实验只通过 /tmp 下的私有 Redis Unix socket 通信,与任何已有实例隔离;TCP、定时快照和追加日志均关闭。程序结束时停止自己的进程并删除本次运行目录。只有下载依赖需要网络,查询的两个城市坐标已经明确写在代码里。
import math
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", "noeviction",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
with instance() as client:
palermo = (13.361389, 38.115556)
catania = (15.087269, 37.502669)
assert client.geoadd("places", [
*palermo, b"Palermo", *catania, b"Catania",
]) == 2
assert client.type("places") == b"zset"
position = client.geopos("places", "Palermo")[0]
assert all(abs(actual - expected) < 0.00001
for actual, expected in zip(position, palermo))
distance = client.geodist("places", "Palermo", "Catania", unit="km")
assert math.isclose(distance, 166.2742, abs_tol=0.001)
assert client.geodist("places", "Palermo", "missing") is None
print(f"longitude first; decoded coordinates are approximate; distance {distance:.4f} km")
query = dict(longitude=15, latitude=37, radius=200, unit="km", sort="ASC")
results = client.geosearch("places", **query, withdist=True)
assert [item[0] for item in results] == [b"Catania", b"Palermo"]
assert results[0][1] < results[1][1] < 200
assert client.geosearch("places", **query, count=1) == [b"Catania"]
assert client.geosearch(
"places", member="Palermo", radius=1, unit="km", sort="ASC",
) == [b"Palermo"]
print("ASC COUNT 1 selects Catania; FROMMEMBER also includes the center member")
assert client.geosearchstore("subset", "places", **query) == 2
assert client.zscore("subset", "Catania") == client.zscore("places", "Catania")
assert client.geosearchstore("distances", "places", **query, storedist=True) == 2
assert math.isclose(client.zscore("distances", "Catania"),
results[0][1], abs_tol=0.001)
assert client.zscore("distances", "Catania") != client.zscore("places", "Catania")
print("default GEOSEARCHSTORE preserves position scores; STOREDIST stores kilometers")
try:
client.geoadd("places", [0, 90, b"pole"])
except redis.ResponseError as error:
assert "invalid longitude,latitude pair" in str(error)
else:
raise AssertionError("latitude 90 must be rejected")
assert client.zcard("places") == 2
assert client.geoadd("swapped", [
*palermo, b"correct", palermo[1], palermo[0], b"reversed",
]) == 2
assert client.geodist("swapped", "correct", "reversed", unit="km") > 1000
print("polar coordinates are rejected; swapped but valid coordinates are not detected")
assert client.geoadd("places", [13.4, 38.1, b"Palermo"]) == 0
assert client.zcard("places") == 2
assert client.geoadd("places", [13.5, 38.2, b"Palermo"], ch=True) == 1
assert client.zrem("places", b"Catania") == 1
assert client.geopos("places", "Catania") == [None]
assert client.zcard("subset") == 2
print("updates reuse members; ZREM removes locations; stored subsets are snapshots")
Palermo 与 Catania 之间得到约 166.2742 公里,坐标比较允许编码带来的精度差异。围绕指定坐标查询时,Catania 排在前面;以成员为中心的查询则包含 Palermo 自身。默认存储的结果分数与源索引一致,STOREDIST 的结果分数对应返回的公里距离,而不是位置编码。
纬度九十度会被拒绝,且没有插入成员;经纬度颠倒但仍落在两个合法区间时,却可以成功存储,表示远离原位置的另一点。同名成员更新不增加成员数量,删除 Catania 也不会修改先前存储的子集。这些结果验证命令语义与数据解释,没有证明真实定位精度、道路距离、陈旧位置防护或生产规模下的查询性能。
来源
- Redis GEOADD,坐标限制、编码、更新及球面模型误差,已核验 2026-09-08。
- Redis GEOPOS,存储坐标返回和缺失成员,已核验 2026-09-08。
- Redis GEODIST,球面距离、单位及缺失端点,已核验 2026-09-08。
- Redis GEOSEARCH,范围形状、排序、数量限制与 ANY 语义,已核验 2026-09-08。
- Redis GEOSEARCHSTORE,结果快照与距离分数存储,已核验 2026-09-08。
- Redis 8.10.1 构建说明,只编译核心及分配器选择,已核验 2026-09-08。