经纬度转 geohash 这个话题,做 LBS、地图、轨迹处理的人早晚会遇到。它在“附近的人”查询、POI 去重、地理围栏这些场景里,基本是绕不开的基础工具。我最早接触它是因为要做一批点位数据的去重和区域归类,直接拿经纬度比对又慢又不准,换成 geohash 之后整个逻辑立刻清爽了。这篇文章我会把经纬度转 geohash 的原理、手写实现、实战用法和常见坑都过一遍,尤其是一些网上不常讲清楚的边界问题。无论你是第一次接触地理编码的新手,还是已经被边界漏查坑过的老开发,都能从中拿到对你有用的东西。
1. 为什么需要经纬度转 geohash:直接存经纬度到底缺了什么
1.1 直接拿经纬度做业务,痛点在哪里
经纬度本身是一组浮点数,纬度范围 -90 到 90,经度范围 -180 到 180。如果你只是存下来展示到地图上,那直接用 double 就行,完全不用转任何编码。但一旦涉及“查附近”“按区域统计”“判断某点是否在某个范围内”,裸的经纬度会带来几个现实问题:
第一,距离查询没法走普通索引。你要找“我周围 1 公里内的门店”,SQL 写出来通常是一个WHERE lat BETWEEN ... AND ... AND lng BETWEEN ... AND ...,再配合距离公式排序。这种矩形范围扫描在数据量小的时候没问题,数据量一旦到百万级、千万级,没有空间索引就会很吃力。虽然可以用空间索引,但不是所有数据库和表结构都方便引入那套东西。
第二,点位匹配和去重很麻烦。同一个位置,两次采集到的经纬度可能差几十米,直接比对浮点数永远匹配不上。如果每次都算距离再判断“小于 50 米算同一个点”,代码写起来啰嗦,跑起来也慢。
第三,区域归类很费劲。我想统计“每个城市区域有多少 POI”,总不能拿经纬度一个个做多边形相交判断。即使能做,性能也扛不住。
geohash 解决的就是这三件事。它把二维坐标编码成一串字符串,每个字符串代表地球上的一个矩形网格。于是距离查询变成前缀匹配,点匹配变成字符串比较,区域归类变成按字符串分组。本质是把一个连续的二维问题,离散化成一维字符串问题。
1.2 geohash 能做什么,不能做什么
先说能做的:
- 附近的人 / 附近门店:对中心点编码,再匹配相同前缀的点,快速构建候选集。
- 轨迹压缩:把轨迹点按 geohash 聚合,同一格子内的点只留一个,轨迹骨架就出来了。
- 地理围栏:把多边形区域近似成一组 geohash 网格,匹配时看点在哪个网格里。
- 数据去重与聚合:同一格子的坐标视为同一个位置,可用于爬虫去重、POI 合并。
但不能做的也很明确:geohash 不是精确坐标,它是有损编码。一个格子内所有坐标都映射成同一个字符串,所以它永远代替不了原始经纬度。距离计算、精确判定、路径规划这些场景,必须回到真实坐标去做。另外它也不是空间索引的万能替代品,复杂多边形查询、精确半径圈选,有时候用成熟的空间索引反而更合适。
理解这一点很重要。geohash 的价值是“快速缩小范围”,而不是“精确解决问题”。凡是把这个边界搞混的,后面一定会踩坑。
2. geohash 编码原理解读:二分法如何把坐标变成字符串
2.1 两个区间不断二分,生成二进制 bit 串
geohash 的编码思想说穿了很简单:不断把坐标所在区间一分为二,记下每次落在左半边还是右半边。
以经度为例。经度范围是 -180 到 180。拿着一个经度值,先跟 0 比:大于等于 0,落在右半边,记 1,区间缩小到 0 到 180;否则记 0,区间缩小到 -180 到 0。然后再拿新的区间中点去比,继续记 0 或 1。每比一次,区间缩小一半,得到的二进制位就多一位。
纬度完全一样,只是范围是 -90 到 90。
但这里有个关键约束:geohash 的二进制串不是先排完经度再排纬度,而是经纬度交替排列。第一位放经度,第二位放纬度,第三位放经度,第四位放纬度,以此类推。为什么是经度在前?因为经度的总跨度是 360,纬度是 180,经度范围大一圈。在总 bit 数是奇数时,把多余的一位给经度,能让东西方向和南北方向的精度尽量均衡。这个交替顺序是 geohash 规范里的约定,实现时千万别搞反。
2.2 base32 字符集:为什么偏偏少了四个字母
原始 bit 串太长,没法直接用,所以 geohash 会把每 5 个 bit 合成一组,映射成一个字符。5 个 bit 能表示 32 种情况,对应的字符集是:
0123456789bcdefghjkmnpqrstuvwxyz注意看,这里面没有 a、i、l、o 四个字母。原因很直接:0 和 o 太像,1 和 l 太像,1 和 i 也容易看混。去掉这四个字母后,剩下 10 个数字加 22 个字母,正好 32 个字符。这套设计是为了让 geohash 字符串在肉眼阅读、日志打印、URL 传输时都尽量少产生歧义。
另外,绝大多数实现输出小写字符。不同库之间如果大小写处理不一致,比对时很容易出问题,后面我会专门提到。
2.3 拿具体坐标走一遍编码流程
光说理论有点虚,我们拿一个示例坐标 (39.9, 116.4) 手动推几步。
先是经度 116.4 的二分过程:
- 第一次:区间 -180 到 180,中点 0,116.4 大于 0,记 1,新区间 0 到 180
- 第二次:区间 0 到 180,中点 90,116.4 大于 90,记 1,新区间 90 到 180
- 第三次:区间 90 到 180,中点 135,116.4 小于 135,记 0,新区间 90 到 135
- 第四次:区间 90 到 135,中点 112.5,116.4 大于 112.5,记 1,新区间 112.5 到 135
- 第五次:区间 112.5 到 135,中点 123.75,116.4 小于 123.75,记 0,新区间 112.5 到 123.75
所以经度前 5 个 bit 是 11010。
再看纬度 39.9:
- 第一次:区间 -90 到 90,中点 0,39.9 大于 0,记 1,新区间 0 到 90
- 第二次:区间 0 到 90,中点 45,39.9 小于 45,记 0,新区间 0 到 45
- 第三次:区间 0 到 45,中点 22.5,39.9 大于 22.5,记 1,新区间 22.5 到 45
- 第四次:区间 22.5 到 45,中点 33.75,39.9 大于 33.75,记 1,新区间 33.75 到 45
- 第五次:区间 33.75 到 45,中点 39.375,39.9 大于 39.375,记 1,新区间 39.375 到 45
纬度前 5 个 bit 是 10111。
接下来按“经度、纬度、经度、纬度……”交替合并:
- bit0 经度:1
- bit1 纬度:1
- bit2 经度:1
- bit3 纬度:0
- bit4 经度:0
前 5 位是 11100,十进制 28,查字符集得到字符 w。
继续往后推:
- bit5 纬度:1
- bit6 经度:1
- bit7 纬度:1
- bit8 经度:0
- bit9 纬度:1
后 5 位是 11101,十进制 29,查表得到字符 x。
所以这个坐标至少会以 wx 开头。按同样流程把 bit 凑到你想要的总长度,每 5 位一组查表,就能得到完整的 geohash 字符串。整体逻辑就是一个不断缩小包围圈的过程,没有任何神秘的地方。
2.4 解码就是反向操作
解码是编码的逆过程。拿到 geohash 字符串,先反查字符集得到每个字符对应的 5 位二进制。比如字符 w 对应 11100,把这些 bit 按位展开,遇到偶数位按经度区间二分处理,遇到奇数位按纬度区间二分处理。
处理方式和编码时完全一致:bit 为 1 取右区间,bit 为 0 取左区间。跑完所有 bit 后,经纬度各自得到一个小区间,取区间的中点,就是解码结果。
这里要说一个很多人忽略的点:解码出来的坐标不是原始坐标,而是网格中心点。比如你编码 (39.9123, 116.4567),得到 8 位 geohash 后再解码,可能得到 (39.9121, 116.4569),两者差个几米很正常。这是有损编码的必然结果,所以任何需要精确位置的场景,都必须同时保存原始经纬度。
3. 从零手写 Python 经纬度转 geohash:完整代码与注释
3.1 encode 和 decode 完整实现
工具库虽然好用,但手写一份能加深对原理的理解,而且在某些不能随便装第三方依赖的环境里,直接拿来用也方便。下面这份代码是我基于标准 geohash 规则实现的,支持自定义精度,也能反向解码。
class GeoHash: BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz" @staticmethod def encode(lat, lng, precision=6): lat_range = [-90.0, 90.0] lng_range = [-180.0, 180.0] bits = [] total_bits = precision * 5 for i in range(total_bits): if i % 2 == 0: # 偶数位处理经度 mid = (lng_range[0] + lng_range[1]) / 2 if lng >= mid: bits.append('1') lng_range[0] = mid else: bits.append('0') lng_range[1] = mid else: # 奇数位处理纬度 mid = (lat_range[0] + lat_range[1]) / 2 if lat >= mid: bits.append('1') lat_range[0] = mid else: bits.append('0') lat_range[1] = mid result = [] for i in range(0, total_bits, 5): chunk = ''.join(bits[i:i + 5]) result.append(GeoHash.BASE32[int(chunk, 2)]) return ''.join(result) @staticmethod def decode(geohash): lat_range = [-90.0, 90.0] lng_range = [-180.0, 180.0] bits = [] for ch in geohash: idx = GeoHash.BASE32.index(ch) bits.extend(f'{idx:05b}') for i, bit in enumerate(bits): if i % 2 == 0: # 偶数位还原经度区间 mid = (lng_range[0] + lng_range[1]) / 2 if bit == '1': lng_range[0] = mid else: lng_range[1] = mid else: # 奇数位还原纬度区间 mid = (lat_range[0] + lat_range[1]) / 2 if bit == '1': lat_range[0] = mid else: lat_range[1] = mid lat = (lat_range[0] + lat_range[1]) / 2 lng = (lng_range[0] + lng_range[1]) / 2 return lat, lng一个简单的验证方式:
gh = GeoHash.encode(39.9, 116.4, 6) print(gh) # 6 位 geohash lat, lng = GeoHash.decode(gh) print(lat, lng) # 解码后的网格中心坐标你可以用任何在线 geohash 工具交叉验证。解码得到的坐标和原始坐标通常只有几十米甚至几米的差距,具体取决于精度。
3.2 这份代码里的三个关键细节
仔细看代码,有几个容易写错的地方值得强调。
第一个是交替顺序。i % 2 == 0处理的是经度,不是纬度。很多手写实现挂在这里,一旦顺序反了,编码结果和标准库对不上,后续所有匹配都会出问题。
第二个是 bit 拼接。decode里用bits.extend(f'{idx:05b}')把每个字符展开成 5 位二进制,注意f'{idx:05b}'会补足前导零。比如索引 3 展开成00011,而不是11。少了补零这一步,整个位串长度不对,解码结果自然全是错的。
第三个是边界条件。编码时用的是>= mid取右区间,这样落在中点上的点保证有一个确定的归属,不会出现模棱两可的情况。不同实现可能用的是>,虽然影响极小,但为了跟标准库输出一致,最好统一用>=。
3.3 精度对照表与选择建议
geohash 长度直接决定网格大小。每多一位,网格就在某个方向上进一步细分。赤道附近的参考数值如下:
| geohash 长度 | 经度方向格子宽(约) | 纬度方向格子高(约) | 典型用途 |
|---|---|---|---|
| 1 | 5000km | 5000km | 大洲级 |
| 2 | 1250km | 625km | 国家级 |
| 3 | 156km | 156km | 城市/区县 |
| 4 | 39km | 19km | 城区 |
| 5 | 4.9km | 4.9km | 乡镇/街道 |
| 6 | 1.2km | 0.6km | 附近的人 |
| 7 | 153m | 153m | 小区级 |
| 8 | 38m | 19m | 楼栋级 |
| 9 | 4.8m | 4.8m | 精确到几米 |
| 10 | 1.2m | 0.6m | 室内级 |
有一个细节必须注意:经度方向的物理宽度会随着纬度升高而收缩。比如在赤道附近经度 1 格约 1.2km,但到了高纬度地区可能只剩一半甚至更少。上面表格是赤道附近的参考值,实际使用时心里要有这根弦。
选精度没有绝对标准,我的经验是:查询半径在 100m 级用 7 位,500m 到 1km 用 6 位,城市级统计用 4 到 5 位。宁可让候选集大一点,也不要为了省查询量选一个比业务半径还小的格子,否则必然漏数据。
3.4 不想手写时的现成方案
如果项目允许引入外部依赖,没必要重复造轮子,但要注意选型。Python 生态里常用的有 geohash2、pygeohash、python-geohash 等,接口大同小异,基本都提供 encode、decode、neighbors 函数。注意不同库的 API 名称可能不同,有的叫encode,有的叫geohash,安装之前先看清楚文档。
数据库层面也有现成函数。MySQL 8.0 提供了ST_GeoHash(lng, lat, max_length),注意参数顺序是经度在前、纬度在后,很多人第一次用都写反。PostGIS 里有ST_GeoHash(geom, precision),需要先构造 geometry 对象。如果你的数据已经落在数据库里,用这些内置函数能省去应用层计算。
Redis GEO 是一个更特殊的方案。它内部使用了一种类似 geohash 的编码方式,把经纬度编码成 52 位整数,然后利用跳表做范围查询。GEOADD、GEORADIUS 这些命令已经把边界问题处理好了,如果只是需要“附近的人”功能,Redis GEO 往往比自己在应用层拼 geohash 更省心。但它返回的是距离排序结果,不是字符串 geohash,所以不要把两者混为一谈。
4. 实战指南:用 geohash 做附近的人查询时,八邻格为什么不能省
4.1 数据表结构设计
附近的人 / 附近门店,是 geohash 最经典的应用。基本的表结构可以这样设计:
CREATE TABLE pois ( id BIGINT PRIMARY KEY, name VARCHAR(255), lat DOUBLE NOT NULL, lng DOUBLE NOT NULL, geohash6 CHAR(6) NOT NULL, geohash7 CHAR(7) NOT NULL, geohash8 CHAR(8) NOT NULL, KEY idx_gh6 (geohash6), KEY idx_gh7 (geohash7), KEY idx_gh8 (geohash8) );同时存 6、7、8 位三个字段看起来有点浪费,实际上每个字段就几个字节,加上索引也远小于一次全表扫描的成本。好处是查询时可以根据半径直接选字段,不用在 SQL 里现算 geohash,也不用担心不同长度在前缀匹配时带来的额外复杂度。
如果你用的是 MySQL 8,插入时可以直接调用内置函数:
INSERT INTO pois (name, lat, lng, geohash6, geohash7) VALUES ('某门店', 39.9, 116.4, ST_GeoHash(116.4, 39.9, 6), ST_GeoHash(116.4, 39.9, 7));再一次提醒:ST_GeoHash的第一个参数是经度,第二个才是纬度。
4.2 查询流程:前缀匹配、八邻格、距离排序
假设要查某个坐标附近 500 米内的门店,完整的查询套路分三步。
第一步,确定用哪个精度的 geohash。500 米半径,6 位 geohash 的格子约 1.2km 宽,8 邻格覆盖范围够用。7 位只有 153m,只查一圈不够,要向外扩好几层,成本反而更高。所以这里选 6 位合适。
第二步,算出中心点本身的 geohash 和它的 8 个邻居 geohash。用下面的函数拿到邻居集合:
def cell_size(precision): n = precision * 5 lng_bits = (n + 1) // 2 lat_bits = n // 2 lng_span = 360.0 / (2 ** lng_bits) lat_span = 180.0 / (2 ** lat_bits) return lat_span, lng_span def neighbors(geohash): precision = len(geohash) lat, lng = GeoHash.decode(geohash) lat_span, lng_span = cell_size(precision) result = set() for dlat in (-1, 0, 1): for dlng in (-1, 0, 1): if dlat == 0 and dlng == 0: continue nlat = lat + dlat * lat_span nlng = lng + dlng * lng_span # 处理跨越 180 度经线的情况 nlng = (nlng + 180) % 360 - 180 result.add(GeoHash.encode(nlat, nlng, precision)) return resultneighbors的思路是:把中心 geohash 解码得到中心点和格子大小,然后上下左右位移一个格子,重新编码成新的 geohash。这套方法实现简单,也绕开了直接操作二进制 bit 的复杂度。
第三步,用这 9 个 geohash 去数据库里匹配,然后再做真实距离排序:
SELECT id, name, lat, lng, (6371000 * acos( cos(radians(39.9)) * cos(radians(lat)) * cos(radians(lng) - radians(116.4)) + sin(radians(39.9)) * sin(radians(lat)) )) AS distance FROM pois WHERE geohash6 IN ('中心哈希', '邻居1', '邻居2', ...) ORDER BY distance LIMIT 20;注意最后一定不能省掉距离排序。geohash 匹配只是粗筛,同一个格子里可能有点在东北角,有点在西南角,它们之间的距离可能超过你的业务半径。只有经过真实距离过滤和排序,结果才是可靠的。
4.3 不查八邻格会漏掉什么
很多人第一次写附近查询时,只查中心点格子的前缀匹配,结果发现边界处的点位大量丢失。原因很简单:geohash 网格是矩形的,而你要找的是一个圆形范围。圆心落在某个格子里,但圆边可能伸进旁边好几个相邻格子。那些跨进邻格的点,geohash 前缀跟你中心点的前缀完全不同,只匹配本格子必然漏掉。
举个例子,中心点恰好在一个格子的右下角,半径 500 米的圆大部分都落在右边和下边的格子。如果你只查中心格子,等于把目标区域砍掉一大块。所以“中心格 + 8 邻格”是附近查询的最低要求,少了任何一个都可能在特定位置出问题。
4.4 跨经线和高纬度地区的边角情况
跨 180 度经线是个经典场景。比如东经 179.5 和西经 -179.5,实际距离只有两度左右,但 geohash 前缀可能完全不同。上面的neighbors函数用了(nlng + 180) % 360 - 180做了经度回绕,所以即使中心点靠近 180 度经线,偏移后的邻居坐标也能回到合法范围内。
高纬度地区的麻烦主要来自两点。一是经度方向格子的物理距离随着纬度升高而收缩,导致你在高纬度用同样的 geohash 精度,实际覆盖的物理范围比赤道附近小很多。二是接近极点时,所有经线汇聚到一点,格子形状变得极不规则。不过对绝大多数业务来说,用户根本不会出现在极点上,只要知道这个限制存在即可。
如果项目覆盖全球且业务对边界特别敏感,一个稳妥做法是:先用 geohash 粗筛出候选集,再对每一条候选记录用真实坐标计算距离,以距离为准做最终判断。把 geohash 当成漏斗的一层,而不是终点。
5. 边界问题与踩坑实录:geohash 最容易翻车的三个细节
5.1 常见问题速查表
把实际使用中容易踩的问题整理成了表格,方便你排查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 附近点位查不全 | 只查了中心格子,没查 8 邻格 | 补上neighbors查询 |
| 两个很远的位置 hash 前缀一样 | 精度过低,格子太大 | 提高 geohash 精度 |
| 两个很近的位置 hash 前缀差异很大 | 点刚好落在两个格子边界两侧 | 配合邻格查询 + 距离排序 |
| 解码坐标和原始坐标差几十米 | geohash 是网格中心,有量化误差 | 需要精确定位时保留原始经纬度 |
| 字符串匹配偶尔失败 | 不同库输出大小写不一致 | 入库前统一转成小写 |
ST_GeoHash结果和 Python 库结果不一致 | 参数顺序写反,或精度语义不同 | 检查经纬度入参顺序 |
5.2 精度选型的经验:宁大勿小,结合实际半径
精度选型是 geohash 使用中最容易被低估的一环。我见过有人在半径 100 米的需求里用了 9 位 geohash,然后发现怎么查都漏数据。原因前面说过,9 位格子只有 4.8m 左右,查 9 格也只覆盖十几米的物理范围,半径 100 米根本不够。
反过来,如果半径只有 50 米,但为了省事用了 5 位 geohash,那一次查询会捞出几平方公里内的所有点,候选集大得离谱,性能照样崩。所以选精度前先算一笔账:目标半径和格子宽度至少要同量级,理想状态下格子宽度应该比半径略大一些,这样 9 格查询刚好能覆盖目标区域。
实际项目中我是这样选的:半径小于 100m 时用 7 位,半径 100m 到 1km 用 6 位,半径 1km 到 5km 用 5 位。如果业务半径经常变化,就按最大半径选一个统一精度,代价是日常小范围查询会多查一些点,但换来的是逻辑简单、不会漏数据。
5.3 关于整数化 geohash 和 Redis GEO 的一些看法
字符串 geohash 虽然好用,但在超大规模数据下,字符串索引和IN查询仍然有开销。一种常见优化是把 geohash 转成整数存储。方法很简单:每个字符占 5 位,从高位开始拼接成一个 64 位整数。这样存储体积更小,比较速度更快,还能直接在缓存系统里做范围扫描。
但注意,千万不要把整数 geohash 的排序等同于空间排序。geohash 的字符串前缀顺序和二维空间的邻近关系并不一致,仅仅因为两个整数大小接近就认为两个点空间邻近,是会出问题的。整数化适合做等值匹配和存储压缩,不适合替代真正的空间索引。
Redis GEO 是另一个值得提的方案。它对标准 geohash 做了改造,内部使用 52 位整数编码,配合跳表实现了高性能的半径查询。如果你只是需要“附近的人”“附近的店”,直接用 Redis GEO 命令可以省掉大量应用层工作。不过 Redis GEO 不返回字符串 geohash,也没有公开的编码细节,所以如果想跟其他系统共享 geohash 字段,或者需要自定义网格聚合逻辑,还是用标准字符串 geohash 更灵活。
5.4 我在 POI 聚合项目里总结的操作习惯
最后分享几个真实项目中沉淀下来的习惯,都是踩过坑之后才养成的。
第一,入库时保留原始经纬度的同时,至少算两个不同精度的 geohash 字段。比如 geohash6 和 geohash8。粗精度字段用于快速区域筛选,细精度字段用于精排和边界复核。这个冗余很便宜,但能省掉很多查询时的临场计算。
第二,任何 geohash 匹配结果都过一遍真实距离阈值。我处理 POI 合并时,先用 7 位 geohash 做粗筛,把同一格子及邻格的点全部捞出来,然后用哈弗辛公式逐一算距离,只有距离小于设定值的才进入合并流程。这一步绝不能省,因为 7 位格子本身约 153m,同一个格子里距离几百米的点也很常见。
第三,别在 SQL 里用LIKE '前缀%'做超大规模查询。前缀索引在数据量大的时候非常容易退化成全表扫描。更稳的做法是直接把geohash7放进IN列表,配合普通索引命中。如果候选 geohash 数量很多,可以考虑把查询拆成多个批次,避免单条 SQL 太重。
写到最后,我还是想强调一句:geohash 是工具,不是目的。它把二维距离问题变成了一维字符串匹配,这是它的核心价值,但代价就是量化误差和边界问题永远存在。我个人做地理位置相关功能时,已经形成了一套固定组合拳:低精度 geohash 粗筛、8 邻格补边界、真实距离精排。这套组合用得非常稳,基本没出过大问题。你如果刚开始接触,建议从 6 位精度入手,先把流程跑通,再根据实际数据量和业务半径去调精度,会比一上来就追求 8 位、9 位舒服得多。