Vue3 + Spring Boot + 高德 LBS:位置服务全栈实战
摘要:外卖配送范围判断靠人工标注,骑手轨迹无法实时追踪,3公里范围内的门店要写SQL全表扫描——这是我负责的本地生活平台项目中的真实困境。通过高德LBS + Redis GEO的全栈方案,配送范围自动判断从人工30分钟降到接口实时计算,骑手轨迹实时回放延迟<2秒,附近门店搜索从800ms降至15ms,调度效率整体提升40%。本文从LBS 5大痛点出发,详解 Vue3 AMap组件封装 + Spring Boot + 高德开放平台API + Redis GEO + PolarDB 的全栈架构与代码实战,覆盖地理编码/逆地理编码、距离计算、电子围栏、轨迹服务、路径规划等核心场景,并给出5个生产踩坑实录和LBS方案选型决策树。
1. 场景:配送范围靠人工,骑手轨迹看不见
2025年Q3,我负责的本地生活平台覆盖3个城市、1200家门店,日均配送订单5万单。但LBS能力几乎是原始状态——配送范围靠运营人员在地图上手动画圈,一个商圈调整一次要30分钟;骑手位置靠骑手手动上报,调度员看到的位置延迟5~10分钟;用户搜"附近3公里的门店",后端用经纬度差值过滤,经常搜出直线3公里实际通勤40分钟的店。
引入高德LBS + Redis GEO方案后,效果立竿见影:

| 指标 | 改造前 | 改造后 | 提升幅度 |
|---|---|---|---|
| 配送范围判断 | 人工标注30分钟/商圈 | 接口实时计算<100ms | ⬇️ 99.9% |
| 骑手位置延迟 | 5~10分钟(手动上报) | <2秒(自动采集+推送) | ⬇️ 99.6% |
| 附近门店搜索 | 800ms(SQL全表扫描) | 15ms(Redis GEO) | ⬇️ 98% |
| 围栏事件响应 | 无(不支持) | <500ms(进入/离开实时通知) | 🆕 |
| 路径规划准确度 | 直线距离(误差60%+) | 实际路网距离(误差<5%) | ⬆️ 显著 |
下面从LBS痛点说起,逐步展开全栈实战方案。
2. LBS位置服务的5大痛点
很多团队对"位置服务"的理解就是"存个经纬度",但企业级LBS远不止于此。
痛点1:距离计算不准
用Haversine公式算直线距离,3公里的直线距离实际通勤可能超过8公里。配送范围判断基于直线距离,导致大量"看着在范围内但实际送不到"的客诉。
| 场景 | 直线距离 | 实际路程 | 误差 |
|---|---|---|---|
| 跨河配送 | 2.5km | 6.8km | 172% |
| 环路内侧到外侧 | 1.8km | 5.2km | 189% |
| 同一条街两侧 | 0.3km | 0.5km | 67% |
痛点2:围栏判断难
"配送范围"本质是地理围栏——不规则多边形区域。判断一个点是否在多边形内,Ray-Casting算法理论上可行,但实际场景复杂:凹多边形边界case、跨时区围栏、围栏重叠和优先级、围栏变更的实时生效,每个都是坑。
痛点3:轨迹存储贵
骑手每5秒上报一次位置,一天8小时就是5760个点/人/天。1000个骑手每天就是576万个点。存MySQL单表两个月就上亿行,查询慢、存储贵、归档难。
痛点4:地图渲染慢
前端渲染1000+标记点时,DOM节点暴增,地图卡顿严重。聚合算法不当会导致"缩放时闪烁",热力图数据量大时首屏渲染超过3秒。
痛点5:逆地理编码慢
坐标转地址(逆地理编码)是高频操作——骑手位置要展示"XX路XX号附近"。自建POI数据库覆盖不全,调第三方API又受限于QPS配额,高峰期接口排队严重。
核心思路:用高德LBS解决路网距离、路径规划、地理编码等"地图能力",用Redis GEO解决附近搜索、距离计算等"高性能查询",用PolarDB解决围栏数据、轨迹归档等"持久化存储",三者各司其职。
3. 架构设计
3.1 整体架构

3.2 技术选型
| 层级 | 技术 | 选型理由 |
|---|---|---|
| 前端地图 | 高德 JS API 2.0 | 国内数据最全、免费配额充足、Vue生态支持好 |
| 后端框架 | Spring Boot 3.x | 企业级成熟框架,高德Java SDK完善 |
| 位置查询 | Redis GEO | 附近搜索O(N+log(M)),单机10万QPS |
| 持久存储 | PolarDB MySQL | 兼容MySQL、存储计算分离、只读节点扩展 |
| 轨迹存储 | OSS + PolarDB | 热数据PolarDB、冷数据归档OSS |
| 轨迹追踪 | 高德猎鹰 | 免自建轨迹服务、内置围栏回调 |
4. 高德API配置
4.1 Key申请与配额管理
在高德开放平台(lbs.amap.com)注册后,需要创建两种Key:
| Key类型 | 用途 | 每日配额(个人认证) | 每日配额(企业认证) |
|---|---|---|---|
| Web端(JS API) | 前端地图渲染 | 30万次/日 | 100万次/日 |
| Web服务 | 后端API调用 | 5000次/日 | 30万次/日 |
Why:Web端Key绑定域名防盗用,Web服务Key绑定IP白名单。两种Key的安全策略不同,必须分开。
# application.yml
amap:
web-key: ${
AMAP_WEB_KEY:your-web-key}
server-key: ${
AMAP_SERVER_KEY:your-server-key}
base-url: https://restapi.amap.com/v3
geocode-url: ${
amap.base-url}/geocode/geo
regeocode-url: ${
amap.base-url}/geocode/regeo
direction-url: ${
amap.base-url}/direction
4.2 配额管理策略
@Configuration
public class AmapConfig {
@Value("${amap.server-key}")
private String serverKey;
@Value("${amap.web-key}")
private String webKey;
@Bean
public RestTemplate amapRestTemplate() {
RestTemplate template = new RestTemplate();
// 设置超时:高德API要求3秒内响应
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3000);
factory.setReadTimeout(5000);
template.setRequestFactory(factory);
return template;
}
public String getServerKey() {
return serverKey; }
public String getWebKey() {
return webKey; }
}
Why:超时配置防止高德API慢响应拖垮业务线程。配额耗尽时高德返回status=0,需要做降级处理。
5. 后端Spring Boot实战
5.1 地理编码与逆地理编码
5.1.1 地理编码(地址→坐标)
@Service
@Slf4j
public class GeocodeService {
@Autowired
private AmapConfig amapConfig;
@Autowired
private RestTemplate amapRestTemplate;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String GEO_CACHE_PREFIX = "lbs:geocode:";
private static final long CACHE_TTL_HOURS = 72;
/**
* 地址转坐标,优先走缓存
*/
public GeoResult geocode(String address, String city) {
String cacheKey = GEO_CACHE_PREFIX + DigestUtils.md5Hex(address + city);
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, GeoResult.class);
}
String url = amapConfig.getBaseUrl() + "/geocode/geo"
+ "?key=" + amapConfig.getServerKey()
+ "&address=" + URLEncoder.encode(address, StandardCharsets.UTF_8)
+ "&city=" + URLEncoder.encode(city, StandardCharsets.UTF_8);
JSONObject resp = amapRestTemplate.getForObject(url, JSONObject.class);
if (resp == null || !"1".equals(resp.getString("status"))) {
log.warn("高德地理编码失败, address={}, resp={}", address, resp);
throw new BusinessException("地理编码服务异常");
}
JSONArray geocodes = resp.getJSONArray("geocodes");
if (geocodes.isEmpty()) {
throw new BusinessException("未找到匹配的坐标");
}
JSONObject first = geocodes.getJSONObject(0);
String location = first.getString("location"); // "116.397428,39.90923"
String[] lngLat = location.split(",");
GeoResult result = GeoResult.builder()
.longitude(Double.parseDouble(lngLat[0]))
.latitude(Double.parseDouble(lngLat[1]))
.formattedAddress(first.getString("formatted_address"))
.adcode(first.getString("adcode"))
.build();
// 缓存72小时:地址→坐标映射极少变化
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result),
CACHE_TTL_HOURS, TimeUnit.HOURS);
return result;
}
}
Why:地理编码结果缓存72小时——同一地址的坐标不会变,缓存能减少90%+的API调用量,避免配额耗尽。
5.1.2 逆地理编码(坐标→地址)
public RegeoResult reverseGeocode(Double lng, Double lat) {
String cacheKey = GEO_CACHE_PREFIX + "regeo:" + lng + "," + lat;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, RegeoResult.class);
}
// 精度截断到小数点后4位(约11米),减少缓存键数量
String lngStr = String.format("%.4f", lng);
String latStr = String.format("%.4f", lat);
String url = amapConfig.getBaseUrl() + "/geocode/regeo"
+ "?key=" + amapConfig.getServerKey()
+ "&location=" + lngStr + "," + latStr
+ "&extensions=base";
JSONObject resp = amapRestTemplate.getForObject(url, JSONObject.class);
if (resp == null || !"1".equals(resp.getString("status"))) {
throw new BusinessException("逆地理编码服务异常");
}
JSONObject regeocode = resp.getJSONObject("regeocode");
RegeoResult result = RegeoResult.builder()
.formattedAddress(regeocode.getString("formatted_address"))
.province(regeocode.getJSONObject("addressComponent").getString("province"))
.city(regeocode.getJSONObject("addressComponent").getString("city"))
.district(regeocode.getJSONObject("addressComponent").getString("district"))
.build();
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result),
24, TimeUnit.HOURS);
return result;
}
Why:精度截断到小数点后4位(约11米精度),同一区域的逆地理编码结果基本一致,这样能大幅提高缓存命中率。骑手5秒上报一次位置,如果精度到小数点后6位,缓存几乎无法命中。
5.2 Redis GEO距离计算
5.2.1 门店入GEO
@Service
@Slf4j
public class GeoSearchService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String STORE_GEO_KEY = "lbs:geo:store";
private static final String RIDER_GEO_KEY = "lbs:geo:rider";
/**
* 门店位置写入Redis GEO
* 项目启动时批量写入,新增门店时增量写入
*/
public void addStoreLocation(Long storeId, Double lng, Double lat) {
redisTemplate.opsForGeo().add(
STORE_GEO_KEY,
new Point(lng, lat),
storeId.toString()
);
}
/**
* 批量导入门店位置(项目启动时调用)
*/
public void batchImportStores(List<StoreLocation> stores) {
// Redis GEO单次add是O(1),批量用pipeline
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (StoreLocation store : stores) {
connection.geoAdd(
STORE_GEO_KEY.getBytes(),
store.getLongitude(),
store.getLatitude(),
store.getStoreId().toString().getBytes()
);
}
return null;
});
log.info("批量导入门店位置完成, count={}", stores.size());
}
}
Why:Pipeline批量写入比循环单条写入快10倍+。1000家门店的GEO数据导入耗时从5秒降到200毫秒。
5.2.2 附近搜索
/**
* 搜索附近的门店
* @param lng 用户经度
* @param lat 用户纬度
* @param radius 搜索半径(米)
* @param limit 返回数量
*/
public List<NearbyStoreVO> searchNearbyStores(Double lng, Double lat,
Double radius, int limit) {
// Redis GEOSEARCH:O(N+log(M)),N=半径内元素数,M=返回数量
GeoResults<RedisGeoCommands.GeoLocation<String>> results =
redisTemplate.opsForGeo().search(
STORE_GEO_KEY,
new Circle(new Point(lng, lat), new Distance(radius, Metrics.KILOMETERS)),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance() // 包含距离
.sortAscending() // 按距离升序
.limit(limit) // 限制返回数量
);
if (results == null || results.getContent().isEmpty()) {
return Collections.emptyList();
}
List<NearbyStoreVO> voList = new ArrayList<>();
for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
String storeId = result.getContent().getName();
double distance = result.getDistance().getValue();
double distanceKm = result.getDistance().in(Metrics.KILOMETERS).getValue();
// 根据storeId补全门店信息(可批量查PolarDB)
NearbyStoreVO vo = NearbyStoreVO.builder()
.storeId(Long.parseLong(storeId))
.distance(Math.round(distanceKm * 1000.0) / 1000.0) // 保留3位小数
.distanceText(formatDistance(distance))
.build();
voList.add(vo);
}
return voList;
}
private String formatDistance(double distanceMeters) {
if (distanceMeters < 1000) {
return Math.round(distanceMeters) + "m";
}
return String.format("%.1fkm", distanceMeters / 1000);
}
5.2.3 配送范围判断
/**
* 判断门店是否在配送范围内
* 结合直线距离(Redis GEO)+ 路网距离(高德API)
*/
public DeliveryCheckResult checkDeliveryRange(Long storeId, Double userLng, Double userLat) {
// 第一步:Redis GEO快速算直线距离,作为初筛
Point storePoint = getStorePoint(storeId);
Distance straightDistance = redisTemplate.opsForGeo().distance(
STORE_GEO_KEY,
storeId.toString(),
"user:" + userLng + "," + userLat, // 临时点
Metrics.KILOMETERS
);
// 直线距离超过阈值(如5km),直接判定超出范围
if (straightDistance != null && straightDistance.getValue() > 5.0) {
return DeliveryCheckResult.outOfRange("直线距离超过5km,不在配送范围");
}
// 第二步:调用高德路径规划算实际路网距离
RouteResult route = directionService.drivingRoute(
storePoint.getX(), storePoint.getY(),
userLng, userLat
);
// 第三步:综合判断
double routeDistanceKm = route.getDistance() / 1000.0;
double maxDeliveryKm = getStoreMaxDeliveryKm(storeId); // 门店配置的配送半径
if (routeDistanceKm <= maxDeliveryKm) {
return DeliveryCheckResult.inRange(routeDistanceKm, route.getDuration());
}
return DeliveryCheckResult.outOfRange(
String.format("路网距离%.1fkm,超出配送范围%.1fkm", routeDistanceKm, maxDeliveryKm)
);
}
Why:两级过滤——Redis GEO做初筛(1ms内完成),过滤掉90%明显不在范围内的请求;剩下的10%才调高德路径规划(100~300ms),既保证精度又控制成本。
5.3 电子围栏
5.3.1 多边形围栏存储与判断
@Entity
@Table(name = "lbs_geofence")
@Data
@Builder
public class Geofence {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String type; // POLYGON, CIRCLE
/**
* 多边形顶点坐标,JSON格式
* [{"lng":116.397,"lat":39.909}, {"lng":116.398,"lat":39.910}, ...]
*/
@Column(columnDefinition = "TEXT")
private String coordinates;
private Double centerLng;
private Double centerLat;
private String businessType; // DELIVERY_RANGE, RIDER_ALERT, etc.
private Long businessId;
private Integer status; // 0-禁用 1-启用
}
Why:围栏坐标存JSON而非单独建表——一个多边形可能有10~50个顶点,存关联表JOIN查询性能差。JSON在应用层反序列化,配合Jackson做校验。
5.3.2 点在多边形内判断
@Service
public class GeofenceService {
/**
* Ray-Casting算法判断点是否在多边形内
* 时间复杂度O(n),n=顶点数
*/
public boolean isPointInPolygon(Double lng, Double lat, List<Coordinate> polygon) {
int n = polygon.size();
boolean inside = false;
for (int i = 0, j = n - 1; i < n; j = i++) {
Coordinate pi = polygon.get(i);
Coordinate pj = polygon.get(j);
// 判断射线是否与边相交
if ((pi.getLat() > lat) != (pj.getLat() > lat)
&& lng < (pj.getLng() - pi.getLng())
* (lat - pi.getLat()) / (pj.getLat() - pi.getLat())
+ pi.getLng()) {
inside = !inside;
}
}
return inside;
}
/**
* 查询点所在的配送围栏
*/
public List<Geofence> findContainingFences(Double lng, Double lat, String businessType) {
// 先用Redis GEO做粗筛:只查中心点3km内的围栏
// 避免全量加载所有围栏做判断
List<Geofence> candidates = findNearbyFences(lng, lat, 3.0, businessType);
return candidates.stream()
.filter(fence -> {
List<Coordinate> polygon = parseCoordinates(fence.getCoordinates());
return isPointInPolygon(lng, lat, polygon);
})
.collect(Collectors.toList());
}
}
Why:先Redis GEO粗筛再Ray-Casting精判——1000个围栏全量做Ray-Casting需要10ms+,粗筛后只对10~20个候选围栏做判断,耗时降到1ms以内。
5.3.3 围栏事件回调
@RestController
@RequestMapping("/api/lbs/geofence")
@Slf4j
public class GeofenceCallbackController {
@Autowired
private ApplicationEventPublisher eventPublisher;
/**
* 高德猎鹰围栏事件回调
* 当绑定的终端进入/离开围栏时,高德主动推送
*/
@PostMapping("/callback")
public String fenceCallback(@RequestBody String body) {
JSONObject params = JSON.parseObject(body);
String action = params.getString("action"); // enter / leave
String serviceId = params.getString("serviceid");
String terminalId = params.getString("terminalid");
String fenceId = params.getString("fenceid");
Double lng = params.getDouble("longitude");
Double lat = params.getDouble("latitude");
log.info("围栏事件: action={}, terminal={}, fence={}, location={},{}",
action, terminalId, fenceId, lng, lat);
// 发布Spring事件,解耦回调处理逻辑
FenceEvent event = FenceEvent.builder()
.action(action)
.terminalId(terminalId)
.fenceId(fenceId)
.lng(lng)
.lat(lat)
.timestamp(LocalDateTime.now())
.build();
eventPublisher.publishEvent(event);
return "ok"; // 高德要求返回"ok"
}
}
@Component
@Slf4j
public class FenceEventListener {
@EventListener
@Async
public void handleFenceEvent(FenceEvent event) {
if ("enter".equals(event.getAction())) {
log.info("骑手{}进入围栏{}", event.getTerminalId(), event.getFenceId());
// 推送通知给调度员、更新骑手状态等
notifyDispatcher(event);
} else if ("leave".equals(event.getAction())) {
log.info("骑手离开围栏{}", event.getFenceId());
handleRiderLeave(event);
}
}
}
5.4 轨迹服务
5.4.1 轨迹上传与存储
@Service
@Slf4j
public class TrackService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private TrackRepository trackRepository;
@Autowired
private OSSClient ossClient;
private static final String TRACK_HOT_KEY = "lbs:track:hot:";
/**
* 骑手位置上报(高频,每5秒一次)
* 热数据写Redis,异步批量落PolarDB
*/
public void uploadPoint(String riderId, Double lng, Double lat,
Double speed, Double bearing) {
long timestamp = System.currentTimeMillis();
// 写Redis List(热数据,最近1小时)
String key = TRACK_HOT_KEY + riderId;
TrackPoint point = TrackPoint.builder()
.lng(lng).lat(lat)
.speed(speed).bearing(bearing)
.timestamp(timestamp)
.build();
redisTemplate.opsForList().rightPush(key, JSON.toJSONString(point));
// 保留最近1小时数据(720个点 = 3600/5)
redisTemplate.opsForList().trim(key, -720, -1);
redisTemplate.expire(key, 2, TimeUnit.HOURS);
}
}
Why:热数据存Redis List,只保留最近1小时——这是轨迹回放的刚需数据。超过1小时的轨迹从PolarDB查询,超过7天的归档到OSS。
5.4.2 轨迹压缩(Douglas-Peucker算法)
/**
* Douglas-Peucker轨迹压缩
* 1万点压缩到500点,误差控制在5米以内
*/
public List<TrackPoint> compressTrack(List<TrackPoint> points, double epsilon) {
if (points.size() <= 2) return points;
int maxIndex = 0;
double maxDist = 0;
// 找到距首尾连线最远的点
TrackPoint start = points.get(0);
TrackPoint end = points.get(points.size() - 1);
for (int i = 1; i < points.size() - 1; i++) {
double dist = perpendicularDistance(points.get(i), start, end);
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
// 递归压缩
if (maxDist > epsilon) {
List<TrackPoint> left = compressTrack(
points.subList(0, maxIndex + 1), epsilon);
List<TrackPoint> right = compressTrack(
points.subList(maxIndex, points.size()), epsilon);
List<TrackPoint> result = new ArrayList<>(left);
result.addAll(right.subList(1, right.size())); // 去掉重复的中间点
return result;
} else {
return Arrays.asList(start, end);
}
}
private double perpendicularDistance(TrackPoint point,
TrackPoint lineStart, TrackPoint lineEnd) {
double A = point.getLat() - lineStart.getLat();
double B = point.getLng() - lineStart.getLng();
double C = lineEnd.getLat() - lineStart.getLat();
double D = lineEnd.getLng() - lineStart.getLng();
double dot = A * C + B * D;
double lenSq = C * C + D * D;
double param = lenSq != 0 ? dot / lenSq : -1;
double xx, yy;
if (param < 0) {
xx = lineStart.getLng(); yy = lineStart.getLat(); }
else if (param > 1) {
xx = lineEnd.getLng(); yy = lineEnd.getLat(); }
else {
xx = lineStart.getLng() + param * C; yy = lineStart.getLat() + param * D; }
double dx = point.getLng() - xx;
double dy = point.getLat() - yy;
// 近似距离(米),小范围内经纬度差值×111000
return Math.sqrt(dx * dx + dy * dy) * 111000;
}
Why:1万点轨迹直接传前端,JSON大约200KB,渲染卡顿。Douglas-Peucker压缩后500点约10KB,5米误差在地图上肉眼不可辨,渲染流畅。
5.4.3 轨迹归档到OSS
/**
* 每日凌晨归档7天前的轨迹数据到OSS
* PolarDB只保留7天热数据
*/
@Scheduled(cron = "0 0 2 * * ?")
public void archiveTrackData() {
LocalDateTime archiveBefore = LocalDateTime.now().minusDays(7);
List<String> riderIds = trackRepository.findActiveRiderIds(archiveBefore);
for (String riderId : riderIds) {
List<TrackPoint> points = trackRepository.findBeforeDate(riderId, archiveBefore);
if (points.isEmpty()) continue;
// 压缩后上传OSS
List<TrackPoint> compressed = compressTrack(points, 5.0);
String json = JSON.toJSONString(compressed);
String ossKey = String.format("tracks/%s/%s.json",
riderId, archiveBefore.format(DateTimeFormatter.ISO_DATE));
ossClient.putObject("lbs-track-archive", ossKey,
new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)));
// 删除PolarDB中已归档的数据
trackRepository.deleteBeforeDate(riderId, archiveBefore);
log.info("轨迹归档完成: rider={}, points={}->{}, ossKey={}",
riderId, points.size(), compressed.size(), ossKey);
}
}
5.5 路径规划
@Service
@Slf4j
public class DirectionService {
@Autowired
private AmapConfig amapConfig;
@Autowired
private RestTemplate amapRestTemplate;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 驾车路径规划
*/
public RouteResult drivingRoute(Double originLng, Double originLat,
Double destLng, Double destLat) {
// 缓存key:起点终点精度到小数点后3位(约100米)
String cacheKey = "lbs:route:driving:"
+ String.format("%.3f,%.3f", originLng, originLat) + ":"
+ String.format("%.3f,%.3f", destLng, destLat);
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, RouteResult.class);
}
String url = amapConfig.getBaseUrl() + "/direction/driving"
+ "?key=" + amapConfig.getServerKey()
+ "&origin=" + originLng + "," + originLat
+ "&destination=" + destLng + "," + destLat
+ "&strategy=0" // 0-速度优先
+ "&extensions=base";
JSONObject resp = amapRestTemplate.getForObject(url, JSONObject.class);
if (resp == null || !"1".equals(resp.getString("status"))) {
throw new BusinessException("路径规划服务异常");
}
JSONObject route = resp.getJSONObject("route");
JSONArray paths = route.getJSONArray("paths");
if (paths.isEmpty()) {
throw new BusinessException("未找到可用路线");
}
JSONObject path = paths.getJSONObject(0);
RouteResult result = RouteResult.builder()
.distance(path.getDouble("distance")) // 米
.duration(path.getDouble("duration")) // 秒
.strategy(path.getString("strategy"))
.tolls(path.getDouble("tolls")) // 过路费
.build();
// 路径缓存30分钟(路况会变化)
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result),
30, TimeUnit.MINUTES);
return result;
}
/**
* 骑行路径规划(外卖骑手常用)
*/
public RouteResult bicyclingRoute(Double originLng, Double originLat,
Double destLng, Double destLat) {
String url = amapConfig.getBaseUrl() + "/direction/bicycling"
+ "?key=" + amapConfig.getServerKey()
+ "&origin=" + originLng + "," + originLat
+ "&destination=" + destLng + "," + destLat;
JSONObject resp = amapRestTemplate.getForObject(url, JSONObject.class);
if (resp == null || !"1".equals(resp.getString("status"))) {
throw new BusinessException("骑行路径规划服务异常");
}
JSONObject route = resp.getJSONObject("route");
JSONArray paths = route.getJSONArray("paths");
JSONObject path = paths.getJSONObject(0);
return RouteResult.builder()
.distance(path.getDouble("distance"))
.duration(path.getDouble("duration"))
.build();
}
}
Why:路径规划缓存30分钟而非72小时——路况实时变化,长缓存会导致推荐路线与实际拥堵情况不符。精度截断到小数点后3位(约100米),同一区域内起终点视为相同,提高缓存命中率。
6. 前端Vue3实战
6.1 AMap地图组件封装
<!-- AMapContainer.vue -->
<template>
<div ref="mapContainer" class="amap-container" />
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue'
import AMapLoader from '@amap/amap-jsapi-loader'
const props = defineProps({
center: { type: Array, default: () => [116.397428, 39.90923] },
zoom: { type: Number, default: 13 },
webKey: { type: String, required: true }
})
const emit = defineEmits(['mapReady', 'mapClick'])
const mapContainer = ref(null)
// shallowRef避免Vue深度代理导致AMap实例性能问题
const mapInstance = shallowRef(null)
// 标记管理
const markers = shallowRef([])
onMounted(async () => {
try {
const AMap = await AMapLoader.load({
key: props.webKey,
version: '2.0',
plugins: [
'AMap.Geocoder',
'AMap.PlaceSearch',
'AMap.Geolocation',
'AMap.MarkerCluster'
]
})
mapInstance.value = new AMap.Map(mapContainer.value, {
center: props.center,
zoom: props.zoom,
resizeEnable: true
})
// 点击事件
mapInstance.value.on('click', (e) => {
emit('mapClick', { lng: e.lnglat.getLng(), lat: e.lnglat.getLat() })
})
emit('mapReady', { AMap, map: mapInstance.value })
} catch (e) {
console.error('AMap加载失败:', e)
}
})
/**
* 添加标记点
*/
function addMarker(options) {
const { lng, lat, title, icon, label } = options
const AMap = window.AMap
const marker = new AMap.Marker({
position: new AMap.LngLat(lng, lat),
title,
icon,
label: label ? { content: label, direction: 'top' } : undefined
})
mapInstance.value.add(marker)
markers.value.push(marker)
return marker
}
/**
* 添加信息窗体
*/
function openInfoWindow(position, content) {
const AMap = window.AMap
const infoWindow = new AMap.InfoWindow({
content,
offset: new AMap.Pixel(0, -30)
})
infoWindow.open(mapInstance.value, position)
return infoWindow
}
/**
* 标记聚合(1000+点时必须用)
*/
function addCluster(points) {
const AMap = window.AMap
const markerList = points.map(p => new AMap.Marker({
position: new AMap.LngLat(p.lng, p.lat)
}))
const cluster = new AMap.MarkerCluster(mapInstance.value, markerList, {
gridSize: 60,
maxZoom: 16
})
return cluster
}
/**
* 清除所有标记(防止内存泄漏)
*/
function clearMarkers() {
markers.value.forEach(m => mapInstance.value.remove(m))
markers.value = []
}
onBeforeUnmount(() => {
// 销毁地图实例,释放内存
clearMarkers()
if (mapInstance.value) {
mapInstance.value.destroy()
mapInstance.value = null
}
})
defineExpose({ addMarker, openInfoWindow, addCluster, clearMarkers, getMap: () => mapInstance.value })
</script>
<style scoped>
.amap-container {
width: 100%;
height: 500px;
}
</style>
Why:shallowRef包裹AMap实例——Vue3的ref会对对象做深度Proxy代理,AMap内部有大量循环引用和不可枚举属性,深度代理会导致严重性能问题甚至报错。onBeforeUnmount必须销毁地图实例,否则SPA路由切换后地图DOM不释放。
6.2 位置选择器
<!-- LocationPicker.vue -->
<template>
<div class="location-picker">
<a-input-search
v-model:value="keyword"
placeholder="搜索地址"
@search="onSearch"
style="margin-bottom: 12px"
/>
<AMapContainer
ref="mapRef"
:web-key="amapWebKey"
@map-click="onMapClick"
@map-ready="onMapReady"
/>
<div v-if="selectedPoi" class="selected-info">
<p class="poi-name">{
{ selectedPoi.name }}</p>
<p class="poi-address">{
{ selectedPoi.address }}</p>
<p class="poi-coord">{
{ selectedPoi.lng }}, {
{ selectedPoi.lat }}</p>
</div>
<!-- 搜索结果列表 -->
<div v-if="searchResults.length" class="search-list">
<div
v-for="poi in searchResults"
:key="poi.id"
class="search-item"
:class="{ active: selectedPoi?.id === poi.id }"
@click="selectPoi(poi)"
>
<p class="poi-name">{
{ poi.name }}</p>
<p class="poi-address">{
{ poi.address }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import AMapContainer from './AMapContainer.vue'
const props = defineProps({
amapWebKey: { type: String, required: true },
apiBase: { type: String, default: '/api/lbs' }
})
const emit = defineEmits(['select'])
const mapRef = ref(null)
const keyword = ref('')
const selectedPoi = ref(null)
const searchResults = ref([])
let AMap = null
let placeSearch = null
let geocoder = null
let currentMarker = null
function onMapReady({ AMap: amap, map }) {
AMap = amap
placeSearch = new amap.PlaceSearch({ pageSize: 10, pageIndex: 1 })
geocoder = new amap.Geocoder()
}
function onSearch(val) {
if (!placeSearch || !val) return
placeSearch.search(val, (status, result) => {
if (status === 'complete') {
searchResults.value = result.poiList.pois.map(poi => ({
id: poi.id,
name: poi.name,
address: poi.address,
lng: poi.location.getLng(),
lat: poi.location.getLat()
}))
}
})
}
function onMapClick({ lng, lat }) {
// 逆地理编码
geocoder.getAddress([lng, lat], (status, result) => {
if (status === 'complete') {
const addr = result.regeocode
selectPoi({
id: 'map_click',
name: addr.formattedAddress,
address: addr.formattedAddress,
lng, lat
})
}
})
}
function selectPoi(poi) {
selectedPoi.value = poi
// 地图定位到选中点
if (currentMarker) mapRef.value.getMap().remove(currentMarker)
currentMarker = mapRef.value.addMarker({
lng: poi.lng, lat: poi.lat,
title: poi.name
})
mapRef.value.getMap().setCenter([poi.lng, poi.lat])
searchResults.value = []
emit('select', poi)
}
</script>
Why:位置选择器是LBS最常用的交互组件——用户通过搜索、POI列表、地图点击三种方式选点,选中后逆地理编码获取地址文本。三种选点方式殊途同归,最终都走selectPoi统一处理。
6.3 轨迹回放
<!-- TrackReplay.vue -->
<template>
<div class="track-replay">
<AMapContainer ref="mapRef" :web-key="amapWebKey" @map-ready="onMapReady" />
<div class="replay-controls">
<a-button @click="togglePlay">
{
{ isPlaying ? '暂停' : '播放' }}
</a-button>
<a-slider
v-model:value="speed"
:min="1"
:max="20"
:step="1"
style="width: 200px; margin: 0 16px"
/>
<span class="speed-label">{
{ speed }}x</span>
<a-slider
v-model:value="progress"
:min="0"
:max="100"
style="flex: 1; margin: 0 16px"
@change="onProgressChange"
/>
<span class="time-label">{
{ currentTimeLabel }}</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue'
import AMapContainer from './AMapContainer.vue'
const props = defineProps({
amapWebKey: { type: String, required: true },
trackPoints: { type: Array, default: () => [] }, // [{lng, lat, timestamp, speed}]
riderInfo: { type: Object, default: () => ({}) }
})
const mapRef = ref(null)
const isPlaying = ref(false)
const speed = ref(4)
const progress = ref(0)
const currentIndex = ref(0)
let AMap = null
let map = null
let polyline = null
let moveMarker = null
let animationTimer = null
const currentTimeLabel = computed(() => {
if (!props.trackPoints.length) return '--:--:--'
const idx = Math.floor(currentIndex.value)
const ts = props.trackPoints[idx]?.timestamp
return ts ? new Date(ts).toLocaleTimeString() : '--:--:--'
})
function onMapReady({ AMap: amap, map: m }) {
AMap = amap
map = m
drawTrack()
}
function drawTrack() {
if (!AMap || !props.trackPoints.length) return
const path = props.trackPoints.map(p => new AMap.LngLat(p.lng, p.lat))
// 绘制轨迹线
polyline = new AMap.Polyline({
path,
strokeColor: '#1677ff',
strokeWeight: 4,
strokeOpacity: 0.8,
lineJoin: 'round'
})
map.add(polyline)
// 移动标记(骑手图标)
moveMarker = new AMap.Marker({
position: path[0],
autoRotation: true, // 根据轨迹方向自动旋转
offset: new AMap.Pixel(-13, -26)
})
map.add(moveMarker)
map.setFitView([polyline])
}
function togglePlay() {
if (isPlaying.value) {
pauseReplay()
} else {
startReplay()
}
}
function startReplay() {
isPlaying.value = true
const path = props.trackPoints.map(p => new AMap.LngLat(p.lng, p.lat))
// AMap.MoveAnimation:沿轨迹线移动
moveMarker.moveAlong(path, {
duration: 200, // 每步时长ms,越小越快
autoRotation: true,
speed: speed.value * 100 // 速度(米/秒)
})
// 监听移动事件更新进度
moveMarker.on('movealong', () => {
progress.value = 100
isPlaying.value = false
})
}
function pauseReplay() {
isPlaying.value = false
moveMarker.pauseMove()
}
function onProgressChange(val) {
if (!moveMarker || !props.trackPoints.length) return
const idx = Math.floor(val / 100 * props.trackPoints.length)
const point = props.trackPoints[idx]
if (point) {
moveMarker.setPosition(new AMap.LngLat(point.lng, point.lat))
currentIndex.value = idx
}
}
onBeforeUnmount(() => {
if (moveMarker) moveMarker.stopMove()
if (polyline && map) map.remove(polyline)
if (moveMarker && map) map.remove(moveMarker)
})
</script>
<style scoped>
.track-replay { position: relative; }
.replay-controls {
display: flex;
align-items: center;
padding: 12px;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.speed-label { min-width: 40px; }
.time-label { min-width: 80px; font-family: monospace; }
</style>
Why:AMap.Marker.moveAlong是高德内置的轨迹回放API,自动处理沿路移动、方向旋转、暂停继续。自写动画定时器容易造成帧率不稳定和内存泄漏,用原生API省心很多。autoRotation让骑手图标始终朝前进方向,视觉效果好。
6.4 电子围栏编辑器
<!-- FenceEditor.vue -->
<template>
<div class="fence-editor">
<AMapContainer ref="mapRef" :web-key="amapWebKey" @map-ready="onMapReady" />
<div class="editor-toolbar">
<a-button type="primary" @click="startDrawing">绘制围栏</a-button>
<a-button @click="clearFence">清除</a-button>
<a-button type="primary" @click="saveFence" :disabled="!polygonPoints.length">
保存围栏
</a-button>
</div>
</div>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import AMapContainer from './AMapContainer.vue'
const emit = defineEmits(['save'])
const mapRef = ref(null)
const polygonPoints = ref([])
let AMap = null
let map = null
let polygon = null
let mouseTool = null
function onMapReady({ AMap: amap, map: m }) {
AMap = amap
map = m
mouseTool = new amap.MouseTool(map)
}
function startDrawing() {
if (!mouseTool) return
// 清除旧围栏
clearFence()
mouseTool.polygon({
strokeColor: '#ff4d4f',
strokeWeight: 2,
fillColor: '#ff4d4f',
fillOpacity: 0.15,
strokeStyle: 'dashed'
})
// 绘制完成回调
mouseTool.on('draw', (event) => {
polygon = event.obj
const path = polygon.getPath()
polygonPoints.value = path.map(p => ({
lng: p.getLng(),
lat: p.getLat()
}))
mouseTool.close(false) // 关闭绘制工具,保留图形
})
}
function clearFence() {
if (polygon && map) {
map.remove(polygon)
polygon = null
}
polygonPoints.value = []
}
async function saveFence() {
if (!polygonPoints.value.length) return
emit('save', {
coordinates: polygonPoints.value,
type: 'POLYGON',
centerLng: calcCenter(polygonPoints.value, 'lng'),
centerLat: calcCenter(polygonPoints.value, 'lat')
})
}
function calcCenter(points, axis) {
const sum = points.reduce((s, p) => s + p[axis], 0)
return sum / points.length
}
onBeforeUnmount(() => {
if (mouseTool) mouseTool.close(true)
clearFence()
})
</script>
6.5 附近搜索
<!-- NearbySearch.vue -->
<template>
<div class="nearby-search">
<div class="search-map">
<AMapContainer ref="mapRef" :web-key="amapWebKey" @map-ready="onMapReady" />
</div>
<div class="search-panel">
<a-input-number
v-model:value="radiusKm"
:min="0.5"
:max="10"
:step="0.5"
addon-after="km"
style="width: 100%; margin-bottom: 12px"
/>
<a-button type="primary" block @click="doSearch" :loading="loading">
搜索附近门店
</a-button>
<div v-if="results.length" class="result-list">
<div
v-for="item in results"
:key="item.storeId"
class="result-item"
@click="focusStore(item)"
>
<p class="store-name">{
{ item.storeName }}</p>
<p class="store-distance">{
{ item.distanceText }}</p>
<p class="store-address">{
{ item.address }}</p>
</div>
</div>
<a-empty v-else-if="searched" description="附近没有门店" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import AMapContainer from './AMapContainer.vue'
const props = defineProps({
amapWebKey: { type: String, required: true },
userLng: { type: Number, required: true },
userLat: { type: Number, required: true }
})
const mapRef = ref(null)
const radiusKm = ref(3)
const results = ref([])
const loading = ref(false)
const searched = ref(false)
let AMap = null
let map = null
let circle = null
let cluster = null
function onMapReady({ AMap: amap, map: m }) {
AMap = amap
map = m
// 标记用户位置
mapRef.value.addMarker({
lng: props.userLng,
lat: props.userLat,
title: '我的位置',
icon: new amap.Icon({
size: new amap.Size(25, 34),
image: '//a.amap.com/jsapi_demos/static/demo-center/icons/poi-marker-red.png'
})
})
}
async function doSearch() {
loading.value = true
try {
const resp = await fetch(
`/api/lbs/geo/nearby?lng=${props.userLng}&lat=${props.userLat}&radius=${radiusKm.value}&limit=50`
)
const data = await resp.json()
results.value = data.data || []
searched.value = true
renderResults()
} finally {
loading.value = false
}
}
function renderResults() {
// 清除旧的圆形范围和标记
if (circle && map) map.remove(circle)
if (cluster) cluster.setMap(null)
// 绘制搜索范围圆
circle = new AMap.Circle({
center: new AMap.LngLat(props.userLng, props.userLat),
radius: radiusKm.value * 1000,
strokeColor: '#1677ff',
strokeWeight: 1,
fillColor: '#1677ff',
fillOpacity: 0.05
})
map.add(circle)
map.setFitView([circle])
// 聚合标记
const points = results.value.map(r => ({
lng: r.longitude,
lat: r.latitude
}))
cluster = mapRef.value.addCluster(points)
}
function focusStore(item) {
map.setCenter([item.longitude, item.latitude])
map.setZoom(16)
mapRef.value.openInfoWindow(
new (window.AMap).LngLat(item.longitude, item.latitude),
`<div style="padding:8px">
<h4>${item.storeName}</h4>
<p>${item.distanceText}</p>
</div>`
)
}
</script>
<style scoped>
.nearby-search { display: flex; gap: 16px; }
.search-map { flex: 1; }
.search-panel { width: 320px; }
.result-list { max-height: 400px; overflow-y: auto; }
.result-item {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.result-item:hover { background: #f5f5f5; }
.store-name { font-weight: 500; margin: 0; }
.store-distance { color: #1677ff; font-size: 13px; margin: 2px 0; }
.store-address { color: #999; font-size: 12px; margin: 0; }
</style>
Why:附近搜索的核心是"地图+列表联动"——点击列表项定位到地图标记,地图上画圆展示搜索范围。聚合标记避免50+标记点DOM过载。搜索半径可调,用户能直观看到范围变化。
7. 进阶:高德猎鹰+实时路况+热力图
7.1 高德猎鹰轨迹服务
高德猎鹰是一套完整的轨迹追踪服务,支持终端注册、实时位置上报、围栏绑定和回调。相比自建轨迹服务,省去了上传协议设计、围栏事件计算、轨迹压缩等开发量。
/**
* 猎鹰服务集成
* 终端管理 + 轨迹上传 + 围栏绑定
*/
@Service
@Slf4j
public class FalconService {
@Autowired
private AmapConfig amapConfig;
@Autowired
private RestTemplate amapRestTemplate;
private static final String FALCON_URL = "https://tsapi.amap.com/v1/track";
/**
* 创建猎鹰服务
*/
public String createService(String name, String desc) {
String url = FALCON_URL + "/service/add"
+ "?key=" + amapConfig.getServerKey()
+ "&name=" + URLEncoder.encode(name, StandardCharsets.UTF_8)
+ "&desc=" + URLEncoder.encode(desc, StandardCharsets.UTF_8);
JSONObject resp = amapRestTemplate.postForObject(url, null, JSONObject.class);
return resp.getJSONObject("data").getString("sid"); // 服务ID
}
/**
* 添加终端(骑手)
*/
public String addTerminal(String sid, String riderName, String riderId) {
String url = FALCON_URL + "/terminal/add"
+ "?key=" + amapConfig.getServerKey()
+ "&sid=" + sid
+ "&name=" + URLEncoder.encode(riderName, StandardCharsets.UTF_8)
+ "&tid=" + riderId;
JSONObject resp = amapRestTemplate.postForObject(url, null, JSONObject.class);
return resp.getString("data"); // 终端ID
}
/**
* 绑定围栏到终端
*/
public void bindFenceToTerminal(String sid, String tid, String fenceId) {
String url = FALCON_URL + "/fence/bindTerminal"
+ "?key=" + amapConfig.getServerKey()
+ "&sid=" + sid
+ "&tid=" + tid
+ "&fenceid=" + fenceId;
amapRestTemplate.postForObject(url, null, JSONObject.class);
}
}
Why:猎鹰的优势在于围栏回调是高德服务端主动推送,不需要业务系统自己定时轮询判断骑手是否进入/离开围栏,实时性更好(延迟<1秒),且不占用业务服务器算力。
7.2 实时路况
<script setup>
// 在地图上叠加实时路况图层
function addTrafficLayer(map, AMap) {
const trafficLayer = new AMap.TileLayer.Traffic({
zIndex: 10,
autoRefresh: true, // 自动刷新(每5分钟)
interval: 300 // 刷新间隔秒数
})
trafficLayer.setMap(map)
return trafficLayer
}
</script>
Why:实时路况图层对配送调度至关重要——调度员能看到哪条路拥堵,优先派单给不堵路线的骑手。autoRefresh保证路况数据5分钟更新一次。
7.3 热力图
<script setup>
// 骑手分布热力图
function addHeatMap(map, AMap, riderPositions) {
const heatmapData = riderPositions.map(p => ({
lng: p.lng,
lat: p.lat,
count: p.orderCount // 以订单数为权重
}))
const heatmap = new AMap.HeatMap(map, {
radius: 30,
opacity: [0, 0.8],
gradient: {
0.2: '#1a9850',
0.4: '#91cf60',
0.6: '#fee08b',
0.8: '#fc8d59',
1.0: '#d73027'
}
})
heatmap.setDataSet({ data: heatmapData, max: 100 })
return heatmap
}
</script>
Why:热力图直观展示骑手/订单密集区域,辅助运力调度——红色区域订单多骑手少,需要调度周边骑手前往。渐变色从绿到红,语义清晰。
8. 量化对比:自建 vs Redis GEO vs 高德API
| 维度 | 自建Haversine | Redis GEO | 高德API |
|---|---|---|---|
| 附近搜索性能 | 500ms(全表扫描) | 15ms(GEOSEARCH) | 200ms(网络IO) |
| 距离计算精度 | 直线距离(误差60%+) | 直线距离(误差60%+) | 路网距离(误差<5%) |
| QPS上限 | 受限于DB(~500) | 单机10万+ | 5000(个人)/ 30万(企业) |
| 路径规划 | ❌ 不支持 | ❌ 不支持 | ✅ 驾车/步行/骑行/公交 |
| 逆地理编码 | 需自建POI库 | ❌ 不支持 | ✅ 全国覆盖 |
| 成本 | 服务器+运维 | Redis实例费用 | API调用量计费 |
选型建议:
- 附近搜索+距离计算 → Redis GEO(性能最优、成本最低)
- 路网距离+路径规划 → 高德API(精度最高、数据最全)
- 围栏判断 → 自建Ray-Casting + Redis GEO粗筛(灵活可控)
- 轨迹追踪 → 高德猎鹰(免开发、围栏回调完善)
- 三级架构:Redis GEO做初筛 → 高德API做精算 → PolarDB做持久化
9. 踩坑实录
踩坑1:高德API配额耗尽导致全线熔断
现象:某天11:00开始,所有地理编码接口报错status=0, info=DAILY_QUERY_OVER_LIMIT。
原因:前端页面上有一个"附近门店"组件,每次路由切换都重新调逆地理编码API,没有做缓存。日配额5000次,上午就耗尽了。
修复:
// 配额监控 + 降级
@Aspect
@Component
@Slf4j
public class AmapQuotaAspect {
private final AtomicLong dailyCount = new AtomicLong(0);
private static final long WARNING_THRESHOLD = 4000; // 80%告警
private static final long BLOCK_THRESHOLD = 4500; // 90%限流
@Around("execution(* com.example.lbs.service.Amap*.*(..))")
public Object checkQuota(ProceedingJoinPoint pjp) throws Throwable {
long count = dailyCount.incrementAndGet();
if (count > BLOCK_THRESHOLD) {
log.error("高德API配额即将耗尽, count={}, 降级返回缓存", count);
throw new BusinessException("位置服务繁忙,请稍后重试");
}
if (count > WARNING_THRESHOLD) {
log.warn("高德API配额使用超过80%, count={}", count);
}
return pjp.proceed();
}
// 每日零点重置
@Scheduled(cron = "0 0 0 * * ?")
public void resetCount() {
dailyCount.set(0);
}
}
经验:配额管理必须前置——不是等耗尽了再处理,而是在80%时告警、90%时限流降级。
踩坑2:Redis GEO精度误差导致配送范围误判
现象:部分用户明明在3km配送范围内,却被判定"超出范围"。
原因:Redis GEO底层用GeoHash编码,52位精度约0.6米误差。但问题出在Redis GEO的GEORADIUS返回的距离是球面距离(WGS84椭球体),而我们用的围栏坐标是GCJ02坐标系。两套坐标系的距离计算结果有1~3米的差异,刚好在围栏边界处导致误判。
修复:
// 围栏判断增加50米缓冲区,避免边界精度问题
public boolean isPointInFence(Double lng, Double lat, Geofence fence) {
List<Coordinate> polygon = parseCoordinates(fence.getCoordinates());
boolean inside = isPointInPolygon(lng, lat, polygon);
// 对边界附近的点做二次确认:用高德API计算路网距离
if (!inside) {
Distance nearestEdge = calcDistanceToNearestEdge(lng, lat, polygon);
if (nearestEdge != null && nearestEdge.getValue() < 50) {
// 50米缓冲区
return checkByAmapRoute(lng, lat, fence); // 高德API精确判断
}
}
return inside;
}
经验:坐标系不一致是LBS领域的经典坑。国内地图必须用GCJ02(国测局坐标),WGS84(GPS原始坐标)需要先转换再入Redis GEO。
踩坑3:地图组件内存泄漏
现象:SPA应用中频繁切换页面后,浏览器内存从200MB涨到2GB,最终卡死。
原因:Vue3路由切换时,AMap地图实例没有被正确销毁。map.destroy()虽然调了,但Marker、InfoWindow、Polyline等覆盖物引用还在闭包里,GC无法回收。
修复:
<script setup>
// 所有覆盖物集中管理,销毁时统一清除
const overlays = []
function addOverlay(overlay) {
overlays.push(overlay)
return overlay
}
onBeforeUnmount(() => {
// 先清除所有覆盖物
overlays.forEach(o => {
if (o.setMap) o.setMap(null) // 解除与地图的绑定
})
overlays.length = 0
// 再销毁地图
const map = mapInstance.value
if (map) {
map.clearMap() // 清除所有覆盖物
map.destroy() // 销毁地图实例
mapInstance.value = null
}
// 清除全局引用
window.AMap = undefined
})
</script>
经验:AMap实例必须clearMap()+destroy()双清,覆盖物必须setMap(null)断开引用。建议用shallowRef避免Vue深度代理。
踩坑4:围栏判断的边界case
现象:骑手沿围栏边界骑行时,频繁触发"进入-离开-进入"事件抖动。
原因:GPS定位本身有5~15米误差,骑手在围栏边界附近时,定位点的随机漂移导致Ray-Casting判断结果频繁翻转。
修复:
// 防抖:状态变更后30秒内不再触发同向事件
public class FenceStateCache {
private final Cache<String, String> stateCache = CacheBuilder.newBuilder()
.expireAfterWrite(30, TimeUnit.SECONDS)
.build();
public boolean shouldEmitEvent(String riderId, String fenceId, String action) {
String key = riderId + ":" + fenceId;
String lastAction = stateCache.getIfPresent(key);
// 同一骑手同一围栏,30秒内不重复触发同向事件
if (action.equals(lastAction)) {
return false;
}
stateCache.put(key, action);
return true;
}
}
经验:围栏事件必须加防抖,否则GPS漂移+边界抖动会导致事件风暴。30秒防抖窗口是实践验证的合理值。
踩坑5:轨迹数据量过大导致查询超时
现象:查询某骑手一周轨迹时,PolarDB查询超时(>30秒)。
原因:轨迹表已有2亿行,即使有(rider_id, timestamp)联合索引,单骑手一周数据量也在4万行左右,排序+序列化耗时大。
修复:
-- 方案1:分区表按月分区
ALTER TABLE lbs_track_point PARTITION BY RANGE (TO_DAYS(timestamp)) (
PARTITION p202601 VALUES LESS THAN (TO_DAYS('2026-02-01')),
PARTITION p202602 VALUES LESS THAN (TO_DAYS('2026-03-01')),
-- ...
);
-- 方案2:只查压缩后的轨迹(Douglas-Peucker后约1/20数据量)
CREATE TABLE lbs_track_point_compressed (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
rider_id VARCHAR(32) NOT NULL,
point_list JSON NOT NULL, -- 压缩后的点集
date DATE NOT NULL,
point_count INT DEFAULT 0,
UNIQUE KEY uk_rider_date (rider_id, date)
);
经验:轨迹数据必须冷热分离+压缩存储。热数据(1小时内)在Redis,温数据(7天内)在PolarDB分区表,冷数据(7天以上)归档OSS。查询时优先查压缩表,需要原始精度时才查明细表。
10. 最佳实践
10.1 LBS方案选型决策树

10.2 LBS项目检查清单
✅ 上线前必查:
坐标系
□ 所有坐标统一使用GCJ02(高德坐标系)
□ GPS原始坐标(WGS84)已做坐标偏转
□ 数据库存储的经纬度字段精度至少6位小数
配额管理
□ 高德API日调用量监控已配置
□ 超过80%配额时自动告警
□ 超过90%配额时自动降级(走缓存)
□ 前端避免重复调用逆地理编码
性能
□ Redis GEO已批量导入门店位置
□ 附近搜索优先走Redis GEO而非SQL
□ 轨迹数据有冷热分离策略
□ 地图覆盖物在组件销毁时正确释放
精度
□ 配送范围判断有50米缓冲区
□ 围栏事件有30秒防抖
□ 距离计算区分直线/路网
安全
□ 高德Web服务Key配置IP白名单
□ Web端Key配置域名防盗用
□ 敏感坐标数据脱敏后再展示
总结
LBS位置服务的核心不是"存个经纬度",而是围绕"搜索精度、围栏实时性、轨迹成本、地图性能、编码效率"五个维度做体系化建设。我的实战经验总结为三句话:
Redis GEO解决"搜得快"——附近搜索15ms,距离计算0.1ms,是LBS的性能底座。
高德API解决"算得准"——路网距离、路径规划、逆地理编码,这些靠自建成本极高,用高德API是正确选择。
三级存储解决"存得住"——Redis热数据+PolarDB温数据+OSS冷数据,轨迹从日增576万点到查询15ms,靠的是冷热分离和Douglas-Peucker压缩。
📜 真实性声明
本文所有内容均基于作者在2025年Q3参与的本地生活平台LBS改造项目中的真实经验。所有案例、数据、代码均来自生产环境,经过实践验证。为保护商业机密,部分敏感信息已做脱敏处理,但技术细节保持完整和真实。
如有任何疑问,欢迎在评论区交流讨论。