【工作记录】springboot中基于redis实现地理位置相关需求@20240822
背景
近期收到一个需求,有个事件管理系统,存储了用户上报上来的事件信息,其中包含了事件发生的经纬度,还有另外一个系统中保存了一系列的摄像头数据,也包含经纬度信息。
需求是这样的,用户点击某个事件的时候弹出一个详情对话框,用户可以点击对话框中的查看周边视频按钮来查看该事件发生地附近一公里以内的摄像头数据。
这里就涉及到一个问题,如何在诸多摄像头中找到事件发生地周边一公里范围内的摄像头数据。
分析实现方案
看到这个需求,第一个想法就是使用mysql或者redis这一类数据库工具提供的地理空间支持API来实现。以下是各个实现方案对比:
| 方案名称 | 方案描述 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 纯Java实现 | 使用Haversine公式计算两个经纬度之间的距离,然后遍历所有经纬度数据,计算目标经纬度与每个数据点之间的距离,判断是否在指定范围内。**** | 易于实现:只需要基本的Java编程知识。 无需额外依赖:不需要使用数据库或其他外部服务。 | 性能较低:需要对每个数据点进行计算,随着数据量增加,性能会显著下降。 不适合大数据集:对于大规模数据集,这种方法可能不可行。 | 数据量较小的情况。 不需要实时处理的情况 |
| 使用Redis GEO功能 | 使用Redis的GEO功能,将经纬度数据存储在Redis中,并利用Redis提供的GEORADIUS命令来查询指定范围内的数据。 | 高性能:Redis提供了非常快的查询速度。 易于扩展:Redis可以水平扩展,支持集群部署。 | 需要额外的Redis服务:需要部署和维护Redis服务。 学习成本:需要了解Redis的使用方法。 | 需要高性能查询的场景。 数据量较大,需要快速响应的情况 |
| 使用MySQL的GIS功能 | 使用MySQL的GIS功能,将经纬度数据存储为POINT类型,并使用ST_Distance_Sphere等函数来查询指定范围内的数据。 | 成熟稳定:MySQL是一个成熟的数据库系统,支持GIS功能。 易于集成:如果已经在使用MySQL,集成起来比较容易。 | 性能相对较低:相比Redis,MySQL的查询速度较慢。 需要额外配置:需要配置MySQL支持GIS功能。 | 已经在使用MySQL的项目。 数据量适中,对性能要求不是特别高的情况。 |
| 使用PostgreSQL的PostGIS扩展 | 使用PostgreSQL的PostGIS扩展,将经纬度数据存储为地理坐标,并利用PostGIS提供的函数来查询指定范围内的数据。 | 强大的GIS功能:PostGIS提供了丰富的GIS功能。 高性能:PostgreSQL是一个高性能的数据库系统。 | 学习曲线较高:PostGIS的学习曲线比其他方案更陡峭。 部署和维护成本:需要部署和维护PostgreSQL服务。 | 需要高级GIS功能的项目。 数据量较大,对性能和功能都有较高要求的情况。 |
| 使用Elasticsearch | 使用Elasticsearch的地理坐标支持,将经纬度数据索引到Elasticsearch中,并使用geo_distance查询来获取指定范围内的数据。 | 高性能:Elasticsearch擅长处理大规模数据集。 易于扩展:Elasticsearch支持水平扩展。 | 部署和维护成本:需要部署和维护Elasticsearch服务。 学习成本:需要学习Elasticsearch的使用方法。 | 需要处理大规模数据集的项目。 对性能和可扩展性有较高要求的情况。 |
总结如下:
- 纯Java实现适用于数据量较小且不需要实时处理的情况。
- Redis GEO功能适用于需要高性能查询的场景,特别是数据量较大的情况。
- MySQL GIS功能适用于已经在使用MySQL且数据量适中的项目。
- PostgreSQL PostGIS扩展适用于需要高级GIS功能的项目,数据量较大且对性能和功能都有较高要求的情况。
- Elasticsearch适用于需要处理大规模数据集且对性能和可扩展性有较高要求的情况。
根据你的具体需求和现有技术栈选择合适的方案。
本文我们选择基于RedisGeo实现该需求。
开始
环境准备
- Java开发环境和开发工具,如IDEA、MAVEN等
- 支持RedisGeo的redis环境
引入依赖
pom.xml文件参考如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com.zjtx.tech-demo</groupId><artifactId>redis-geo-demo</artifactId><version>1.0-SNAPSHOT</version><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.7.8</version></parent><properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.32</version></dependency></dependencies><build><finalName>redis-geo-demo</finalName><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
添加配置
application.yml配置redis相关信息:
spring:redis:host: 172.16.10.204port: 6379database: 8
数据准备
摄像头信息实体类:
package com.zjtx.tech.demo.entity;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;@Data
@NoArgsConstructor
@AllArgsConstructor
public class CameraInfo {private Integer id;private String name;private double longitude;private double latitude;private String address;}
返回结果类:
package com.zjtx.tech.demo.utils;import lombok.Data;@Data
public class Result {private int code = 200;private String msg = "ok";private Object data;public Result() {}public static Result ok(Object data) {Result result = new Result();result.setData(data);return result;}}
为了演示方便,这里使用java生成了一系列的经纬度点位(实际中应当是从某个数据存储中读取),代码参考如下:
package com.zjtx.tech.demo.utils;import java.util.ArrayList;
import java.util.List;
import java.util.Random;public class GeoPointGenerator {// 地球平均半径,单位:千米private static final double EARTH_RADIUS_KM = 6371;private static final Random RANDOM = new Random();/*** 生成指定数量的经纬度信息,满足特定的距离条件。** @param centerLongitude 中心点的经度* @param centerLatitude 中心点的纬度* @return 生成的经纬度列表*/public static List<Double[]> generateGeoPoints(double centerLongitude, double centerLatitude) {List<Double[]> geoPoints = new ArrayList<>();// 不足1km, 不足2km, 不足3km, 超过3km 的数量int[] counts = {2, 3, 4, 3};// 距离上限double[] distancesKm = {1, 2, 3, 10};for (int i = 0; i < counts.length; i++) {for (int j = 0; j < counts[i]; j++) {double distanceKm = getRandomDistance(distancesKm[i]);double angle = getRandomAngle();Double[] point = getGeoPoint(centerLongitude, centerLatitude, distanceKm, angle);while (point[0] < 0 || point[1] < 0) {// 如果生成的经纬度有负值,则重新生成distanceKm = getRandomDistance(distancesKm[i]);angle = getRandomAngle();point = getGeoPoint(centerLongitude, centerLatitude, distanceKm, angle);}geoPoints.add(point);}}return geoPoints;}private static double getRandomDistance(double maxDistanceKm) {return RANDOM.nextDouble() * maxDistanceKm;}private static double getRandomAngle() {return RANDOM.nextDouble() * 360;}private static Double[] getGeoPoint(double centerLongitude, double centerLatitude, double distanceKm, double angleDegrees) {// 将角度转换为弧度double angle = Math.toRadians(angleDegrees);// 计算半径double radius = distanceKm / EARTH_RADIUS_KM;double lat1 = Math.toRadians(centerLatitude);double lon1 = Math.toRadians(centerLongitude);double lat2 = Math.asin(Math.sin(lat1) * Math.cos(radius) + Math.cos(lat1) * Math.sin(radius) * Math.cos(angle));double lon2 = lon1 + Math.atan2(Math.sin(angle) * Math.sin(radius) * Math.cos(lat1), Math.cos(radius) - Math.sin(lat1) * Math.sin(lat2));return new Double[]{Math.toDegrees(lon2), Math.toDegrees(lat2)};}}
编写service类
package com.zjtx.tech.demo.service;import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.geo.Point;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.domain.geo.Metrics;
import org.springframework.stereotype.Service;import javax.annotation.Resource;@Service
public class RedisGeoService {@Resourceprivate RedisTemplate<String, String> redisTemplate;/*** 向指定的Geo集合中添加成员,附带其地理坐标** @param key Geo集合的键,标识一个Geo集合* @param member 需要添加的成员* @param longitude 经度,表示成员的地理坐标位置* @param latitude 纬度,表示成员的地理坐标位置*/public void addMember(String key, String member, double longitude, double latitude) {redisTemplate.opsForGeo().add(key, new Point(longitude, latitude), member);}/*** 根据指定的圆范围查询Geo集合中的成员* 此方法用于查询给定Geo集合中,在指定圆范围内的所有成员,圆的中心由经纬度指定,半径为5公里** @param key Geo集合的键,标识一个Geo集合* @param longitude 圆心的经度* @param latitude 圆心的纬度*/public void radius(String key, double longitude, double latitude, int radius) {Point center = new Point(longitude, latitude);Circle circle = new Circle(center, new Distance(radius, Metrics.KILOMETERS));redisTemplate.opsForGeo().radius(key, circle);}/*** 根据指定的圆范围查询Geo集合中的成员,并按距离排序* <p>* 此方法用于查询给定Geo集合中,在指定圆范围内的所有成员,圆的中心由经纬度指定,半径为5公里。* 查询结果将按照距离从近到远排序** @param key Geo集合的键,标识一个Geo集合* @param longitude 圆心的经度* @param latitude 圆心的纬度* @return 按距离排序的查询结果*/public GeoResults<RedisGeoCommands.GeoLocation<String>> radiusCondition(String key, double longitude, double latitude, int radius, long count) {Point center = new Point(longitude, latitude);Circle circle = new Circle(center, new Distance(radius, Metrics.KILOMETERS));//这里实现了根据距离升序排序并且限制返回数量的功能,可根据实际情况灵活调整RedisGeoCommands.GeoRadiusCommandArgs args =RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().includeCoordinates().sortAscending().limit(count);return redisTemplate.opsForGeo().radius(key, circle, args);}}
附上redis原生支持的命令:
| 命令 | 命令说明 | 参数说明 | 使用示例 |
|---|---|---|---|
| GEOADD | 用于向一个已存在的 Geo 集合键中添加一个或多个带有地理坐标的成员。 | GEOADD key longitude latitude member [longitude latitude member …] | GEOADD geo:locations 116.3975 39.9042 Beijing 121.4737 31.2304 Shanghai |
| GEOPOS | 获取一个或多个给定成员的位置(经度和纬度)。 | GEOPOS key member [member …] | GEOPOS geo:locations Beijing |
| GEODIST | 计算两个给定成员之间的距离。 | GEODIST key member1 member2 unit | GEODIST geo:locations Beijing Shanghai km |
| GEOHASH | 返回一个或多个给定成员的 Geo Hash 表示。 | GEOHASH key member [member …] | GEOHASH geo:locations Beijing |
| GEORADIUS | 以给定的经纬度为中心,返回键中指定区域内满足给定的最大距离的所有成员。 | GEODIST key member1 member2 unit | GEORADIUS geo:locations 116.3975 39.9042 100 km WITHDIST WITHCOORD |
| GEORADIUSBYMEMBER | 与 GEORADIUS 类似,但是是以给定的成员为中心,而不是经纬度。 | GEORADIUSBYMEMBER key member radius unit [WITHDIST] [WITHHASH] [WITHCOORD] [ASC DESC] [COUNT count] [STORE key] [STOREDIST key] | GEORADIUSBYMEMBER geo:locations Beijing 100 km WITHDIST WITHCOORD |
这些命令在 Redis 3.2 版本开始被正式引入到 Redis 中,提供了强大的地理空间数据存储和检索功能。
编写controller类
package com.zjtx.tech.demo.controller;import com.zjtx.tech.demo.entity.CameraInfo;
import com.zjtx.tech.demo.service.RedisGeoService;
import com.zjtx.tech.demo.utils.GeoPointGenerator;
import com.zjtx.tech.demo.utils.Result;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;@RestController
@RequestMapping("camera")
public class RedisGeoDemoController {@Resourceprivate RedisGeoService redisGeoService;public static final List<CameraInfo> CAMERA_INFO_LIST = new ArrayList<>();static {double centerLongitude = 116.3975;double centerLatitude = 39.9042;List<Double[]> geoPoints = GeoPointGenerator.generateGeoPoints(centerLongitude, centerLatitude);for (int i = 0; i < geoPoints.size(); i++) {CameraInfo cameraInfo = new CameraInfo();cameraInfo.setId(i);cameraInfo.setName("camera" + i);cameraInfo.setLongitude(geoPoints.get(i)[0]);cameraInfo.setLatitude(geoPoints.get(i)[1]);cameraInfo.setAddress("address" + i);CAMERA_INFO_LIST.add(cameraInfo);}}@GetMapping("list")public Result getCameraList(double longitude, double latitude) {CAMERA_INFO_LIST.forEach(cameraInfo -> {redisGeoService.addMember("cameras", cameraInfo.getName(), cameraInfo.getLongitude(), cameraInfo.getLatitude());});//查询传入的经纬度附近1公里以内的3个摄像头数据GeoResults<RedisGeoCommands.GeoLocation<String>> res = redisGeoService.radiusCondition("cameras", longitude, latitude, 1, 3);res.getContent().forEach(result -> System.out.println(result.getContent().getName()));return Result.ok(res);}}
测试
启动项目并通过浏览器访问接口测试地址

总结
本文依赖于redis对geo的支持相关API,结合springboot实现了指定范围内经纬度的搜索。
示例相对简单,如有更复杂需求可在此基础上实现,有任何问题欢迎留言讨论。
除了文中提到的方案外还有其他方案,如有需要欢迎留言交流。
