大家好,我是此林。
关于 MongoDB,我们在这篇文章中已经详细讲述了其基本增删改查、SpringBoot 整合等内容。一文快速入门 MongoDB 、MongoDB 8.2 下载安装、增删改查操作、索引、SpringBoot整合 Spring Data MongoDB
今天我们分享的是 MongoDB GEO 项目场景实战。
1. 场景描述
小王在某物流平台上下单,系统需要快速找到能为他服务的快递员。通过对比小王的位置与快递员的服务范围,平台能准确匹配到合适人员,解决了订单分配效率低、匹配不精准的问题。
那系统具体怎么做呢?

首先快递网点会有个作业范围,比如图中红色框标注的多边形,一个由多个坐标点组成的多边形,并且必须是闭合的多边形。小王位置为灰色的竖线。
这个就比较适合用MongoDB来存储。
用户小王下了订单,如何找到属于该服务范围内的快递员呢?我们使用MongoDB的$geoIntersects
查询操作,其原理就是查找小王的位置坐标点与哪个多边形有交叉,这个就是为其服务的快递员。
2. 定义 MongoDB 实体
机构和快递员的作业范围逻辑一般是一样的,所以可以共存一张表中,通过type进行区分,1-机构,2-快递员。
java
/**
* 服务范围实体
*/
@Data
@Document("service_scope")
public class ServiceScopeEntity {
@Id
@JsonIgnore
private ObjectId id;
/**
* 业务id,可以是机构或快递员
*/
@Indexed
private Long bid;
/**
* 类型 {@link com.ms.scope.enums.ServiceTypeEnum}
*/
@Indexed
private Integer type;
/**
* 多边形范围,是闭合的范围,开始经纬度与结束经纬度必须一样
* x: 经度,y:纬度
*/
@GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE)
private GeoJsonPolygon polygon;
private Long created; //创建时间
private Long updated; //更新时间
}
这里面的 @Document("service_scope") 指定 MongoDB 集合名称(类似于关系型数据库中的表名),里面的每条记录对应一片服务区域。
**id:**ObjectId 类型。ObjectId 是 MongoDB 内置的唯一标识类型,@Id 表示该字段是主键,@JsonIgnore 表示序列化为 JSON 时忽略该字段(对外不暴露)。
**bid:**某个网点 ID,或某个快递员的 ID, @Indexed 表示该字段会在 MongoDB 中建立索引,加快查询速度。
**type:**1=网点,2=快递员,也加了索引,提高查询效率。
**polygon:**GeoJsonPolygon 类型。用 GeoJsonPolygon 来存储一个多边形(由多个经纬度点组成)。多边形必须闭合,即起点和终点的经纬度必须相同。@GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE) 用于建立地理空间索引(GEO_2DSPHERE)。
**created:**创建时间(时间戳,毫秒值)
**updated:**更新时间(时间戳,毫秒值)
这里再贴一下服务类型枚举:
java
import cn.hutool.core.util.EnumUtil;
/**
* 服务类型枚举
*/
public enum ServiceTypeEnum {
ORGAN(1, "机构"),
COURIER(2, "快递员");
/**
* 类型编码
*/
private final Integer code;
/**
* 类型值
*/
private final String value;
ServiceTypeEnum(Integer code, String value) {
this.code = code;
this.value = value;
}
public Integer getCode() {
return code;
}
public String getValue() {
return value;
}
public static ServiceTypeEnum codeOf(Integer code) {
return EnumUtil.getBy(ServiceTypeEnum::getCode, code);
}
}
3. ScopeService 接口编写
我们 Service 要编写哪些方法呢?
主要有下面几个:
-
新增或更新服务范围。即新增或更新服务范围的多边形各个顶点坐标。
-
根据主键id、业务id、类型删除+查询服务范围
-
根据坐标点查询所属哪个服务范围。这个坐标点就是用户位置坐标。
-
根据用户详细地址查询所属的服务范围。
所以我们定义如下接口:
java
/**
* 服务范围Service
*/
public interface ScopeService {
/**
* 新增或更新服务范围
*
* @param bid 业务id
* @param type 类型
* @param polygon 多边形坐标点
* @return 是否成功
*/
Boolean saveOrUpdate(Long bid, ServiceTypeEnum type, GeoJsonPolygon polygon);
/**
* 根据主键id删除数据
*
* @param id 主键
* @return 是否成功
*/
Boolean delete(String id);
/**
* 根据业务id和类型删除数据
*
* @param bid 业务id
* @param type 类型
* @return 是否成功
*/
Boolean delete(Long bid, ServiceTypeEnum type);
/**
* 根据主键查询数据
*
* @param id 主键
* @return 服务范围数据
*/
ServiceScopeEntity queryById(String id);
/**
* 根据业务id和类型查询数据
*
* @param bid 业务id
* @param type 类型
* @return 服务范围数据
*/
ServiceScopeEntity queryByBidAndType(Long bid, ServiceTypeEnum type);
/**
* 根据坐标点查询所属的服务对象
*
* @param type 类型
* @param point 坐标点
* @return 服务范围数据
*/
List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, GeoJsonPoint point);
/**
* 根据详细地址查询所属的服务对象
*
* @param type 类型
* @param address 详细地址,如:石家庄市桥西区XXX小区XX号楼XXX室
* @return 服务范围数据
*/
List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, String address);
}
4. ScopeController API 编写
java
@Api(tags = "服务范围")
@RestController
@RequestMapping("/scopes")
@Validated
public class ScopeController {
@Resource
private ScopeService scopeService;
/**
* 新增或更新服务服务范围
*
* @return REST标准响应
*/
@ApiOperation(value = "新增/更新", notes = "新增或更新服务服务范围")
@PostMapping
public ResponseEntity<Void> saveScope(@RequestBody ServiceScopeDTO serviceScopeDTO) {
ServiceScopeEntity serviceScopeEntity = EntityUtils.toEntity(serviceScopeDTO);
Long bid = serviceScopeEntity.getBid();
ServiceTypeEnum type = ServiceTypeEnum.codeOf(serviceScopeEntity.getType());
Boolean result = this.scopeService.saveOrUpdate(bid, type, serviceScopeEntity.getPolygon());
if (result) {
return ResponseEntityUtils.ok();
}
return ResponseEntityUtils.error();
}
/**
* 删除服务范围
*
* @param bid 业务id
* @param type 类型
* @return REST标准响应
*/
@ApiImplicitParams({
@ApiImplicitParam(name = "bid", value = "业务id,可以是机构或快递员", dataTypeClass = Long.class),
@ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class)
})
@ApiOperation(value = "删除", notes = "删除服务范围")
@DeleteMapping("{bid}/{type}")
public ResponseEntity<Void> delete(@NotNull(message = "bid不能为空") @PathVariable("bid") Long bid,
@NotNull(message = "type不能为空") @PathVariable("type") Integer type) {
Boolean result = this.scopeService.delete(bid, ServiceTypeEnum.codeOf(type));
if (result) {
return ResponseEntityUtils.ok();
}
return ResponseEntityUtils.error();
}
/**
* 查询服务范围
*
* @param bid 业务id
* @param type 类型
* @return 服务范围数据
*/
@ApiImplicitParams({
@ApiImplicitParam(name = "bid", value = "业务id,可以是机构或快递员", dataTypeClass = Long.class),
@ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class)
})
@ApiOperation(value = "查询", notes = "查询服务范围")
@GetMapping("{bid}/{type}")
public ResponseEntity<ServiceScopeDTO> queryServiceScope(@NotNull(message = "bid不能为空") @PathVariable("bid") Long bid,
@NotNull(message = "type不能为空") @PathVariable("type") Integer type) {
ServiceScopeEntity serviceScopeEntity = this.scopeService.queryByBidAndType(bid, ServiceTypeEnum.codeOf(type));
return ResponseEntityUtils.ok(EntityUtils.toDTO(serviceScopeEntity));
}
/**
* 地址查询服务范围
*
* @param type 类型,1-机构,2-快递员
* @param address 详细地址,如:北京市昌平区金燕龙办公楼传智教育总部
* @return 服务范围数据列表
*/
@ApiImplicitParams({
@ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class),
@ApiImplicitParam(name = "address", value = "详细地址,如:北京市昌平区金燕龙办公楼传智教育总部", dataTypeClass = String.class)
})
@ApiOperation(value = "地址查询服务范围", notes = "地址查询服务范围")
@GetMapping("address")
public ResponseEntity<List<ServiceScopeDTO>> queryListByAddress(@NotNull(message = "type不能为空") @RequestParam("type") Integer type,
@NotNull(message = "address不能为空") @RequestParam("address") String address) {
List<ServiceScopeEntity> serviceScopeEntityList = this.scopeService.queryListByPoint(ServiceTypeEnum.codeOf(type), address);
return ResponseEntityUtils.ok(EntityUtils.toDTOList(serviceScopeEntityList));
}
/**
* 位置查询服务范围
*
* @param type 类型,1-机构,2-快递员
* @param longitude 经度
* @param latitude 纬度
* @return 服务范围数据列表
*/
@ApiImplicitParams({
@ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class),
@ApiImplicitParam(name = "longitude", value = "经度", dataTypeClass = Double.class),
@ApiImplicitParam(name = "latitude", value = "纬度", dataTypeClass = Double.class)
})
@ApiOperation(value = "位置查询服务范围", notes = "位置查询服务范围")
@GetMapping("location")
public ResponseEntity<List<ServiceScopeDTO>> queryListByAddress(@NotNull(message = "type不能为空") @RequestParam("type") Integer type,
@NotNull(message = "longitude不能为空") @RequestParam("longitude") Double longitude,
@NotNull(message = "latitude不能为空") @RequestParam("latitude") Double latitude) {
List<ServiceScopeEntity> serviceScopeEntityList = this.scopeService.queryListByPoint(ServiceTypeEnum.codeOf(type), new GeoJsonPoint(longitude, latitude));
return ResponseEntityUtils.ok(EntityUtils.toDTOList(serviceScopeEntityList));
}
}
Spring MVC 的 @RequestParam
或 @PathVariable
默认不会自动生成 Swagger 文档里参数的详细信息。
@ApiImplicitParam
是 Swagger(API 文档生成工具) 的注解,用于描述接口的请求参数,让 Swagger UI 能够自动生成可交互的 API 文档。
name
:参数名,对应接口方法里的@RequestParam
或@PathVariable
名称。value
:参数说明,会显示在 Swagger 文档里。dataTypeClass
:参数的数据类型(如Integer.class
、String.class
)。- 还可以写
required = true
表示必填。
5. ScopeServiceImpl 实现
- 删除或更新服务范围
java
@Override
public Boolean saveOrUpdate(Long bid, ServiceTypeEnum type, GeoJsonPolygon polygon) {
Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode())); //构造查询条件
ServiceScopeEntity serviceScopeEntity = this.mongoTemplate.findOne(query, ServiceScopeEntity.class);
if (ObjectUtil.isEmpty(serviceScopeEntity)) {
//新增
serviceScopeEntity = new ServiceScopeEntity();
serviceScopeEntity.setBid(bid);
serviceScopeEntity.setType(type.getCode());
serviceScopeEntity.setPolygon(polygon);
serviceScopeEntity.setCreated(System.currentTimeMillis());
serviceScopeEntity.setUpdated(serviceScopeEntity.getCreated());
} else {
//更新
serviceScopeEntity.setPolygon(polygon);
serviceScopeEntity.setUpdated(System.currentTimeMillis());
}
try {
this.mongoTemplate.save(serviceScopeEntity);
return true;
} catch (Exception e) {
log.error("新增/更新服务范围数据失败! bid = {}, type = {}, points = {}", bid, type, polygon.getPoints(), e);
}
return false;
}
- 删除服务范围
java
@Override
public Boolean delete(String id) {
Query query = Query.query(Criteria.where("id").is(new ObjectId(id))); //构造查询条件
return this.mongoTemplate.remove(query, ServiceScopeEntity.class).getDeletedCount() > 0;
}
@Override
public Boolean delete(Long bid, ServiceTypeEnum type) {
Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode())); //构造查询条件
return this.mongoTemplate.remove(query, ServiceScopeEntity.class).getDeletedCount() > 0;
}
- 基础查询
java
@Override
public ServiceScopeEntity queryById(String id) {
return this.mongoTemplate.findById(new ObjectId(id), ServiceScopeEntity.class);
}
@Override
public ServiceScopeEntity queryByBidAndType(Long bid, ServiceTypeEnum type) {
Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode())); //构造查询条件
return this.mongoTemplate.findOne(query, ServiceScopeEntity.class);
}
- 根据用户经纬度坐标查询所属哪个服务范围
java
@Override
public List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, GeoJsonPoint point) {
Query query = Query.query(Criteria.where("polygon").intersects(point)
.and("type").is(type.getCode()));
return this.mongoTemplate.find(query, ServiceScopeEntity.class);
}
**
GeoJsonPoint point
**是用户传过来的经纬度坐标,比如:
javanew GeoJsonPoint(116.395645, 39.929986) // 北京天安门
Criteria.where("polygon").intersects(point)
polygon
是我们之前在ServiceScopeEntity
中定义的服务范围多边形(闭合区域)。
intersects(point)
的意思是:查询所有polygon
多边形包含该点的记录MongoDB 的地理空间查询会自动判断点是否落在多边形内部。
- 根据详细地址查询坐标
java
@Override
public List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, String address) {
//根据详细地址查询坐标
GeoResult geoResult = this.mapTemplate.opsForBase().geoCode(ProviderEnum.AMAP, address, null);
Coordinate coordinate = geoResult.getLocation();
return this.queryListByPoint(type, new GeoJsonPoint(coordinate.getLongitude(), coordinate.getLatitude()));
}
这里我们要调用地图服务的接口(比如高德或百度地图服务)。只要传入地址,高德或百度都可以返回经纬度。具体接口调用可以看看官方文档。
6. 测试
java
@SpringBootTest
public class ScopeServiceTest {
@Resource
private ScopeService scopeService;
@Test
void saveOrUpdate() {
List<Point> pointList = Arrays.asList(new Point(116.340064,40.061245),
new Point(116.347081,40.061836),
new Point(116.34751,40.05842),
new Point(116.342446,40.058092),
new Point(116.340064,40.061245));
Boolean result = this.scopeService.saveOrUpdate(2L, ServiceTypeEnum.ORGAN, new GeoJsonPolygon(pointList));
System.out.println(result);
}
@Test
void testQueryListByPoint() {
GeoJsonPoint point = new GeoJsonPoint(116.344828,40.05911);
List<ServiceScopeEntity> serviceScopeEntities = this.scopeService.queryListByPoint(ServiceTypeEnum.ORGAN, point);
serviceScopeEntities.forEach(serviceScopeEntity -> System.out.println(serviceScopeEntity));
}
@Test
void testQueryListByPoint2() {
String address = "石家庄市桥西区永德小区2号楼802室";
List<ServiceScopeEntity> serviceScopeEntities = this.scopeService.queryListByPoint(ServiceTypeEnum.ORGAN, address);
serviceScopeEntities.forEach(serviceScopeEntity -> System.out.println(serviceScopeEntity));
}
}
最终返回类似如下的匹配到的服务范围。

7. 其他思考点
其实目前代码所做,基本上是确定出用户被分派给哪个机构,后续还需要把派件任务委派给快递员。
确定好了起始的 网点,还需要确定终点网点。
同样的,取 收件人地址 → 转坐标 (lng, lat),
查询 MongoDB:找哪个机构的服务范围包含这个点,结果 = 终点网点。
中途还会有 **路线规划(用于调度),**起点网点 → 中转中心(多级)→ 终点网点。这些后续会介绍。
为什么会用 MongoDB 存储服务范围?
- 快递员、网点、机构都有"服务范围",这些范围通常是多边形(polygon)。
- 如果用 MySQL 来存:我们可能想到用 JSON 字符串存经纬度点,但是查询时需要自己写数学算法判断点是否在多边形内,比较复杂且慢。
- MongoDB 支持 GeoJSON 和 地理空间索引,自带点、多边形、圆形的查询能力,查询效率高。
常见应用:
- "附近的餐馆/快递员/加油站"
- "这个地址属于哪个网点"
- "用户当前位置在不在配送范围"
所以,这类"地理空间场景"适合 MongoDB,既然有开箱即用的工具为啥不用呢?
不过,话虽然这么说。
MySQL 从 5.7 开始其实也支持 GIS ,可以存储 POINT
、POLYGON
等几何类型。除了 MySQL、MongoDB,还有其他的技术实现对比:
技术 | 支持点查询 | 支持多边形 | 性能 | 适用场景 |
---|---|---|---|---|
MongoDB | ✅ | ✅ | 中等 | 普通互联网业务,范围查询、附近的人 |
MySQL GIS | ✅ | ✅ | 一般 | 小规模场景,已有 MySQL 就能用 |
PostGIS | ✅ | ✅ | 高 | 需要复杂地理计算的大型系统 |
Elasticsearch | ✅ | ✅ | 高 | 搜索 + 地理混合场景 |
Redis GEO | ✅ | ❌(无多边形) | 超高 | 附近的人,高并发场景 |
Redis GEO性能超高的原因还是主要是因为它是内存计算。
不管哪种技术选型,适合自己的业务才是王道,项目里之所以用 MongoDB 其实也是因为 MongoDB 不仅仅用于地理范围搜索,还有其他用途。
当然比如说项目里有百万千万级别的全文搜索 + 地理位置搜索场景,用 Elasticsearch也是可以的。
今天的分享就到这里了。
我是此林,关注我吧!带你看不一样的世界!