概述
「附近的人」功能的第一步不是查询,而是有人愿意把位置交出来:客户端在合适的时机上报经纬度,服务端决定存到哪、怎么存、存成什么结构。本文围绕这三个决策展开,并给出从 Controller 到 MongoTemplate 的完整代码与验证。
背景与业务场景
搜附近的查询逻辑再精巧,前提也是库里有位置数据。这些数据没有第三方来源,只能靠用户主动上报:App 打开定位权限后拿到当前经纬度,发一条请求给服务端,服务端落库,后续搜附近才有查询对象。
第一个要想清楚的问题是上报时机。位置不是静态的,用户会移动,总不能每秒都上报一次。频繁上报有三个代价:
- 耗电:GPS 持续工作是手机功耗大户;
- 耗流量:请求本身虽小,乘上用户量就是不小的带宽;
- 隐私敏感:持续上报等于持续追踪用户轨迹,用户感知到的「被监控感」会直接伤害产品口碑。
业界常见的做法是在客户端定规则:当检测到位置变化大于 500 米时,或每隔 5 分钟,向服务端上报一次地理位置。两种触发条件的侧重不同:
| 上报策略 | 特点 | 问题 |
|---|---|---|
| 每次定位变化都上报 | 数据最实时 | 耗电、请求量大、隐私感知差 |
| 纯定时上报(如 5 分钟) | 实现简单,频率可控 | 用户移动后 5 分钟内数据是旧的 |
| 纯位移阈值上报(如 500 米) | 不动就不报,省流量 | 长时间静止则数据一直「陈旧」,但语义上没错 |
| 定时 + 位移阈值(工程采用) | 静止时兜底刷新,移动时及时跟进 | 客户端逻辑稍复杂 |
注意一个语义细节:上报到服务端的位置,含义是「用户当前所在地」而不是「历史轨迹」。这个语义决定了后面存储方案的选择,每用户只需要一条最新记录,旧位置直接覆盖即可,不需要保留历史。
至于「如何定位」本身(GPS/基站/WiFi 混合定位、权限申请),全是客户端的事,服务端只管接收。接口层面的服务端职责就一句话:拿到经纬度和位置描述,保存或更新到数据库。
存储设计:位置到底放哪
这是本文的核心决策。可选项有三个:
| 维度 | 方案 A:更新 movement 文档 | 方案 B:独立位置集合,覆盖更新 | 方案 C:Redis GEO |
|---|---|---|---|
| 语义 | 给「某条动态」挂位置,一条动态一个位置 | 「用户当前在哪」,一用户一条最新记录 | 同 B,当前所在地 |
| 时效性 | 动态发布后就固化,不随用户移动更新 | 每次上报覆盖,永远是最新的 | 每次上报覆盖,最新 |
| 查询效率 | 动态集合文档大、数量多,地理查询成本高 | 集合小而干净,建 2dsphere 索引直接查 | GEO 底层 zset,查询快 |
| 存储成本 | 与动态耦合,重复上报产生冗余 | 每用户一条,成本最低 | 内存存储,成本最高 |
| 持久性 | 强 | 强 | 弱(重启/淘汰策略可能丢数据) |
| 隐私可控性 | 位置混在动态里,难以单独清除 | 独立集合,删一条即清除全部位置数据 | 可删除,但需额外同步 |
| 适用场景 | 「带定位的动态」(发动态时打卡) | 「当前所在地」语义的搜附近 | 已有 Redis 且能接受数据可能丢失 |
三个方案其实回答了两个不同的问题。movement 上的位置回答「这条动态是在哪发的」,那是发动态功能(Movement 实体里有 longitude/latitude/locationName 三个字段)的职责;user_location 回答「这个人现在在哪」,这才是搜附近要的。Redis GEO 查询快,但作为位置这种需要长期稳定存在的数据,纯内存存储的持久性短板明显,且后续还要和 Mongo 里的用户数据做关联,多一份一致性负担。
工程结论:采用方案 B ,独立的 user_location 集合,一个用户一条文档,每次上报覆盖更新,GeoJsonPoint 存坐标并配 2dsphere 索引。位置上报和搜附近查询共用一个 UserLocationApi。
上报链路涉及四个模块,结构如下:
tree
mf
├── mf-app-server # 消费者:接收 App 请求
│ └── src/main/java/com/mf/server
│ ├── controller
│ │ └── BaiduController.java # POST /baidu/location
│ └── service
│ └── BaiduService.java # 调用 dubbo 服务上报
├── mf-dubbo
│ ├── mf-dubbo-interface # 服务接口
│ │ └── src/main/java/com/mf/dubbo/api
│ │ └── UserLocationApi.java
│ └── mf-dubbo-mongo # 服务提供者:操作 MongoDB
│ └── src/main/java/com/mf/dubbo/api
│ └── UserLocationApiImpl.java
└── mf-model # 实体
└── src/main/java/com/mf/model/mongo
└── UserLocation.java # user_location 集合实体
数据结构:UserLocation 与 GeoJsonPoint
实体定义如下:
java
package com.mf.model.mongo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.geo.GeoJsonPoint;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "user_location")
@CompoundIndex(name = "location_index", def = "{'location': '2dsphere'}")
public class UserLocation implements java.io.Serializable {
private static final long serialVersionUID = 4508868382007529970L;
@Id
private ObjectId id;
@Indexed
private Long userId; // 用户 id
private GeoJsonPoint location; // x:经度 y:纬度,GeoJSON Point 结构
private String address; // 位置描述
private Long created; // 创建时间
private Long updated; // 更新时间
private Long lastUpdated; // 上次更新时间
}
几个设计点:
location用GeoJsonPoint而不是两个 Double 散字段,这样 MongoDB 才能建2dsphere索引、支持球面距离查询。2dsphere 与 GeoJSON 的原理在MongoDB地理位置:概述和分析一篇已经展开,这里不重复。userId加普通索引,因为「根据用户 id 找位置」是上报更新和搜附近起点的必经查询。lastUpdated记录上一次的更新时间,updated记录本次,覆盖更新会抹掉旧数据,这两个字段留下了一条「上次是什么时候」的线索。
Dubbo 传输的序列化坑
这里有个容易踩的坑:GeoJsonPoint 不支持 Java 序列化 ,而 Dubbo 默认走 hessian2 序列化,消费者(mf-app-server)和提供者(mf-dubbo-mongo)之间不能直接传这个对象。所以接口签名里传的是裸的 Double longitude, Double latitude,由提供者在 API 实现层内部才封装成 GeoJsonPoint:
java
package com.mf.dubbo.api;
import java.util.List;
public interface UserLocationApi {
// 更新地理位置
Boolean updateLocation(Long userId, Double longitude, Double latitude, String address);
// 查询附近的人的所有用户 id
List<Long> queryNearUser(Long userId, Double metre);
}
如果在 Service 层就把经纬度封装成 GeoJsonPoint 再调 API,会在 dubbo 序列化这一步直接报错,别这么写。
String 经纬度转 GeoJsonPoint
工程里 Movement 实体的位置是 String longitude/latitude/locationName 三个散字段,发动态时的「打卡」场景,没有地理查询需求,所以没建索引。但如果想让动态位置也参与 2dsphere 查询,就需要把 String 转成 GeoJSON 结构。转换本身很简单:
java
import org.springframework.data.mongodb.core.geo.GeoJsonPoint;
public GeoJsonPoint convert(String longitude, String latitude) {
double lng = Double.parseDouble(longitude.trim());
double lat = Double.parseDouble(latitude.trim());
// 经度在前(x),纬度在后(y),顺序写反查询结果会整体偏移
return new GeoJsonPoint(lng, lat);
}
对应的改造路径(呼应搜附近快速入门一篇中提到的差距点):给 Movement 增加一个 GeoJsonPoint location 字段,存量数据用迁移脚本把散字段转换回填,再在 location 上建 2dsphere 索引。user_location 集合从设计之初就是 GeoJSON 结构,没有这个历史包袱。
接口设计
| 项 | 内容 |
|---|---|
| 接口地址 | /baidu/location(名字源自百度定位 SDK,历史原因保留) |
| 请求方式 | POST |
| 请求参数 | longitude(经度)、latitude(纬度)、addrStr(位置中文描述) |
| 响应 | 无业务数据,状态码 200 即成功 |
| 认证 | 依赖登录拦截器,UserHolder.getUserId() 取当前用户 |
请求体示例:
json
{
"longitude": "116.353885",
"latitude": "40.065911",
"addrStr": "育新地铁站"
}
Controller 层实读代码:
java
package com.mf.server.controller;
import com.mf.server.service.BaiduService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/baidu")
public class BaiduController {
@Autowired
private BaiduService baiduService;
/**
* 更新位置
*/
@PostMapping("/location")
public ResponseEntity updateLocation(@RequestBody Map param) {
Double longitude = Double.valueOf(param.get("longitude").toString());
Double latitude = Double.valueOf(param.get("latitude").toString());
String address = param.get("addrStr").toString();
this.baiduService.updateLocation(longitude, latitude, address);
return ResponseEntity.ok(null);
}
}
入参用 Map 接而不是 VO,是因为客户端传的字段名(longitude / latitude / addrStr)来自百度地图 SDK 的回调结构,字段语义跟业务 VO 对不上。注意 addrStr 这个键名,前端最容易传成 address 然后 NPE。
Service 层通过 dubbo 引用 UserLocationApi,失败时抛业务异常:
java
package com.mf.server.service;
import com.mf.dubbo.api.UserLocationApi;
import com.mf.model.vo.ErrorResult;
import com.mf.server.exception.BusinessException;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.stereotype.Service;
@Service
public class BaiduService {
@DubboReference
private UserLocationApi userLocationApi;
// 更新地理位置
public void updateLocation(Double longitude, Double latitude, String address) {
Boolean flag = userLocationApi.updateLocation(UserHolder.getUserId(), longitude, latitude, address);
if (!flag) {
throw new BusinessException(ErrorResult.error());
}
}
}
这里有个安全细节:userId 从登录态取,不信客户端上报的任何身份字段,杜绝「替别人上报位置」的伪造。
上报链路
MongoDB user_location UserLocationApiImpl (dubbo 提供者) BaiduService BaiduController App 客户端 MongoDB user_location UserLocationApiImpl (dubbo 提供者) BaiduService BaiduController App 客户端 #mermaid-svg-whqQJnsQYvZ6Kb1t{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-whqQJnsQYvZ6Kb1t .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-whqQJnsQYvZ6Kb1t .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-whqQJnsQYvZ6Kb1t .error-icon{fill:#552222;}#mermaid-svg-whqQJnsQYvZ6Kb1t .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-whqQJnsQYvZ6Kb1t .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-whqQJnsQYvZ6Kb1t .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-whqQJnsQYvZ6Kb1t .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-whqQJnsQYvZ6Kb1t .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-whqQJnsQYvZ6Kb1t .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-whqQJnsQYvZ6Kb1t .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-whqQJnsQYvZ6Kb1t .marker{fill:#333333;stroke:#333333;}#mermaid-svg-whqQJnsQYvZ6Kb1t .marker.cross{stroke:#333333;}#mermaid-svg-whqQJnsQYvZ6Kb1t svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-whqQJnsQYvZ6Kb1t p{margin:0;}#mermaid-svg-whqQJnsQYvZ6Kb1t .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-whqQJnsQYvZ6Kb1t text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-whqQJnsQYvZ6Kb1t .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-whqQJnsQYvZ6Kb1t .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-whqQJnsQYvZ6Kb1t .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-whqQJnsQYvZ6Kb1t .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-whqQJnsQYvZ6Kb1t #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-whqQJnsQYvZ6Kb1t .sequenceNumber{fill:white;}#mermaid-svg-whqQJnsQYvZ6Kb1t #sequencenumber{fill:#333;}#mermaid-svg-whqQJnsQYvZ6Kb1t #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-whqQJnsQYvZ6Kb1t .messageText{fill:#333;stroke:none;}#mermaid-svg-whqQJnsQYvZ6Kb1t .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-whqQJnsQYvZ6Kb1t .labelText,#mermaid-svg-whqQJnsQYvZ6Kb1t .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-whqQJnsQYvZ6Kb1t .loopText,#mermaid-svg-whqQJnsQYvZ6Kb1t .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-whqQJnsQYvZ6Kb1t .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-whqQJnsQYvZ6Kb1t .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-whqQJnsQYvZ6Kb1t .noteText,#mermaid-svg-whqQJnsQYvZ6Kb1t .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-whqQJnsQYvZ6Kb1t .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-whqQJnsQYvZ6Kb1t .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-whqQJnsQYvZ6Kb1t .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-whqQJnsQYvZ6Kb1t .actorPopupMenu{position:absolute;}#mermaid-svg-whqQJnsQYvZ6Kb1t .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-whqQJnsQYvZ6Kb1t .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-whqQJnsQYvZ6Kb1t .actor-man circle,#mermaid-svg-whqQJnsQYvZ6Kb1t line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-whqQJnsQYvZ6Kb1t :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 首次上报(无记录) 已有记录 数据就位,供搜附近 geoNear/withinSphere 查询 定位 SDK 获取经纬度 (位移>500m 或 间隔5分钟) POST /baidu/location {longitude, latitude, addrStr} updateLocation(lng, lat, address) dubbo 调用 updateLocation(userId, lng, lat, address) 按 userId 查询 user_location save 新文档 created=updated=lastUpdated=now updateFirst 覆盖更新 location=新坐标, updated=now lastUpdated=旧updated true 成功 200
提供者实现的关键逻辑,首次保存、后续覆盖更新:
java
package com.mf.dubbo.api;
import com.mf.dubbo.api.UserLocationApi;
import com.mf.model.mongo.UserLocation;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.geo.GeoJsonPoint;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
@DubboService
public class UserLocationApiImpl implements UserLocationApi {
@Autowired
private MongoTemplate mongoTemplate;
// 更新地理位置
public Boolean updateLocation(Long userId, Double longitude, Double latitude, String address) {
try {
// 1、根据用户 id 查询位置信息
Query query = Query.query(Criteria.where("userId").is(userId));
UserLocation location = mongoTemplate.findOne(query, UserLocation.class);
if (location == null) {
// 2、首次上报,保存
location = new UserLocation();
location.setUserId(userId);
location.setAddress(address);
location.setCreated(System.currentTimeMillis());
location.setUpdated(System.currentTimeMillis());
location.setLastUpdated(System.currentTimeMillis());
location.setLocation(new GeoJsonPoint(longitude, latitude));
mongoTemplate.save(location);
} else {
// 3、已有记录,覆盖更新
Update update = Update.update("location", new GeoJsonPoint(longitude, latitude))
.set("updated", System.currentTimeMillis())
.set("lastUpdated", location.getUpdated());
mongoTemplate.updateFirst(query, update, UserLocation.class);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
这段代码是「当前所在地」语义的直接落地:不管上报多少次,user_location 里每个用户永远只有一条文档,位置被新数据覆盖,lastUpdated 保留上一次的时间戳。
一个可以推敲的地方:查询再更新是两步操作,并发上报(多端登录、请求重试)时存在竞态,理论上可能插入两条相同 userId 的文档。工程里 userId 只是普通索引不是唯一索引,兜底手段是把 @Indexed 升级为 @Indexed(unique = true) 并配合 findAndModify,或者干脆用 upsert 一步完成。教学工程按两步写,生产环境建议收紧。
上报频率与隐私的工程要点
讲义的 500 米/5 分钟规则解决了「多久报一次」,落到真实产品还有一层必须考虑的东西,位置属于敏感个人信息,设计上报时不能只顾功能可用:
| 控制点 | 做法 | 说明 |
|---|---|---|
| 开关控制 | 设置页提供「位置信息」开关,关闭后客户端停报 | 用户要有随时撤回授权的能力,这是合规底线 |
| 精度模糊化 | 服务端入库前对经纬度截断,保留两位小数 | 两位小数约 1.1km 精度,足以支撑「附近的人」,但定位不到具体楼宇 |
| 可清除 | 注销/关闭功能时删除 user_location 文档 | 独立集合的好处在这里兑现:一条 delete 即清除全部位置数据 |
| 频控 | 服务端按 userId 限流,如 1 分钟内最多 1 次写库 | 防止异常客户端高频上报拖垮数据库 |
频控的最小实现,用 Redis 计数即可:
java
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Duration;
public boolean allowReport(StringRedisTemplate redisTemplate, Long userId) {
String key = "location:freq:" + userId;
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
redisTemplate.expire(key, Duration.ofMinutes(1));
}
return count != null && count <= 1;
}
精度模糊化放在服务端做而不是客户端,原因是:客户端代码可被篡改,服务端入库前截断才是真正对外的精度上限;而且同一个数据源还能保留全精度字段用于风控,展示层再降精度。
完整代码实现
文件清单
| 模块 | 文件 | 职责 |
|---|---|---|
| mf-model | UserLocation.java |
位置文档实体,声明 user_location 集合与 2dsphere 索引 |
| mf-dubbo-interface | UserLocationApi.java |
dubbo 接口,方法签名只传原始经纬度 |
| mf-dubbo-mongo | UserLocationApiImpl.java |
写入实现:不存在则 save,存在则 updateFirst |
| mf-app-server | BaiduController.java |
POST /baidu/location,接收 Map 参数并转型 |
| mf-app-server | BaiduService.java |
从 UserHolder 取当前用户 id,调 dubbo |
tree
mf
├── mf-model
│ └── src/main/java/com/mf/model/mongo
│ └── UserLocation.java # 位置文档实体(GeoJsonPoint + 2dsphere)
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── src/main/java/com/mf/dubbo/api
│ │ └── UserLocationApi.java # dubbo 接口:上报 + 搜附近
│ └── mf-dubbo-mongo
│ └── src/main/java/com/mf/dubbo/api
│ └── UserLocationApiImpl.java # 实现:覆盖更新 / 圆球查询
└── mf-app-server
└── src/main/java/com/mf/server
├── controller/BaiduController.java # 接收上报请求
└── service/BaiduService.java # 组装 userId 调 dubbo
为什么叫 BaiduController?因为客户端定位用的是百度地图 SDK,上报接口命名跟了地图厂商,与业务本身无关。
UserLocation 实体
java
package com.mf.model.mongo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.geo.GeoJsonPoint;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "user_location")
//location 字段建 2dsphere 地理索引,地理查询的硬前提
@CompoundIndex(name = "location_index", def = "{'location': '2dsphere'}")
public class UserLocation implements java.io.Serializable{
private static final long serialVersionUID = 4508868382007529970L;
@Id
private ObjectId id;
@Indexed
private Long userId; //用户id
private GeoJsonPoint location; //x:经度 y:纬度
private String address; //位置描述
private Long created; //创建时间
private Long updated; //更新时间
private Long lastUpdated; //上次更新时间
}
三个注解各管一件事:
@Document(collection = "user_location"):映射到 MongoDB 的user_location集合@CompoundIndex(def = "{'location': '2dsphere'}):在location字段上建 2dsphere 索引。没有它,后面的withinSphere查询直接报错@Indexed加在userId上:覆盖更新前要按 userId 找旧记录,这条索引把 findOne 从集合扫描变成点查
updated 与 lastUpdated 两个字段并存有点绕:updated 是本次上报时间,lastUpdated 存的是「上一次的 updated」。覆盖更新时把旧 updated 挪进 lastUpdated,等于白送一个「上次位置更新于何时」的字段,可以做上报频次的观测。
BaiduController
java
package com.mf.server.controller;
import com.mf.server.service.BaiduService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/baidu")
public class BaiduController {
@Autowired
private BaiduService baiduService;
/**
* 更新位置
*/
@PostMapping("/location")
public ResponseEntity updateLocation(@RequestBody Map param) {
//客户端传来的经纬度是字符串形式,先转 Double
Double longitude = Double.valueOf(param.get("longitude").toString());
Double latitude = Double.valueOf(param.get("latitude").toString());
String address = param.get("addrStr").toString();
this.baiduService.updateLocation(longitude, latitude, address);
return ResponseEntity.ok(null);
}
}
入参用 Map 接而不是 VO,是因为客户端传的字段名(longitude / latitude / addrStr)来自百度地图 SDK 的回调结构,字段语义跟业务 VO 对不上。注意 addrStr 这个键名,前端最容易传成 address 然后 NPE。
BaiduService
java
package com.mf.server.service;
import com.mf.dubbo.api.UserLocationApi;
import com.mf.model.vo.ErrorResult;
import com.mf.server.exception.BusinessException;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.stereotype.Service;
@Service
public class BaiduService {
@DubboReference
private UserLocationApi userLocationApi;
//更新地理位置
public void updateLocation(Double longitude, Double latitude, String address) {
//userId 不走参数,从登录态拦截器塞进来的 UserHolder 里取
Boolean flag = userLocationApi.updateLocation(
UserHolder.getUserId(), longitude, latitude, address);
if(!flag) {
throw new BusinessException(ErrorResult.error());
}
}
}
安全上这个细节值得说:userId 从登录态取,不信客户端上报的任何身份字段,杜绝「替别人上报位置」的伪造。
UserLocationApi 接口
java
package com.mf.dubbo.api;
public interface UserLocationApi {
//更新地理位置
Boolean updateLocation(Long userId, Double longitude, Double latitude, String address);
}
签名里是四个裸参数,没有 GeoJsonPoint,原因见坑表第一条。
UserLocationApiImpl 实现
java
package com.mf.dubbo.api;
import com.mf.dubbo.api.UserLocationApi;
import com.mf.model.mongo.UserLocation;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.geo.GeoJsonPoint;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
@DubboService
public class UserLocationApiImpl implements UserLocationApi {
@Autowired
private MongoTemplate mongoTemplate;
//更新地理位置
public Boolean updateLocation(Long userId, Double longitude, Double latitude, String address) {
try {
//1、按 userId 查现有位置记录(userId 有 @Indexed,点查)
Query query = Query.query(Criteria.where("userId").is(userId));
UserLocation location = mongoTemplate.findOne(query, UserLocation.class);
if(location == null) {
//2、首次上报:构造完整文档后 save
location = new UserLocation();
location.setUserId(userId);
location.setAddress(address);
location.setCreated(System.currentTimeMillis());
location.setUpdated(System.currentTimeMillis());
location.setLastUpdated(System.currentTimeMillis());
//经度在前,纬度在后
location.setLocation(new GeoJsonPoint(longitude, latitude));
mongoTemplate.save(location);
} else {
//3、非首次:覆盖更新,把旧 updated 挪到 lastUpdated
Update update = Update.update("location", new GeoJsonPoint(longitude, latitude))
.set("updated", System.currentTimeMillis())
.set("lastUpdated", location.getUpdated());
mongoTemplate.updateFirst(query, update, UserLocation.class);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
核心分支就一个判断:查得到旧记录就 updateFirst 覆盖,查不到就 save 新建。_id 不变,一个用户在 user_location 集合里永远只有一条文档,坐标永远是他最新上报的那次。
调用时序图
MongoDB UserLocationApiImpl BaiduService BaiduController 客户端 MongoDB UserLocationApiImpl BaiduService BaiduController 客户端 #mermaid-svg-McLxeHKJy7gDFmD0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-McLxeHKJy7gDFmD0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-McLxeHKJy7gDFmD0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-McLxeHKJy7gDFmD0 .error-icon{fill:#552222;}#mermaid-svg-McLxeHKJy7gDFmD0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-McLxeHKJy7gDFmD0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-McLxeHKJy7gDFmD0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-McLxeHKJy7gDFmD0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-McLxeHKJy7gDFmD0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-McLxeHKJy7gDFmD0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-McLxeHKJy7gDFmD0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-McLxeHKJy7gDFmD0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-McLxeHKJy7gDFmD0 .marker.cross{stroke:#333333;}#mermaid-svg-McLxeHKJy7gDFmD0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-McLxeHKJy7gDFmD0 p{margin:0;}#mermaid-svg-McLxeHKJy7gDFmD0 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-McLxeHKJy7gDFmD0 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-McLxeHKJy7gDFmD0 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-McLxeHKJy7gDFmD0 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-McLxeHKJy7gDFmD0 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-McLxeHKJy7gDFmD0 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-McLxeHKJy7gDFmD0 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-McLxeHKJy7gDFmD0 .sequenceNumber{fill:white;}#mermaid-svg-McLxeHKJy7gDFmD0 #sequencenumber{fill:#333;}#mermaid-svg-McLxeHKJy7gDFmD0 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-McLxeHKJy7gDFmD0 .messageText{fill:#333;stroke:none;}#mermaid-svg-McLxeHKJy7gDFmD0 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-McLxeHKJy7gDFmD0 .labelText,#mermaid-svg-McLxeHKJy7gDFmD0 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-McLxeHKJy7gDFmD0 .loopText,#mermaid-svg-McLxeHKJy7gDFmD0 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-McLxeHKJy7gDFmD0 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-McLxeHKJy7gDFmD0 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-McLxeHKJy7gDFmD0 .noteText,#mermaid-svg-McLxeHKJy7gDFmD0 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-McLxeHKJy7gDFmD0 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-McLxeHKJy7gDFmD0 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-McLxeHKJy7gDFmD0 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-McLxeHKJy7gDFmD0 .actorPopupMenu{position:absolute;}#mermaid-svg-McLxeHKJy7gDFmD0 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-McLxeHKJy7gDFmD0 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-McLxeHKJy7gDFmD0 .actor-man circle,#mermaid-svg-McLxeHKJy7gDFmD0 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-McLxeHKJy7gDFmD0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 无记录(首次上报) 有记录(覆盖更新) POST /baidu/location (longitude/latitude/addrStr) String 转 Double updateLocation(longitude, latitude, address) UserHolder.getUserId() 取当前用户 updateLocation(userId, longitude, latitude, address) findOne({userId}) new GeoJsonPoint(longitude, latitude) save(UserLocation) new GeoJsonPoint(longitude, latitude) updateFirst({userId}, {location, updated, lastUpdated}) true void 200 OK
异常与边界
| 异常场景 | 现象 | 处理方式 |
|---|---|---|
| 经纬度非法(超范围、非数字) | Double.valueOf 抛 NumberFormatException,或坐标语义错误 |
服务端范围校验:经度 -180,180、纬度 -90,90,不合法直接拒绝 |
| 上报过频 | 同一用户短时间大量写库 | Redis 频控(见上),超限直接返回成功但跳过写库 |
| App 位置服务关闭 | 客户端拿不到坐标 | 客户端兜底:引导开启定位,搜附近页展示「未开启定位」空态;服务端收不到请求即可,无需特殊处理 |
| 坐标体系不一致 | 位置整体偏移几百米 | 国内地图 SDK(百度/高德/腾讯)返回的是各自加偏坐标系(BD-09/GCJ-02),与 GPS 原始 WGS84 存在偏移;采集和入库必须用同一坐标系,混用会出现「人在 A 点库里有 B 点」的诡异现象 |
| dubbo 序列化失败 | 调用 UserLocationApi 直接报错 |
检查是否把 GeoJsonPoint 传过了接口边界,跨服务只传 Double |
| 首次上报并发 | 可能插入重复 userId 文档 | userId 唯一索引 + upsert(工程未做,生产建议补上) |
经纬度范围校验建议加在 Controller 解析参数之后:
java
private void checkRange(double longitude, double latitude) {
if (longitude < -180 || longitude > 180) {
throw new BusinessException(ErrorResult.error("经度超出合法范围"));
}
if (latitude < -90 || latitude > 90) {
throw new BusinessException(ErrorResult.error("纬度超出合法范围"));
}
}
坐标体系这里只点一个坑:如果定位用的百度 SDK(BD-09)而 distance 计算或第三方 POI 数据用 WGS84,500 米的查询半径实际会查出偏移后的错误人群。项目工程全程百度系坐标,链路统一,问题不会暴露;一旦引入其他坐标系的数据源,先转换再入库。
验证
启动 mf-dubbo-mongo(dubbo provider)与 mf-app-server,确认 Nacos 与 MongoDB 连接正常。
curl 上报
先从登录接口拿 token(项目用 token 而非 cookie),然后上报一个位置:
bash
curl -X POST "http://localhost:18080/baidu/location" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"longitude": "116.353885", "latitude": "40.065911", "addrStr": "育新地铁站"}'
注意经纬度按客户端习惯传了字符串,Controller 里 Double.valueOf 兜住了。
另一种等价的核对方式(思路篇的验证要点):上报后先切到 mf 库,确认 location 是标准 GeoJSON Point 结构:
javascript
use mf
// 查看上报结果,确认 location 是 GeoJSON Point 结构
db.user_location.find({userId: NumberLong(1)})
// 输出中应能看到:
// location: { type: 'Point', coordinates: [ 116.353885, 40.065911 ] }
mongo shell 核对
javascript
use mf
db.user_location.find().pretty()
正常应看到:
json
{
"_id": ObjectId("6152a8f3c9e77d1f8a2b3c4d"),
"userId": NumberLong("1"),
"location": { "type": "Point", "coordinates": [116.353885, 40.065911] },
"address": "育新地铁站",
"created": NumberLong("1632785139000"),
"updated": NumberLong("1632785139000"),
"lastUpdated": NumberLong("1632785139000"),
"_class": "com.mf.model.mongo.UserLocation"
}
location 是标准 GeoJSON Point,coordinates 数组先经度后纬度。顺手确认索引建成了:
javascript
db.user_location.getIndexes()
应能看到 location_2dsphere 和 userId_1 两条。
重复上报验证覆盖
用同一个 token 再报一个新位置(比如换成天安门),然后核对三点:
javascript
db.user_location.find({userId: NumberLong("1")}).count() // 结果应为 1
count仍是 1:没有插入新文档,覆盖更新生效coordinates变成了新坐标updated变成新时间,旧时间挪进了lastUpdated
数据准备
搜附近要有一定数据量才看得出效果,用单元测试批量灌一批(坐标可用腾讯位置拾取器取:https://lbs.qq.com/getPoint/ ):
java
@RunWith(SpringRunner.class)
@SpringBootTest(classes = AppServerApplication.class)
public class TestUserLocationApi {
@DubboReference
private UserLocationApi userLocationApi;
@Test
public void testUpdateUserLocation() {
this.userLocationApi.updateLocation(1L, 116.353885, 40.065911, "育新地铁站");
this.userLocationApi.updateLocation(2L, 116.352115, 40.067441, "北京石油管理干部学院");
this.userLocationApi.updateLocation(3L, 116.336438, 40.072505, "回龙观医院");
this.userLocationApi.updateLocation(4L, 116.396797, 40.025231, "奥林匹克森林公园");
this.userLocationApi.updateLocation(5L, 116.323849, 40.053723, "小米科技园");
this.userLocationApi.updateLocation(6L, 116.403963, 39.915119, "天安门");
this.userLocationApi.updateLocation(7L, 116.328103, 39.900835, "北京西站");
this.userLocationApi.updateLocation(8L, 116.609564, 40.083812, "北京首都国际机场");
this.userLocationApi.updateLocation(9L, 116.459958, 39.937193, "德云社(三里屯店)");
this.userLocationApi.updateLocation(10L, 116.333374, 40.009645, "清华大学");
this.userLocationApi.updateLocation(41L, 116.316833, 39.998877, "北京大学");
this.userLocationApi.updateLocation(42L, 117.180115, 39.116464, "天津大学(卫津路校区)");
}
}
跑完这个测试再查 user_location,应有 12 条。坐标特意选了北京各处加一个天津的,后面按 2000 米搜附近时,天津大学那条正好用来验证「半径外的人搜不到」。
再确认 2dsphere 索引真实存在(没有索引,$near/geoNear 查询会直接报错):
javascript
db.user_location.getIndexes()
// 应包含 { name: 'location_index', key: { location: '2dsphere' } }
四个坑
| 坑 | 现象 | 原因与对策 |
|---|---|---|
| GeoJsonPoint 过不了 Dubbo | 把 GeoJsonPoint 放进 UserLocationApi 的参数或返回值,dubbo 调用直接序列化异常 |
GeoJsonPoint 不是 Dubbo hessian2 默认可序列化的类型。接口签名只传 Double longitude / latitude 裸值,坐标对象在 provider 内部构造 |
| Movement 的经纬度是 String | 动态表 movement 的 longitude/latitude 是 String 类型,直接塞给 GeoJsonPoint 编译不过 |
转换两步走:Double.parseDouble(longitude) 转 Double,再 new GeoJsonPoint(lon, lat)。这是 Movement 的已知改造点,后续做「附近的动态」前必须先做 |
| 覆盖更新的并发窗口 | 两个上报请求同时到达且记录不存在时,可能各自 save 一条,出现同一 userId 两条文档 | 教学实现是「findOne 再 save/updateFirst」,非原子。生产上更稳的写法是 upsert + userId 唯一索引,或按 userId 幂等锁 |
| 频控缺失 | 教学代码对上报频率完全不设防,恶意客户端可以高频刷写 | 上报策略(位移 >500m 或间隔 5min)目前只靠客户端自觉,服务端可以按 lastUpdated 做最小间隔拦截;另外出于隐私考虑,落库前可对坐标做模糊化处理(降低小数位精度),这里从简未展开 |
常见误解澄清
| 误解 | 实际情况 |
|---|---|
| 位置数据存在 movement 动态集合里 | 搜附近用的是独立的 user_location 集合;movement 上的经纬度是发动态时的打卡位置,二者语义不同 |
| 每个用户每次上报都会新增一条记录 | 覆盖更新,一用户永远只有一条文档,旧位置直接被替换 |
GeoJsonPoint 可以直接经 dubbo 传输 |
不支持 Java 序列化,跨服务只能传 Double,由提供者封装 |
| 上报是服务端定时拉取的 | 客户端行为:位移超 500 米或间隔 5 分钟主动 POST,服务端纯被动接收 |
| 存了经纬度就能查附近 | 还必须建 2dsphere 索引,且字段必须是 GeoJSON 结构而非两个 Double 散字段 |
| 客户端上报的坐标就是 GPS 原始坐标 | 国内 SDK 返回加偏后的坐标系(百度 BD-09、高德/腾讯 GCJ-02),与 WGS84 有偏移 |
| 上报位置会把用户历史轨迹都存下来 | user_location 一个用户只保留一条最新记录,旧坐标被覆盖,不存轨迹 |
| 上报是 insert,用户上报越多次数据越多 | 已有记录走 updateFirst 覆盖,集合里始终一用户一条 |
| 客户端上报什么坐标就存什么 | 教学实现确实原样落库,但生产环境应在服务端做坐标模糊化与频控,不能全信客户端 |
addrStr 传成 address 也能收到 |
取不到值会 NPE,键名必须与百度 SDK 回调字段一致 |
官方文档
总结
- 上报地理位置解决的是「附近的人」的数据来源问题,上报时机由客户端掌握:位移超 500 米或间隔 5 分钟触发,在实时性、耗电、隐私三者间取平衡。
- 存储选型的关键在语义:「当前所在地」用独立集合覆盖更新(工程的 user_location,一用户一条),「动态打卡位置」挂在 movement 上,「高频临时数据」才考虑 Redis GEO。
GeoJsonPoint+ 2dsphere 索引是地理查询的前提,GeoJsonPoint不可 dubbo 序列化,跨服务传 Double,提供者内部封装。- 覆盖更新的代价是丢历史,
updated/lastUpdated两个字段保留了时间线索;工程未处理的并发首次上报竞态,生产环境用唯一索引 + upsert 收紧。 - 位置是敏感个人信息,开关、降精度、可清除、频控这四件事属于上报设计的组成部分,不是可选项。
- 上报地理位置这条链路四层走完:Controller 转型、Service 取登录态用户、dubbo 接口传裸值、Impl 里 findOne + save/updateFirst 完成覆盖写入。代码不长,但 Dubbo 序列化限制、坐标顺序、覆盖语义、频控与模糊化这些点都值得在面试里展开。位置数据已经就位,下一步就是反方向的查询,给定「我的位置 + 半径」,把圆内的人捞出来。