前阵子接了个需求:给一批门店数据加位置检索能力。数据量不算大,一万多个点,但要按区域批量导出,还要支持“某点附近有哪些店”这种查询。我第一反应是“这不就是按经纬度范围过滤一下吗”,真动手才发现,直接拿经纬度做范围查询非常尴尬——要么全表扫描慢得没法看,要么索引策略绕来绕去。后来我把每个点都转成geohash字符串,区域导出和附近查询都变得干净利落。今天就把经纬度转geohash这件事彻底聊透,从原理到手写实现、精度选型、边界处理,再到实际业务里的坑,一次讲完。
这篇文章适合后端开发、数据分析、以及任何要和坐标打交道的同学。读完你能自己实现编码解码、知道该选几位geohash、能处理边界漏数据的经典问题,还会明白为什么geohash在分库分表、缓存设计里那么好用。
1. geohash是什么,以及它解决的真实痛点
1.1 一段让我改换思路的需求经历
最早我想得很简单:门店表里有lat和lng两个字段,按经纬度范围导出,SQL 里写WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?不就行了。可当区域范围一变,比如画个五公里圆,你就得先算经纬度上下界,然后在这个矩形里捞数据。听着也不算复杂,但问题在于:一万个点还能靠全表扫描扛住,数据量到百万、千万级,这种写法要么索引失效,要么EXPLAIN出来type=ALL,一次查询拖垮一个接口。
我也试过给经纬度各建一个普通索引,但二维范围条件对单列索引来说很难高效命中。传统 B+ 树本质上是一维结构,要让它处理二维查询,要么用空间索引,要么把二维坐标想办法压成一维。geohash 就是后者,而且压完之后,它只是一个普通字符串,能直接塞进 B+ 树、哈希索引、Redis 的 Sorted Set,甚至存文件里都能做字典序扫描。
1.2 geohash把二维坐标变成了一个字符串
geohash 的核心思想很简单粗暴:把经度、纬度分别做二分区间逼近,交替取出若干比特,拼成一个整数,再用 Base32 编码成字符串。换句话说,一个坐标点(lat, lng)会变成一个像是wtq535u的字符串。这个字符串有两个很宝贵的性质。
第一,字符串越长,代表的区域越小,精度越高。同一个点,1 位 geohash 可能代表全球某个大块,9 位就缩小到几米见方。第二,在同一片区域内,所有点的 geohash 都有共同前缀。比如两个点相聚几十米,它们的 7 位 geohash 很可能前 6 位完全相同。
这个共同前缀性质,让“查某个区域里的点”变成“查某个前缀开头的字符串”,无数现成的索引机制都能直接复用。这就是为什么 geohash 能在工程里被广泛应用,而不是只停留在教科书上。
1.3 这篇内容能帮你解决什么问题
我会先把编码原理拆开讲清楚,让你知道比特是怎么从坐标里“挤”出来的;然后给出一套手写 Python 实现,包含编码、解码和误差范围计算;接着讲精度对照表和边界处理,这是日常最容易踩坑的地方;最后说实际业务的选型思路,包括和四叉树、H3 这类方案怎么取舍。
我尽量把每一步的“为什么”也讲掉。比如 Base32 字符表为什么去掉 4 个字母,为什么有时候两个很近的点 geohash 前缀完全不同,为什么别总想着把格子算得很小。这些不是网上随便抄来的结论,都是我实际做数据迁移、做附近搜索、做热力聚合时踩过之后沉淀下来的经验。
2. 编码原理拆解:二维坐标如何变成一串字符
2.1 经纬度分别做二分区间逼近
编码过程可以理解成“猜数字游戏”:给一个数,我说一个区间,你告诉我在左边还是右边。每回答一次,区间就缩小一半。geohash 就是把经度和纬度分别拿来做这个游戏。
拿经度举例。初始区间是[-180, 180],取中点 0。如果目标经度大于等于 0,当前这一位记为 1,区间收缩成[0, 180];否则记为 0,区间变成[-180, 0]。下一轮继续取新区间的中点,再次比较,再次收缩。纬度同理,只是初始区间是[-90, 90]。
每轮比较产生 1 个 bit,经过 n 轮比较,区间宽度缩小到原来的1/2^n。这个 bit 序列就是坐标在这个维度上的“指纹”,位数越多,指纹越精确。这个过程是可逆的:拿着 bit 序列,反向把区间一步步恢复出来,取区间中点当作还原后的近似坐标,就能完成解码。
2.2 经度、纬度按位交替合并
一个坐标包含两个维度,如果只是简单把经度 bits 放前面、纬度 bits 放后面,字符串前缀就不能同时表达“位置相近”。所以标准做法是:从第一个 bit 开始,按“经度、纬度、经度、纬度……”的顺序交替排列。
为什么要这么干?因为相邻的格子希望共享尽量长的前缀。如果先把所有经度 bits 放前面,两个经度相同但纬度不同的点,前缀会完全一样,直到纬度 bits 开始才出现差异,这会让格子分布变得非常奇怪。交替合并之后,每隔 5 个 bit 编码成一个 Base32 字符,第一个字符主要体现了经度信息,第二个字符开始经纬信息就交织在一起了。
举一个直观例子:某个 7 位 geohash 一共用了 35 个 bit,其中 18 个 bit 来自经度,17 个 bit 来自纬度。因为经度范围 360 度比纬度范围 180 度大一倍,同样位数下经度方向切的格子更多,这不是 bug,是标准设计。你可以通过字符位置推断当前这一轮是在切经度还是切纬度,解码时要用同样的交替逻辑。
2.3 Base32字符表以及为什么去掉4个字母
每组 5 个 bit 可以表示 0 到 31 的整数,Base32 就是用 32 个字符去映射这些整数。geohash 社区常用的字符表是这样的:
| 数值 | 字符 | 数值 | 字符 | 数值 | 字符 | 数值 | 字符 |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 8 | 8 | 16 | h | 24 | s |
| 1 | 1 | 9 | 9 | 17 | j | 25 | t |
| 2 | 2 | 10 | b | 18 | k | 26 | u |
| 3 | 3 | 11 | c | 19 | m | 27 | v |
| 4 | 4 | 12 | d | 20 | n | 28 | w |
| 5 | 5 | 13 | e | 21 | p | 29 | x |
| 6 | 6 | 14 | f | 22 | q | 30 | y |
| 7 | 7 | 15 | g | 23 | r | 31 | z |
注意里面没有a、i、l、o这四个字母。原因是这些字符容易被看错:l和1太像,o和0太像,i在部分字体里也容易和1混淆,a则是因为老式编码里可能和4之类的字符产生误读。去掉之后,整个字符串更适合人工抄写和口头沟通。
在代码里,这 32 个字符的顺序是固定的,解码时要把字符映射回数值,所以通常用一个字典存好映射关系。如果你在别的语言里看到字符位序略有差异,比如个别库把b和c的顺序调换了,那基本都是不完整实现,标准顺序就是上面这张表。
3. 手写一个经纬度转geohash的Python实现
3.1 编码函数:最直观的版本
理解了原理,写实现其实不复杂。关键在于维护好两个区间,以及记住当前 bit 应该切经度还是切纬度。下面这个版本我故意写得直白,方便对照原理看。
def geohash_encode(lat, lng, precision=7): # 初始区间 lat_range = [-90.0, 90.0] lng_range = [-180.0, 180.0] bits = [] even = True # True 表示当前该处理经度,False 表示处理纬度 while len(bits) < precision * 5: if even: 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 even = not even chars = "0123456789bcdefghjkmnpqrstuvwxyz" result = [] for i in range(0, len(bits), 5): group = bits[i:i + 5] value = 0 for bit in group: value = (value << 1) | bit result.append(chars[value]) return ''.join(result)拿一个测试点试一下,坐标取lat=30.123456, lng=120.987654,跑precision=7,得到的结果是wtq535u。你可以把结果手动喂回解码函数,能还原出接近原始值的坐标,差了十几米到几十米,这是正常的。
代码里最容易被忽略的是even这个标志。我第一次手写时就写反了,导致前两个字符对调,排查了半天。我建议在调试阶段打几个已知坐标,拿在线工具或者现成库的结果对比,确保交替顺序没反。
3.2 解码函数:反向还原坐标和误差范围
解码是编码的逆过程。先把字符串按字符表还原成 5 bit 一组,然后仍然按“经度、纬度交替”的顺序,一步步把区间缩小。最后取区间的中心点作为还原坐标。
def geohash_decode(code): chars = "0123456789bcdefghjkmnpqrstuvwxyz" base32_map = {c: i for i, c in enumerate(chars)} bits = [] for c in code: value = base32_map[c] for i in range(4, -1, -1): bits.append((value >> i) & 1) lat_range = [-90.0, 90.0] lng_range = [-180.0, 180.0] even = True for bit in bits: if even: mid = (lng_range[0] + lng_range[1]) / 2 if bit: lng_range[0] = mid else: lng_range[1] = mid else: mid = (lat_range[0] + lat_range[1]) / 2 if bit: lat_range[0] = mid else: lat_range[1] = mid even = not even lat = (lat_range[0] + lat_range[1]) / 2 lng = (lng_range[0] + lng_range[1]) / 2 lat_span = lat_range[1] - lat_range[0] lng_span = lng_range[1] - lng_range[0] return lat, lng, lat_span, lng_span我建议解码函数顺手把lat_span和lng_span也算出来。这两个值是编码后格子的大小,后面求邻居、判断查询误差都用得上。比如解码wtq535u会返回一个经纬度,以及大约0.0014 x 0.0014度的跨度,这就是当前 7 位字符串所代表的矩形范围。
要注意的是,geohash 解码出来的坐标并不是输入坐标本身,而是某个矩形格子的中心。文档或缓存里如果写的是“该点坐标”,那只能用于近似定位,不能替代原始精确坐标。
3.3 性能优化:用整数位运算一次成型
上面的实现用来学习很好,但要是让你给几百万个点批量转 geohash,用 list 一位一位攒会有点浪费。实际上我们可以把整个 bit 流当成一个整数来构建,每产生一个 bit 就左移一位并或上 0/1。转字符串时,按精度分 5 bit 一组,从高到低截取。
def geohash_encode_fast(lat, lng, precision=9): lat_range = [-90.0, 90.0] lng_range = [-180.0, 180.0] value = 0 even = True for _ in range(precision * 5): if even: mid = (lng_range[0] + lng_range[1]) / 2 if lng >= mid: value = (value << 1) | 1 lng_range[0] = mid else: value = value << 1 lng_range[1] = mid else: mid = (lat_range[0] + lat_range[1]) / 2 if lat >= mid: value = (value << 1) | 1 lat_range[0] = mid else: value = value << 1 lat_range[1] = mid even = not even chars = "0123456789bcdefghjkmnpqrstuvwxyz" result = [] for i in range(precision): shift = (precision - 1 - i) * 5 index = (value >> shift) & 0x1F result.append(chars[index]) return ''.join(result)这个版本在 Python 里跑起来也未必比 list 版本快多少,真正的好处是中间状态是一个整数,可以序列化、可以做位运算、可以存库,尤其后面如果要跟 Redis 位图或者自定义索引结构配合时会更顺手。至于跨语言实现,其他语言照着这套逻辑写就行,核心就两个点:交替的 even 标志和整数移位。
4. 精度对照、边界问题和邻居查询
4.1 长度与格子尺寸的对照表
geohash 长度越长,精度越高。下面这张表是各长度对应的经度跨度和纬度跨度,以及赤道附近的物理距离。所谓“赤道附近”很关键,后面会专门解释为什么不是所有纬度都一样。
| geohash长度 | 经度跨度 | 纬度跨度 | 赤道附近约对应距离 | 适用场景参考 |
|---|---|---|---|---|
| 1 | 22.5° | 22.5° | 约 2500km x 2500km | 全球或大洲 |
| 2 | 11.25° | 5.625° | 约 1250km x 625km | 国家级 |
| 3 | 1.40625° | 1.40625° | 约 156km x 156km | 城市级 |
| 4 | 0.3515625° | 0.17578125° | 约 39km x 19.5km | 区县级 |
| 5 | 0.0439° | 0.0439° | 约 4.9km x 4.9km | 街道/商圈级 |
| 6 | 0.010986° | 0.005493° | 约 1.2km x 0.61km | 园区级 |
| 7 | 0.001372° | 0.001372° | 约 153m x 153m | 门店级 |
| 8 | 0.000343° | 0.000171° | 约 38m x 19m | 楼宇级 |
| 9 | 0.0000429° | 0.0000429° | 约 4.8m x 4.8m | 点位级 |
| 10 | 0.0000107° | 0.00000536° | 约 1.19m x 0.6m | 厘米级定位后处理 |
设计 query 的时候,先想清楚业务半径。比如要找“附近 1 公里”的店铺,那 6 位 about 1.2km x 0.61km 的格子也算半个可用粒度,但更稳妥的是直接选 5 位,因为格子大、召回全,然后拿到结果再用真实经纬度算距离过滤。反过来,如果你只在数据库里存了 9 位 geohash,却要查 5 公里范围,那就麻烦了,因为 9 位格子太小,你根本不知道该用哪个前缀去匹配。
4.2 为什么会有“纬度越高格子越歪”的现象
这张表里写的“约对应距离”全是赤道场景。真实世界中,经度方向上一度的物理距离是111km * cos(纬度),纬度方向上一度大概恒定为 111km。所以在高纬度地区,同样跨度的经度在物理上会明显变窄,geohash 格子会从“近似正方形”逐渐变成“竖长条”。
举个例子,6 位 geohash 在赤道附近大约是 1.2km x 0.61km,但如果业务集中在纬度 60 度的地方,经度方向实际只有1.2km * cos(60°)=0.6km,格子就变成了 0.6km x 0.61km,方向和形状都不一样了。做跨纬度较大的全国型业务时,不能用一张固定精度表一刀切,最好按“目标纬度先估算实际距离,再决定 geohash 位数”。
我见过一个具体问题:某系统在南方城市用 7 位 geohash 做“500 米附近”查询效果很好,直接复制到北方高纬度城市后,莫名其妙漏了很多点。原因是 7 位格子的经度方向物理跨度变小,两个点虽然直线距离 200 米,但横向一偏移就跑出了同一个格子,前缀匹配就失效了。解决方案不是换更长的 geohash,而是把“查单格”改成“查中心格子加周围 8 个邻居”,这样就绕开了所有向量误差。
4.3 边界查询的经典漏数据问题
这是 geohash 最经典的坑。假设目标点 A 在格子 X 的最东边,目标点 B 在格子 Y 的最西边,两个点可能只相距 20 米,但 X 和 Y 是相邻格子。如果你想查 A 点附近的人,只搜 A 点自己的 geohash 前缀,B 点所在的格子 Y 根本不会被命中。更极端的情况是,两个点很近却落在不同的大分区边界,它们的 geohash 字符串可能从第 1 个字符就不一样,完全没有公共前缀。
很多人用“把精度提高”来规避这个问题,比如从 5 位升到 7 位,认为格子变小了就不会漏。其实没用,只要查询点附近的目标点在相邻格子里,漏数据概率依然存在,只是漏的物理范围变小了。正确做法是:查询时取目标点所在格子以及周围 8 个邻居格子,共 9 个 geohash 前缀一起匹配,拿到候选集之后再按实际经纬度距离精确过滤。这样既不丢数据,又不会因为格子过大引入太多远距离噪声。
4.4 用“解码—偏移—再编码”求9宫格
求邻居格子有一堆算法,但不一定都要用高大上的位运算版。我在工程里更常用一个“笨但稳”的办法:把当前 geohash 解码,得到中心坐标和跨度;然后在中心点基础上叠加(-幅度, 不偏移)等九种组合;偏移后的坐标重新编码成同长度 geohash。九个结果就是九宫格。
def geohash_neighbors(code): lat, lng, lat_span, lng_span = geohash_decode(code) offsets = [ (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 0), (0, 1), (1, -1), (1, 0), (1, 1), ] neighbors = [] for dlat_off, dlng_off in offsets: neighbor_lat = min(90.0, max(-90.0, lat + dlat_off * lat_span)) neighbor_lng = lng + dlng_off * lng_span if neighbor_lng > 180.0: neighbor_lng -= 360.0 elif neighbor_lng < -180.0: neighbor_lng += 360.0 neighbors.append(geohash_encode(neighbor_lat, neighbor_lng, len(code))) return neighbors这段代码有三个小细节。第一,纬度超过 90 或低于 -90 时要夹紧,否则会编码出无效坐标。第二,经度跨越 180 度线时要做 ±360 度回绕,否则偏移后的点可能跑到地球另一侧。第三,这里用的是“数值跨度”而不是“物理距离跨度”,因为 geohash 编码本身就是在数值经纬度上做二分,所以直接用解码出来的数值跨度作为步长是准的。这套求邻居法对任意长度都适用,也不存在特殊边界手写坐标表的情况,是我最推荐的工程做法。
有了九宫格,查询就变成简单三步:先算出目标点的九宫格 geohash 列表;用这个列表做前缀匹配或 IN 条件查库;最后用 Vincenty 或 Haversine 公式计算距离,过滤掉超出业务半径的记录。
5. 实际业务里的正确打开方式
5.1 区域检索与聚合,先把召回做对再谈精确
我在实际项目里使用 geohash 最多的是两类需求:区域召回和区域聚合。区域召回就是上面说的九宫格加距离过滤;区域聚合则是把一个大范围里的 POI 按 geohash 前缀分组,统计每个格子的数量,用来画热力图或做密度分析。
做热力图时,格子大小直接决定视觉效果。5 位太粗,整个城市都是几个大色块;9 位太细,每个格子两三个点,聚合意义不大。我一般先用 7 位聚合,如果格子太多导致前端渲染压力大,再升级到 6 位或 5 位。这种“先跑一版看效果,再调位数”的思路,比一上来就纠结理论精度要高效得多。聚合脚本里,字符串的前 N 位做group by非常简单,这也是 geohash 相对其他索引方案最大的优势。
5.2 数据分布、缓存设计与轨迹压缩
除了检索,geohash 在数据分发上也有不少妙用。比如分布式缓存,可以按 geohash 前缀做分片,某个片对应某个地理区域,这样同一片区域的请求能落到同一台机器,缓存命中率会明显提高。
再比如轨迹压缩。一条车辆轨迹可能是几十万个经纬度点,如果车辆绝大部分时间在一个网格内慢速移动,连续几十个点会落在同一个 7 位 geohash 里。存储端可以只记录“轨迹点序列对应的 geohash 变化事件”,不变的连续段直接合并;等到还原时再按格子中心与时间戳做插值。这样存储量能降一个数量级,很多轻量级车辆管理平台就是这么干的。
还有一类常见用法是生成稳定 ID。比如多个分库分表服务各自写入事件,可以用“业务类型 + geohash + 时间戳”做数据分片键,保证同一地理位置的事件数据在物理上相邻,方便后续做地理维度的分析查询。
5.3 坐标顺序、精度选型这些坑,踩一次就长记性
第一坑是坐标顺序。有的库 API 是encode(lat, lng),有的则是(lng, lat),前端地图 SDK 和后端接口也经常不一致。最靠谱的做法是:在系统内部固定一个带字段名的结构,比如Point(lat, lng),并且所有外部接口都显式标注参数名,绝不裸传两个陌生数字。我一度因为某接口文档写反,导致一批坐标全部漂移到海里,排查了两小时,最终发现只是顺序问题。
第二坑是精度选型。不要习惯性写 9 位。9 位确实精度高,但对前端展示和业务聚合都不友好,而且 9 位字符串的基数大,建索引占的空间也大。你要先想清楚业务半径。找“附近的人”,半径 3 公里,用 5 位召回再过滤;展示某一栋楼的 POI,7 位或 8 位才合适。选位参考:5 位适合公里级、7 位适合百米级、9 位适合点位级。
第三坑是“前缀等值”不等于“距离近”。两个 geohash 前缀相同,说明它们在同一格子,但格子的两个边缘点可能相距接近对角线长度。反过来,两个点前缀只有一位相同,也可能距离很近。前缀只是召回工具,不是距离计算器,真正的距离判断永远要回到经纬度上来。
第四坑是存储字段类型。如果数据库支持二进制类型,可以考虑把 geohash 直接存成字符串同时加前缀索引,因为普通字符串可能被排序规则干扰。在 MySQL 里对 geohash 字段建立前缀索引并配合LIKE 'wtq%'是可行的,但要注意字符集和排序规则,建议用utf8mb4_bin这类二进制排序,避免字典序和 geohash 需要的顺序不一致。
5.4 和四叉树、H3等其他空间索引怎么选
geohash 并不是唯一选择。如果是动态频繁增删的空间数据,并且你完全掌控自己的存储层,四叉树或 R 树可能更适合;如果你需要全球均匀的正六边形网格,Uber H3 也是很好的方案。H3 的格子更均匀、层级更灵活,适合蜂窝可视化、流量分析等场景,但它的字符串没有 geohash 这种“前缀即父子区域”的特性,很多传统数据库也没内置 H3 索引。
从落地成本来说,geohash 依然是通用性最强的。你不需要装任何插件,一个字符串就能搞定跨语言、跨存储的地理索引。H3 在计算上更巧妙,四叉树在局部更新上更灵活,但 geohash 在大规模批处理、跟传统关系库结合、以及各种简单地理聚合场景下,几乎是零门槛方案。
我在真实项目里最终选择 geohash 作为基础层,然后在服务端额外引入了一层内存距离过滤。这种组合简单可靠,即便将来数据量翻几十倍,只要把 geohash 换成 H3 或者引入更多索引,业务逻辑也不需要推翻重来。
6. 一点个人体会
自己动手实现完编码和解码之后,我对那些封装好的库不再有“黑盒恐惧”。文中这套实现已经足够覆盖日常需求,但真正决定项目成败的从来不是代码本身,而是位数选型、边界处理和查询链路设计。
这里有个很值得记住的技巧:定位一个项目时,先用一个测试点跑通“编码—解码—求邻居—查库—距离过滤”的完整闭环,并把每个步骤的结果打印出来。我每次换语言、换数据库,都会先做这个最小闭环验证,确认坐标顺序没有转错、精度选型符合业务半径,再继续堆业务代码。很多经验不是看文档看出来的,而是这样一步步跑出来的。
以后你要是有机会遇到“一个字符串如何索引整片区域”这类需求,可以优先想想 geohash。配合九宫格和距离校正,它能在大多数业务场景里交出让人满意的答卷。