1. 从“附近的门店”这个需求说起
“附近的门店”这五个字,看起来简单,真动手做过的同行都清楚,它背后牵扯的东西一点都不少。我第一次接到这个需求的时候,脑子里第一反应是“不就是查个距离排个序吗”,结果真正落地的时候,从坐标系的选型、距离算法的取舍、分页与排序的冲突,到缓存策略、索引设计、边界场景处理,前前后后踩了不下十个坑。这篇文章就把我这些年做LBS(基于位置的服务)门店查询功能积累下来的经验,完整地梳理一遍,用Java技术栈从头到尾讲清楚一个可上线的“附近的门店”功能该怎么设计、怎么实现、怎么避坑。
先说清楚这个功能到底要解决什么问题。用户打开应用,授权定位之后,系统需要根据用户当前的经纬度,找出一定范围内(比如3公里、5公里)的门店,并且按照距离从近到远排序返回。听起来就这一句话,但实际业务里往往还会叠加更多条件:门店可能有关闭状态、营业时间、品类筛选、是否有库存、是否支持配送等等。所以这个功能本质上是一个“带空间约束的多条件排序查询”,而不是单纯的“算个距离”。
适合谁来参考这篇内容?我把它定位成三类人:第一类是刚接触LBS功能的Java后端开发者,需要一套能直接落地的方案;第二类是有一定经验但没系统做过地理空间查询的工程师,想搞清楚各种方案背后的取舍逻辑;第三类是做技术方案评审或者架构设计的同行,需要一份能对比不同实现路径的参考。不管你是哪一类,我都会尽量把“为什么这么做”讲透,而不是只丢一段代码让你抄。
在正式开始之前,先明确一个前提:本文所有方案都基于通用的Java技术生态,涉及数据库时以MySQL为主,缓存以Redis为主,不绑定任何特定云厂商的专有服务。这样你不管在什么环境下都能复现。
2. 整体方案设计与技术选型思路
2.1 为什么不能直接用数据库的三角函数算距离
很多人第一反应是写一条SQL,用经纬度通过球面余弦定理或者Haversine公式算出距离,然后ORDER BY distance。这个思路在小数据量下没问题,但门店数量一旦上千、用户并发一上来,问题就暴露了。
原因很直接:基于函数的距离计算无法走索引。你写的WHERE条件里如果包含了对经纬度列的数学运算,数据库只能全表扫描,每一行都要算一次三角函数。门店表如果有几万条数据,每次查询都全表扫描加排序,响应时间轻松上到几百毫秒甚至秒级。更麻烦的是,这种查询的CPU开销很大,三角函数计算是浮点运算,并发一高数据库CPU直接飙满。
所以核心思路必须是:先用一个能走索引的矩形范围把候选集缩小,再在候选集里精确计算距离并排序。这就是“先粗筛、再精算”的两阶段策略,也是所有成熟LBS方案的基本盘。
2.2 矩形边界框粗筛的原理
假设用户当前位置是(lat, lng),要查半径r公里内的门店。我们可以先算出一个能包住这个圆的外接矩形,用矩形的四条边作为WHERE条件去查数据库。因为条件是lat BETWEEN ... AND ...和lng BETWEEN ... AND ...这种范围比较,只要在经纬度上建了索引,就能快速缩小范围。
纬度的换算比较简单,地球子午线一圈约40000公里,对应360度,所以1度纬度大约111公里。经度就复杂一些,因为经线在不同纬度上间距不同,赤道处1度经度约111公里,越往两极越短,需要乘以纬度的余弦值。
具体公式是这样的:
- 纬度范围:
lat ± (r / 111.0) - 经度范围:
lng ± (r / (111.0 * cos(lat * π / 180)))
这里有个坑要注意:当用户位置非常接近两极时,cos(lat)趋近于0,经度范围会趋向无穷大,公式会失效。不过实际业务中门店基本不会开在极地,一般做个边界保护就行,比如纬度超过85度就按85度处理。
粗筛出来的矩形范围会比实际圆形范围大,四个角上的门店距离可能超过r,所以第二阶段必须精确计算距离再过滤一次。这个“多查一点再筛掉”的代价是可以接受的,换来的是索引的有效利用。
2.3 距离算法的选择:Haversine还是Vincenty
精确计算两点间距离,常用的有两个公式。Haversine公式把地球当成正球体,计算简单,误差在0.5%左右,对于门店查询这种场景完全够用。Vincenty公式把地球当成椭球体,精度更高,但计算量大得多,而且有迭代不收敛的边界情况。
我的建议是:门店查询用Haversine就够了。你算出来的距离差个几十米,对用户来说根本没有感知,但Vincenty带来的额外CPU开销在高并发下是实打实的成本。除非你做的是测绘级别的应用,否则没必要上Vincenty。
Haversine的Java实现大概长这样:
public class GeoUtils { private static final double EARTH_RADIUS = 6371.0; // 公里 public static double haversine(double lat1, double lng1, double lat2, double lng2) { double dLat = Math.toRadians(lat2 - lat1); double dLng = Math.toRadians(lng2 - lng1); double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return EARTH_RADIUS * c; } }这段代码里EARTH_RADIUS取6371公里是地球平均半径,用这个值算出来的距离和实际导航距离会有偏差,但作为“直线距离”展示是合理的。如果你想让结果更贴近用户认知,可以在返回时标注“直线距离”,避免和导航距离混淆引发投诉。
2.4 数据库选型:MySQL够不够用
这是被问得最多的问题。我的答案是:门店数量在十万级以内、并发在每秒几百次以内,MySQL完全够用。配合合理的索引和缓存,性能可以做到毫秒级。
MySQL 5.7之后原生支持了空间数据类型和ST_Distance_Sphere函数,可以直接算球面距离。但我不建议在查询里直接用这个函数做排序,原因还是那个——函数计算走不了索引。正确用法是先用矩形范围粗筛,再对候选集用ST_Distance_Sphere精算。
如果门店数量到了百万级,或者并发到了每秒几千次,那就该考虑专门的方案了。可选的有Elasticsearch的地理空间查询、Redis的GEO数据结构,或者PostGIS。但这些方案的引入成本都不低,我的建议是先用MySQL把功能跑通,等真有性能瓶颈了再迁移,不要一上来就过度设计。
2.5 缓存策略的取舍
门店的位置信息是典型的“读多写少”数据,非常适合缓存。但缓存什么、怎么缓存,有讲究。
一种做法是缓存查询结果,key用“用户网格坐标+半径”来拼。但用户位置是连续变化的,网格划分太细会导致缓存命中率低,太粗又会导致结果不准确。我试过几种网格粒度,最后发现按500米划分网格、半径固定几个档位(1km、3km、5km)的组合,命中率比较理想。
另一种做法是缓存门店基础数据,把门店列表整个加载到本地缓存或者Redis里,距离计算在应用层做。这种方案适合门店数量不多(比如几千家)且更新不频繁的场景,好处是查询完全不依赖数据库,响应极快。缺点是门店数据更新时缓存同步有延迟,需要设计好失效机制。
我实际项目里用的是混合方案:门店基础信息放Redis,查询时先从Redis拿全量或按城市分片的数据,在应用层做粗筛和精算。数据库只作为持久化存储和兜底。这样即使Redis挂了,降级到数据库查询也能保证功能可用。
3. 核心细节解析与实操要点
3.1 数据库表结构设计的关键字段
门店表的设计直接决定了查询性能。核心字段至少要有这几个:
CREATE TABLE `store` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(128) NOT NULL COMMENT '门店名称', `latitude` DECIMAL(10, 7) NOT NULL COMMENT '纬度', `longitude` DECIMAL(10, 7) NOT NULL COMMENT '经度', `city_code` VARCHAR(16) NOT NULL COMMENT '城市编码', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1营业 0关闭', `address` VARCHAR(256) DEFAULT NULL COMMENT '详细地址', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_lat_lng` (`latitude`, `longitude`), KEY `idx_city_status` (`city_code`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个关键点解释一下。经纬度用DECIMAL(10,7)而不是FLOAT或DOUBLE,是因为浮点数在范围查询时可能有精度问题,DECIMAL能保证比较的准确性。小数点后7位大约对应1厘米的精度,对门店定位来说绰绰有余。
索引idx_lat_lng是联合索引,纬度在前经度在后。为什么这么排?因为矩形粗筛时纬度的范围通常比经度小(尤其在高纬度地区),把选择性更强的列放前面能让索引更高效。不过这个顺序不是绝对的,实际要看数据分布,你可以用EXPLAIN验证一下。
city_code字段很重要。如果业务覆盖多个城市,查询时先按城市过滤能大幅缩小范围。用户定位后先反查城市,再在城市内做附近查询,比全国范围查要快得多。
3.2 矩形粗筛的SQL写法与索引命中
粗筛的SQL大概是这样:
SELECT id, name, latitude, longitude, address FROM store WHERE status = 1 AND city_code = ? AND latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?这里有个细节:BETWEEN的范围查询在联合索引上,只能用到第一列的范围,第二列是用不上索引的。也就是说idx_lat_lng这个索引,在latitude BETWEEN之后,longitude的条件只能作为索引条件下推(ICP)在存储引擎层过滤,不能直接定位。不过即便如此,也比全表扫描快得多。
如果你想让经纬度都用上索引,可以考虑用空间索引(SPATIAL INDEX)配合MBRContains函数。但空间索引在MySQL里的使用有一些限制,比如必须是MyISAM或者InnoDB(5.7+),而且查询写法不同。我实测下来,对于门店这种数据量,普通联合索引加矩形粗筛已经够用,空间索引的收益不明显,反而增加了维护复杂度。
注意:
BETWEEN的边界值要算准。经度范围计算时如果cos(lat)很小,算出来的范围可能超过180度,这时候要直接取-180到180,否则SQL条件会出错。
3.3 应用层精算与排序的实现
粗筛拿到候选集之后,在Java里做精算和排序。这里要注意几个点。
第一,距离计算要放在内存里做,不要在SQL里做。候选集通常只有几十到几百条,内存计算完全没压力。如果在SQL里用ST_Distance_Sphere排序,数据库要额外做一次排序操作,反而慢。
第二,排序要稳定。距离相同的门店,应该有固定的排序规则,比如按门店ID升序,避免每次查询结果顺序不一致导致前端展示跳动。
第三,分页要在精算排序之后做。这是个容易踩的坑:如果你在SQL里用LIMIT分页,粗筛出来的矩形范围里的门店顺序是随机的,分页结果会错乱。正确做法是把候选集全部拿到内存,精算排序后再做内存分页。候选集不大,内存分页完全可行。
public List<StoreVO> findNearby(double lat, double lng, double radiusKm, int page, int pageSize) { // 1. 计算矩形边界 double latDelta = radiusKm / 111.0; double lngDelta = radiusKm / (111.0 * Math.cos(Math.toRadians(lat))); double minLat = lat - latDelta; double maxLat = lat + latDelta; double minLng = lng - lngDelta; double maxLng = lng + lngDelta; // 2. 粗筛 List<Store> candidates = storeMapper.selectByBounds(minLat, maxLat, minLng, maxLng); // 3. 精算距离并过滤 List<StoreVO> result = new ArrayList<>(); for (Store store : candidates) { double distance = GeoUtils.haversine(lat, lng, store.getLatitude(), store.getLongitude()); if (distance <= radiusKm) { StoreVO vo = convert(store); vo.setDistance(distance); result.add(vo); } } // 4. 排序 result.sort(Comparator.comparingDouble(StoreVO::getDistance) .thenComparing(StoreVO::getId)); // 5. 内存分页 int fromIndex = (page - 1) * pageSize; if (fromIndex >= result.size()) { return Collections.emptyList(); } int toIndex = Math.min(fromIndex + pageSize, result.size()); return result.subList(fromIndex, toIndex); }这段代码是核心逻辑的骨架,实际项目里还要加上缓存读取、异常处理、日志埋点等。但主干就是这个“粗筛-精算-排序-分页”四步。
3.4 坐标系问题:GCJ-02与WGS-84的坑
这是国内做LBS绕不开的问题。GPS设备拿到的是WGS-84坐标,但国内地图服务商展示用的是GCJ-02坐标(俗称火星坐标)。如果你的门店坐标存的是GCJ-02,而用户定位拿到的是WGS-84,直接算距离会有几百米的偏差。
解决方案有两种:一是统一坐标系,门店录入时就转成和用户定位一致的坐标系;二是在计算距离前做坐标转换。我推荐第一种,在数据入库时就统一,避免每次查询都转换。
坐标转换的算法是公开的,网上能搜到,核心就是一组三角函数加偏移量。但要注意,转换是有精度损失的,而且不同地图服务商的偏移算法可能有细微差异。最稳妥的做法是:门店坐标用哪个地图服务商采集的,就用哪个服务商的坐标系,用户定位也走同一个服务商,保证一致。
提示:如果门店数据来自多个渠道,坐标系可能不统一,入库前一定要做校验和转换。我见过因为坐标系混用导致“附近门店”推荐到几公里外的案例,用户投诉很严重。
3.5 边界场景的处理清单
实际业务里有一堆边界情况要处理,我整理了一个清单:
- 用户未授权定位:降级到按城市或手动选择的位置查询,不能直接报错。
- 定位精度差:用户定位可能偏差几百米,可以在返回结果时适当扩大搜索半径,比如用户要3公里,实际查4公里再筛。
- 门店坐标缺失或为0:入库时要校验,查询时要过滤掉经纬度为0的脏数据。
- 跨城市边界:用户在两个城市交界处,只查当前城市可能漏掉隔壁城市的门店。解决方案是查当前城市加周边城市,或者干脆不按城市过滤,直接用矩形范围查。
- 半径过大:用户传了个100公里的半径,矩形范围会覆盖大半个省,查询会很慢。要限制最大半径,比如不超过10公里。
- 并发重复查询:同一用户频繁刷新,可以用本地缓存或者请求合并来减少数据库压力。
4. 完整实操流程与核心环节实现
4.1 环境准备与依赖配置
先说一下我用的技术栈版本,方便你对照:JDK 8或11(17也行,但有些老项目还在8上),Spring Boot 2.7.x,MyBatis 3.5.x,MySQL 5.7或8.0,Redis 6.x。这些版本都是经过大量项目验证的,稳定性没问题。
Maven依赖核心就这几个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>不需要引入额外的地理空间库,Haversine公式自己写就行,几十行代码的事。有些教程会推荐用GeoTools或者JTS,但那些库太重了,为了算个距离引入一堆依赖不划算。
4.2 门店数据入库与坐标校验
门店数据入库时,除了基本的字段校验,坐标校验特别重要。我一般会做这几项检查:
第一,经纬度是否在合法范围内。纬度-90到90,经度-180到180,超出范围的直接拒绝。
第二,经纬度是否明显异常。比如纬度是0经度是0,这大概率是数据缺失导致的默认值,要标记出来人工确认。
第三,同一门店的坐标是否和地址匹配。这个比较难自动校验,但可以做一个粗略的城市级校验:根据坐标反查城市,和门店填写的城市对比,不一致的标记出来。
入库的Service层代码大概这样:
public void saveStore(StoreSaveDTO dto) { if (dto.getLatitude() == null || dto.getLongitude() == null) { throw new BizException("门店坐标不能为空"); } if (dto.getLatitude() < -90 || dto.getLatitude() > 90 || dto.getLongitude() < -180 || dto.getLongitude() > 180) { throw new BizException("门店坐标超出合法范围"); } if (Math.abs(dto.getLatitude()) < 0.0001 && Math.abs(dto.getLongitude()) < 0.0001) { throw new BizException("门店坐标疑似无效,请确认"); } // 坐标转换(如果需要) double[] converted = CoordinateConverter.toGcj02(dto.getLatitude(), dto.getLongitude()); Store store = new Store(); store.setName(dto.getName()); store.setLatitude(converted[0]); store.setLongitude(converted[1]); store.setCityCode(dto.getCityCode()); store.setStatus(1); storeMapper.insert(store); // 清除相关缓存 cacheService.evictStoreCache(dto.getCityCode()); }坐标转换那一步,如果你的门店数据本来就是目标坐标系,可以跳过。但一定要确认清楚,不要想当然。
4.3 查询接口的完整实现
查询接口的Controller层很简单,接收经纬度和半径参数,做参数校验后调用Service:
@RestController @RequestMapping("/api/store") public class StoreController { @Autowired private StoreService storeService; @GetMapping("/nearby") public Result<List<StoreVO>> nearby( @RequestParam double lat, @RequestParam double lng, @RequestParam(defaultValue = "3") double radius, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize) { if (lat < -90 || lat > 90 || lng < -180 || lng > 180) { return Result.error("坐标参数不合法"); } if (radius <= 0 || radius > 10) { return Result.error("半径需在0到10公里之间"); } if (page < 1 || pageSize < 1 || pageSize > 100) { return Result.error("分页参数不合法"); } List<StoreVO> list = storeService.findNearby(lat, lng, radius, page, pageSize); return Result.success(list); } }参数校验这块不要偷懒。我见过因为没校验半径,用户传了个99999,直接把数据库查挂的案例。上限一定要设,而且要在接口层就拦住。
Service层的实现就是前面说的四步流程,但加上缓存逻辑后会更复杂一些。我的做法是:
- 先根据用户坐标算出所在网格(比如500米一格),拼出缓存key。
- 查Redis,命中就直接返回。
- 未命中,走数据库粗筛加内存精算。
- 把结果写入Redis,设置较短的过期时间(比如60秒),避免位置变化导致结果过时。
缓存key的设计要注意:网格坐标要取整,比如Math.floor(lat / 0.005)和Math.floor(lng / 0.005),这样同一个网格内的用户共享缓存。半径和分页参数也要拼进key里。
4.4 缓存层的具体实现
Redis缓存用Spring的RedisTemplate就行。序列化方式建议用JSON,方便排查问题。key的格式我一般用nearby:{gridLat}:{gridLng}:{radius}:{page}:{pageSize}。
public List<StoreVO> findNearby(double lat, double lng, double radius, int page, int pageSize) { String cacheKey = buildCacheKey(lat, lng, radius, page, pageSize); String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return JSON.parseArray(cached, StoreVO.class); } List<StoreVO> result = queryFromDb(lat, lng, radius, page, pageSize); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), 60, TimeUnit.SECONDS); return result; } private String buildCacheKey(double lat, double lng, double radius, int page, int pageSize) { long gridLat = (long) Math.floor(lat / 0.005); long gridLng = (long) Math.floor(lng / 0.005); return String.format("nearby:%d:%d:%.1f:%d:%d", gridLat, gridLng, radius, page, pageSize); }这里有个细节:网格大小0.005度大约对应500米。网格太小缓存命中率低,太大结果不准确。500米是我实测下来比较平衡的值。你可以根据业务特点调整,比如门店密集的商圈可以用更小的网格。
注意:缓存过期时间不要设太长。用户位置是变化的,缓存太久会导致用户移动后还看到旧结果。60秒是个比较稳妥的值,既能挡住短时间内的重复请求,又不会让结果太陈旧。
4.5 性能压测与调优记录
功能写完只是第一步,性能到底怎么样得压测说了算。我用JMeter做了一轮压测,场景是100个并发线程持续请求,门店数据量5万条,分布在全国20个城市。
第一轮结果不太理想,平均响应时间120毫秒,P99到了400毫秒。用EXPLAIN分析慢查询,发现有些城市的门店数据分布不均,矩形粗筛出来的候选集有上千条,内存精算和排序耗时较长。
优化措施有三个:一是给city_code加了索引,查询时强制带上城市条件;二是把候选集上限做了限制,超过500条就自动缩小半径;三是把门店基础数据预热到Redis,减少数据库查询。
第二轮压测,平均响应时间降到了35毫秒,P99在120毫秒左右。这个结果对于门店查询场景已经足够了。后来我又试了把精算逻辑改成并行流,但提升不明显,反而增加了线程开销,就放弃了。
压测数据我整理了一个对比表:
| 优化阶段 | 平均响应时间 | P99响应时间 | 数据库QPS |
|---|---|---|---|
| 优化前 | 120ms | 400ms | 800 |
| 加城市索引 | 80ms | 260ms | 500 |
| 限制候选集 | 55ms | 180ms | 500 |
| 加Redis缓存 | 35ms | 120ms | 120 |
从表里能看出来,缓存带来的收益最大,数据库QPS直接降了一个数量级。所以如果你的项目还没上缓存,优先把缓存加上,性价比最高。
5. 常见问题与排查技巧实录
5.1 距离算出来偏差很大怎么办
这是最常见的问题。排查思路按顺序来:
先确认坐标系是否一致。门店坐标和用户坐标是不是同一个坐标系,这是偏差最大的来源。GCJ-02和WGS-84之间差几百米是正常的。
再确认距离公式的参数。EARTH_RADIUS用的是6371还是6378,差别不大,但如果用了错误的单位(比如把公里当成米),结果会差1000倍。
最后确认经纬度的顺序。haversine(lat1, lng1, lat2, lng2),参数顺序传反了结果会完全错误。这个错误很隐蔽,因为算出来的距离也是个合理的数值,只是位置不对。
5.2 查询结果为空但明明有门店
按这个顺序排查:
第一,检查门店的status字段。很多查询默认只查营业中的门店,如果门店被标记为关闭,自然查不到。
第二,检查城市编码。如果查询带了城市条件,而门店的城市编码和用户定位反查出来的城市不一致,就会被过滤掉。跨城市边界的场景特别容易出这个问题。
第三,检查矩形范围计算。经度范围计算时如果cos(lat)算错了,范围可能严重偏小。可以在日志里把计算出来的矩形边界打出来,和预期对比。
第四,检查坐标是否在矩形内。有时候门店坐标看起来在范围内,但因为浮点数精度问题,BETWEEN条件可能刚好把它排除掉。可以在边界值上加减一个很小的epsilon(比如0.000001)来避免。
5.3 分页结果错乱或重复
这个问题几乎可以肯定是分页位置不对。如果你在SQL里做了LIMIT,然后又在内存里排序,分页结果必然错乱。正确做法是SQL只做粗筛不做分页,所有候选集拿到内存后统一排序再分页。
还有一种可能是排序不稳定。距离相同的门店如果没有次级排序条件,每次查询顺序可能不同,导致分页时同一家门店出现在不同页。加上thenComparing按ID排序就能解决。
5.4 高并发下数据库压力大
除了加缓存,还有几个手段可以用:
一是读写分离,附近查询走从库,减轻主库压力。门店数据更新频率低,主从延迟对查询影响很小。
二是限流降级。给查询接口设置QPS上限,超过之后返回缓存数据或者默认推荐,保证核心功能可用。
三是异步预热。在低峰期定时把热门城市的门店数据加载到缓存,避免高峰期缓存穿透。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 距离偏差几百米 | 坐标系不一致 | 对比门店和用户坐标的采集来源 | 统一坐标系,入库时转换 |
| 查询结果为空 | 城市编码不匹配 | 打印用户反查城市和门店城市 | 跨城查询或去掉城市条件 |
| 分页结果重复 | 排序不稳定 | 检查排序字段是否有唯一性 | 加次级排序字段 |
| 响应时间波动大 | 缓存穿透或候选集过大 | 看缓存命中率和候选集数量 | 加缓存、限制候选集上限 |
| 高并发下超时 | 数据库连接池不足 | 看连接池监控和慢查询日志 | 扩大连接池、加缓存、限流 |
| 门店坐标异常 | 数据录入错误 | 校验经纬度范围和是否为0 | 入库校验加人工复核 |
5.6 几个我踩过的坑
第一个坑是忽略了门店的营业时间。用户凌晨三点打开应用,推荐了一家已经打烊的门店,体验很差。后来在查询条件里加了营业时间过滤,但营业时间是个时间段,跨天的怎么处理又是个问题。最后方案是把营业时间转成分钟数区间,查询时判断当前时间是否在区间内,跨天的拆成两段。
第二个坑是缓存key没考虑坐标系。有次做坐标转换升级,门店坐标从WGS-84转成了GCJ-02,但缓存里的旧数据还是WGS-84的,导致新旧数据混用,距离算出来乱七八糟。后来在缓存key里加了坐标系版本号,升级时版本号一变,旧缓存自然失效。
第三个坑是半径参数没做上限。测试的时候随手传了个1000,结果矩形范围覆盖了整个亚洲,数据库直接查了十几秒。后来加了上限校验,最大10公里,接口层就拦住。
第四个坑是并发更新门店坐标时的缓存一致性。门店坐标更新后,缓存里的旧坐标还在,导致查询结果不准。解决方案是更新门店时主动清除相关网格的缓存,或者给缓存设置较短的过期时间兜底。
6. 进阶优化与扩展方向
6.1 用GeoHash优化网格划分
前面说的网格划分是简单的经纬度取整,其实可以用GeoHash来做更优雅的网格划分。GeoHash把经纬度编码成一个字符串,前缀相同的点在地理上相邻。用GeoHash做缓存key,可以方便地实现“查当前网格,没有就查相邻网格”的逻辑。
GeoHash的精度和字符串长度对应,比如6位GeoHash大约对应1.2公里×0.6公里的区域。你可以根据业务需要的精度选择长度。Java里可以用geohash-java这个库,也可以自己实现编码算法。
不过GeoHash有个问题:相邻网格的边界处,两个很近的点可能GeoHash前缀完全不同。所以查询时需要同时查当前网格和周围8个网格,再合并结果。这增加了复杂度,但能提高缓存命中率和查询准确性。
6.2 多条件叠加时的查询设计
实际业务里,附近门店往往还要叠加品类、评分、是否有优惠等条件。这些条件如果都加到SQL里,索引设计会变得很复杂。我的建议是分层过滤:
第一层用矩形范围粗筛,这是必须走索引的。第二层在内存里做距离精算和半径过滤。第三层在内存里做业务条件过滤,比如品类、评分。第四层排序分页。
这样设计的好处是,数据库只负责它最擅长的范围查询,复杂的业务逻辑在应用层做,灵活且易于扩展。缺点是候选集不能太大,否则内存过滤的成本会很高。所以第一层的矩形范围要控制好,必要时可以先用城市条件缩小范围。
6.3 从MySQL到Elasticsearch的迁移时机
什么时候该考虑换Elasticsearch?我的经验是看三个指标:门店数量超过50万、查询QPS超过1000、P99响应时间超过200毫秒。满足其中两个,就该认真评估迁移了。
Elasticsearch的geo_point类型和geo_distance查询原生支持地理空间搜索,性能比MySQL好很多。而且ES支持复杂的多条件组合查询和聚合,适合业务复杂的场景。但ES的运维成本高,数据一致性需要额外保障,不是所有团队都适合。
迁移的时候要注意:ES的坐标顺序是[经度, 纬度],和常见的(纬度, 经度)相反,这个坑很多人踩。另外ES的距离计算默认用弧线距离,可以配置成平面距离,但平面距离在大范围下误差大,建议还是用弧线距离。
6.4 实时位置更新与门店动态
有些业务场景下,门店的位置不是固定的,比如快闪店、移动摊位。这种场景下,门店坐标会频繁更新,缓存策略要调整。我的做法是给这类门店单独建一个缓存区域,过期时间设短一些,比如10秒,保证位置变化的及时性。
还有一种场景是用户位置实时变化,比如打车或者配送。这种场景下,附近门店的查询频率很高,但每次结果变化不大。可以用增量更新的方式,只查询用户移动方向上新增的门店,而不是每次全量查询。不过这属于更复杂的优化,一般门店查询用不上。
6.5 监控与告警体系的搭建
功能上线不是终点,监控才是。我一般会监控这几个指标:
- 查询QPS和响应时间分布(P50、P95、P99)
- 缓存命中率
- 数据库慢查询数量
- 候选集大小的分布
- 空结果率
空结果率这个指标特别有用。如果某个区域空结果率突然升高,可能是门店数据出了问题,或者坐标转换有bug。我设的告警阈值是空结果率超过30%持续5分钟,就会触发告警。
监控数据用Micrometer加Prometheus收集,Grafana展示。这套组合是Java生态里最成熟的,接入成本低,社区资料多。
7. 我个人在实际操作中的几点体会
做了这么多年的LBS功能,我最大的体会是:不要过早追求完美方案,先把最简单的方案跑通,再根据实际瓶颈优化。我见过太多团队一上来就设计复杂的分布式地理空间方案,结果功能还没上线,光架构就改了好几版。其实对于大多数业务,MySQL加Redis加Haversine公式,已经能支撑相当规模的门店查询了。
另一个体会是坐标系和数据质量的重要性被严重低估。很多性能问题、准确性问题,根源都在数据上。门店坐标录错了、坐标系混用了、脏数据没清理,这些问题的排查成本远高于代码优化。所以我在每个项目里都会花大力气做数据校验和清洗,宁可上线慢一点,也不要带着脏数据上线。
最后一个体会是缓存是把双刃剑。缓存能极大提升性能,但也会带来一致性问题。我的原则是:缓存的数据必须是“允许短暂不一致”的。门店位置这种数据,用户看到几十秒前的旧位置,通常是可以接受的。但如果涉及库存、价格这种敏感数据,缓存就要慎用,或者用更短的过期时间和主动失效机制。
如果你正在做类似的功能,我的建议是先按这篇文章的骨架把功能实现出来,跑通主流程,然后再根据实际压测结果做针对性优化。不要一开始就纠结用哪个数据库、要不要上ES,那些都是后面的事。先把“粗筛-精算-排序-分页”这四步走通,你就已经解决了80%的问题。剩下的20%,等你真正遇到瓶颈了,自然知道该怎么选。