MongoDB 地理位置上报实战:从接口设计到覆盖写入的完整实现

概述

「附近的人」功能的第一步不是查询,而是有人愿意把位置交出来:客户端在合适的时机上报经纬度,服务端决定存到哪、怎么存、存成什么结构。本文围绕这三个决策展开,并给出从 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 序列化限制、坐标顺序、覆盖语义、频控与模糊化这些点都值得在面试里展开。位置数据已经就位,下一步就是反方向的查询,给定「我的位置 + 半径」,把圆内的人捞出来。
相关推荐
鬼手点金1 小时前
Skills:领域能力的可组合单元
java·人工智能·笔记·自动化·1024程序员节
captain3769 小时前
MyBatis(入门5)其他查询操作
java·数据库·spring boot·mybatis
Novproxy_Z10 小时前
商用代理避坑:如何识别商家拿机房 IP 冒充住宅 IP
java·网络·tcp/ip
文丑颜不良啊11 小时前
LeetCode1481~1500题目解析
java·leetcode
2601_9628857211 小时前
quotes 字段全解:涨跌幅、振幅、换手率的口径与单位
java·前端·spring
前端精髓11 小时前
调用openrouter的免费API模型
java·前端·javascript
步行cgn11 小时前
Spring MVC 拦截器 Interceptor 详解:从执行流程到实战应用
java·spring·mvc
文丑颜不良啊11 小时前
LeetCode1381~1400题目解析
java·leetcode
开开心心_Every12 小时前
快速隐藏程序工具自定义快捷键一键切换
java·开发语言·前端·spring·智能手机·word·intellij-idea