日结零工"附近有活"怎么搜的:xllg 用 GeoHash + 9 宫格把周边工单检索干到 30ms
零工 App 打开第一屏就是"附近有活",这个接口要是慢,用户基本就流失了。xllg 早期直接用 MySQL 算距离:WHERE (lat-?)*(lat-?) + (lng-?)*(lng-?) < r*r,工单量到了几十万条,一次全表扫描,接口 500ms 起步。后来改成 GeoHash 前缀索引 + 9 宫格扩展,P95 稳定在 30ms 上下,这篇讲讲怎么落地。
表结构:把 GeoHash 前缀存进去,走索引
工单表加一列geo_hash,写入时按精度 7(约 150m 格子)算好存进去:
CREATETABLEwork_order(idBIGINTPRIMARYKEYAUTO_INCREMENT,titleVARCHAR(64)NOTNULLCOMMENT'工单标题',latDECIMAL(10,6)NOTNULLCOMMENT'纬度',lngDECIMAL(10,6)NOTNULLCOMMENT'经度',geo_hashVARCHAR(12)NOTNULLCOMMENT'geohash 前缀,精度7',statusTINYINTNOTNULLDEFAULT0,create_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,KEYidx_geo_status(geo_hash,status),KEYidx_create_time(create_time))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COMMENT='零工工单表';前缀索引idx_geo_status是这套方案的核心:按格子前缀过滤,走 B+ 树,不用算距离就已经把数据量砍到只剩附近几个格子。
Java 端:算格子、取邻格、拼前缀
GeoHash 计算用现成库(ch.hsr:geohash),核心是"取当前格子 + 8 个邻格"拼成前缀列表:
publicListnearbyPrefixes(doublelat,doublelng,intprecision,intkm){GeoHashcenter=GeoHash.withCharacterPrecision(lat,lng,precision);// 按公里数换算格子半径,决定取几圈邻格doublecellKm=cellSizeKm(precision);// 精度7约0.15kmintring=(int)Math.ceil(km/cellKm);// 3km 半径 → 取一圈就够Listprefixes=newArrayList<>();for(inti=-ring;i<=ring;i++){for(intj=-ring;j<=ring;j++){GeoHashneighbor=center.getNeighbour(i,j);prefixes.add(neighbor.toBase32().substring(0,precision));}}returnprefixes;}这里有个细节:geohash 的"邻格"不是简单的经纬度加减,必须用getNeighbour(i, j),否则格子边界会算错。
SQL:前缀 IN 查询 + 精确距离过滤
格子过滤完,还剩格子边缘的"伪邻居"(同一个格子但距离 2km 的角落),SQL 里再算一次真实距离兜底:
SELECTid,title,lat,lng,ROUND(6371*ACOS(COS(RADIANS(?))*COS(RADIANS(lat))*COS(RADIANS(lng)-RADIANS(?))+SIN(RADIANS(?))*SIN(RADIANS(lat))),2)ASdistance_kmFROMwork_orderWHEREgeo_hashIN('w1x2y3z','w1x2y4e',...)-- 9 宫格前缀ANDstatus=1HAVINGdistance_km<=3ORDERBYdistance_kmLIMIT20;geo_hash IN (...)直接命中idx_geo_status索引,状态过滤也在索引里,距离计算只发生在几十条候选上,开销可以忽略。
踩坑:边界工单搜不到,排查了俩小时
现象:测试同事在两家公司中间发了个工单,定位点在格子边界上,附近的人搜"3km 内有活"怎么都搜不到这条。
排查:先看 SQL 单独跑,WHERE geo_hash = 'w1x2y3z'能查到;再看 9 宫格集合,发现边界点的getNeighbour结果集里没有包含它自己的格子。
定位:问题出在我自己拼前缀的逻辑——我只取了"以目标点为中心的正北正南正东正西 8 格",但 geohash 格子是经纬度交替二分出来的,斜对角方向的格子编号和中心格不一定相邻,简单取 8 格会漏掉斜向格子,而边界工单恰好落在斜向格。
解决:把 ring 循环改成完整矩形取格(上面代码里i,j ∈ [-ring, ring]全取),再把精度从 6 提到 7,格子变小后边界穿透概率大幅下降。上线后随机抽样 200 个边界点自测,全部命中。
前缀算好了就别每次现算:Caffeine 缓存 9 宫格
nearbyPrefixes这个方法里getNeighbour循环看着轻,实际压测发现它占了接口 15% 的 CPU——因为每个请求都在做字符串拼接和格子计算。xllg 后来把"城市 + 中心点 + 半径"当成 key,结果缓存起来:
privatefinalCache>prefixCache=Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(5,TimeUnit.MINUTES).build();publicListcachedPrefixes(doublelat,doublelng,intprecision,intkm){Stringkey=String.format("%.4f:%.4f:%d:%d",lat,lng,precision,km);returnprefixCache.get(key,k->nearbyPrefixes(lat,lng,precision,km));}注意 key 里的经纬度只保留 4 位小数,相邻请求落同一个格子,缓存命中率能到 90% 以上。加了这层,接口 CPU 直接降了 10 个点。
写入侧也别闲着:geohash 算错一格,整个格子都废
工单创建时 geohash 必须和坐标同步算好,两边逻辑不一致就会出现"表里 geo_hash 和 lat/lng 对不上"的脏数据。xllg 的做法是把计算收敛到一个工具类里,写入和检索共用同一份实现,避免两处逻辑漂移:
publicvoidcreateOrder(WorkOrderCreateDTOdto){Stringhash=GeoHashUtil.encode(dto.getLat(),dto.getLng(),7);WorkOrderorder=newWorkOrder();order.setLat(dto.getLat());order.setLng(dto.getLng());order.setGeoHash(hash);// 和检索端同一个 encode 方法workOrderMapper.insert(order);}写入端和检索端必须共用同一个 geohash 实现,一个用精度 7、另一个用精度 6,检索立刻漏数据。老数据要补的话,写个一次性任务把geo_hash IS NULL的行按 lat/lng 重算填上,别手工 UPDATE。
冷热数据分开:老工单就别占 LBS 索引了
工单是有生命周期的:进行中的单才需要被"附近"搜到,完成超过 3 天的单子基本没人搜。全部堆在一个表里,geo_hash索引越来越大,插入和查询都变慢。xllg 的处理是状态过滤 + 定期归档:
-- 检索侧只关心进行中的单WHEREgeo_hashIN(...)ANDstatus=1-- 进行中ANDend_timeISNULL-- 未结单-- 每天凌晨把完成超过 3 天的单归档到 history 表INSERTINTOwork_order_history(id,title,lat,lng,geo_hash,status,create_time)SELECTid,title,lat,lng,geo_hash,status,create_timeFROMwork_orderWHEREstatus=2ANDupdate_time<DATE_SUB(NOW(),INTERVAL3DAY);DELETEFROMwork_orderWHEREstatus=2ANDupdate_time<DATE_SUB(NOW(),INTERVAL3DAY);归档之后主表稳定在"当前活跃单"的量级,索引体积、插入性能、查询缓存命中率一起改善。别小看这一步,工单量过百万后这招比调索引参数管用得多。
可直接复用的清单
- 表加
geo_hash前缀列 +(geo_hash, status)联合索引,写入时按精度 7 存。 - 邻格必须用
getNeighbour(i,j)全矩形取格,别手写"上下左右"漏斜角。 - 格子过滤后保留真实距离 HAVING 兜底,防同格异距的伪邻居。
- 半径和精度换算:精度 7 约 0.15km/格,3km 取一圈 9 格够用,10km 就得两圈 25 格。
- 写入端和检索端共用同一个 geohash 实现,精度不一致必漏数据。
- 工单量百万级以内这套够用,再往上就该上 ES 的 geo 查询了,别硬撑。
项目源码:https://gitee.com/gzqkl/xllg