从经纬度到Geohash:原理、Python实现与边界处理实战 前阵子接了个需求给一批门店数据加位置检索能力。数据量不算大一万多个点但要按区域批量导出还要支持“某点附近有哪些店”这种查询。我第一反应是“这不就是按经纬度范围过滤一下吗”真动手才发现直接拿经纬度做范围查询非常尴尬——要么全表扫描慢得没法看要么索引策略绕来绕去。后来我把每个点都转成geohash字符串区域导出和附近查询都变得干净利落。今天就把经纬度转geohash这件事彻底聊透从原理到手写实现、精度选型、边界处理再到实际业务里的坑一次讲完。这篇文章适合后端开发、数据分析、以及任何要和坐标打交道的同学。读完你能自己实现编码解码、知道该选几位geohash、能处理边界漏数据的经典问题还会明白为什么geohash在分库分表、缓存设计里那么好用。1. geohash是什么以及它解决的真实痛点1.1 一段让我改换思路的需求经历最早我想得很简单门店表里有lat和lng两个字段按经纬度范围导出SQL 里写WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?不就行了。可当区域范围一变比如画个五公里圆你就得先算经纬度上下界然后在这个矩形里捞数据。听着也不算复杂但问题在于一万个点还能靠全表扫描扛住数据量到百万、千万级这种写法要么索引失效要么EXPLAIN出来typeALL一次查询拖垮一个接口。我也试过给经纬度各建一个普通索引但二维范围条件对单列索引来说很难高效命中。传统 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 社区常用的字符表是这样的数值字符数值字符数值字符数值字符008816h24s119917j25t2210b18k26u3311c19m27v4412d20n28w5513e21p29x6614f22q30y7715g23r31z注意里面没有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, precision7): # 初始区间 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)拿一个测试点试一下坐标取lat30.123456, lng120.987654跑precision7得到的结果是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, precision9): 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长度经度跨度纬度跨度赤道附近约对应距离适用场景参考122.5°22.5°约 2500km x 2500km全球或大洲211.25°5.625°约 1250km x 625km国家级31.40625°1.40625°约 156km x 156km城市级40.3515625°0.17578125°约 39km x 19.5km区县级50.0439°0.0439°约 4.9km x 4.9km街道/商圈级60.010986°0.005493°约 1.2km x 0.61km园区级70.001372°0.001372°约 153m x 153m门店级80.000343°0.000171°约 38m x 19m楼宇级90.0000429°0.0000429°约 4.8m x 4.8m点位级100.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 位召回再过滤展示某一栋楼的 POI7 位或 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。配合九宫格和距离校正它能在大多数业务场景里交出让人满意的答卷。