🎯 本文档详细介绍了如何使用Redis GEO模块实现场馆位置的存储与查询,以支持“附近场馆”搜索功能。首先,通过微信小程序获取用户当前位置,并将该位置信息与场馆的经纬度数据一同存储至Redis中。利用Redis GEO高效的地理空间索引能力,文档展示了如何初始化缓存、批量处理和存储场馆位置信息,以及执行基于距离和多种条件的分页查询。此外,还提供了计算两个地理位置间距离的工具类。此方案适用于开发具备地理定位功能的应用程序,如体育场馆预订系统。
🏠️ HelloDam/场快订(场馆预定 SaaS 平台)
GEO__4">Redis GEO 介绍
Redis GEO模块为Redis数据库引入了地理位置处理的功能,使得开发者能够基于地理坐标(经纬度)进行数据的操作和查询。通过使用GEO功能,可以方便地存储带有地理位置信息的数据,并执行如添加地理位置、计算两个位置之间的距离、查找指定半径内所有位置等操作。这些特性非常适合于构建需要处理地理位置的应用程序,比如附近的人或地点搜索功能。Redis GEO背后的技术基于高效的GeoHash算法,将地理位置映射到一个字符串上,从而允许对地理位置进行快速检索。这一功能极大地扩展了Redis在地理空间数据处理方面的能力,使其成为开发具有地理定位功能应用的强大工具。
流程
1、小程序前端获取位置(在小程序中获取当前位置的功能通常是通过调用微信小程序提供的API来实现的)
2、后端将位置存储到数据库
3、附近场馆查询
数据库设计
为了实现附近场馆功能,需要存储场馆的经纬度信息
DROP TABLE IF EXISTS `venue`;
CREATE TABLE `venue`(
`id` bigint NOT NULL COMMENT 'ID',
`create_time` datetime,
`update_time` datetime,
`is_deleted` tinyint default 0 COMMENT '逻辑删除 0:没删除 1:已删除',
`organization_id` bigint NOT NULL COMMENT '所属机构ID',
`name` varchar(30) NOT NULL COMMENT '场馆名称',
`type` int NOT NULL COMMENT '场馆类型 1:篮球馆(场) 2:足球场 3:羽毛球馆(场) 4:排球馆(场)100:体育馆 1000:其他',
`address` varchar(255) NOT NULL COMMENT '场馆地址名称',
`latitude` DECIMAL(9, 6) NOT NULL COMMENT '纬度',
`longitude` DECIMAL(9, 6) NOT NULL COMMENT '经度',
`description` varchar(255) DEFAULT '' COMMENT '场馆描述,也可以说是否提供器材等等',
`open_time` varchar(2000) NOT NULL COMMENT '场馆营业时间',
`phone_number` varchar(11) NULL DEFAULT '' COMMENT '联系电话',
`status` tinyint NOT NULL COMMENT '场馆状态 0:关闭 1:开放 2:维护中',
`is_open` tinyint NOT NULL COMMENT '是否对外开放 0:否 1:是 如果不对外开放,需要相同机构的用户才可以预定',
`advance_booking_day` int NOT NULL COMMENT '提前可预定天数,例如设置为1,即今天可预订明天的场',
`start_booking_time` time NOT NULL COMMENT '开放预订时间',
PRIMARY KEY (`id`) USING BTREE
)
实体类
【查询请求类】
import com.vrs.convention.page.PageRequest;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* @Author dam
* @create 2024/12/7 10:51
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class VenueListReqDTO extends PageRequest {
/**
* 场馆名称
*/
private String name;
/**
* 场馆类型 1:篮球馆(场) 2:足球场 3:羽毛球馆(场) 4:排球馆(场)100:体育馆 1000:其他
*/
private Integer type;
/**
* 维度
*/
private BigDecimal latitude;
/**
* 经度
*/
private BigDecimal longitude;
/**
* 多少千米
*/
private double km;
/**
* 场馆状态 0:关闭 1:开放 2:维护中
*/
private Integer status;
}
【返回实体类】
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.vrs.domain.base.BaseEntity;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalTime;
/**
*
* @TableName venue
*/
@TableName(value ="venue")
@Data
public class VenueRespDTO extends BaseEntity implements Serializable {
/**
* 所属机构ID
*/
private Long organizationId;
/**
* 所属机构名称
*/
private String organizationName;
/**
* 场馆名称
*/
private String name;
/**
* 场馆类型 1:篮球馆(场) 2:足球场 3:羽毛球馆(场) 4:排球馆(场)100:体育馆 1000:其他
*/
private Integer type;
private String typeName;
/**
* 场馆地址
*/
private String address;
/**
* 场馆描述,也可以说是否提供器材等等
*/
private String description;
/**
* 场馆营业时间
*/
private String openTime;
/**
* 联系电话
*/
private String phoneNumber;
/**
* 场馆状态 0:关闭 1:开放 2:维护中
*/
private Integer status;
private String statusName;
/**
* 是否对外开放 0:否 1:是 如果不对外开放,需要相同机构的用户才可以预定
*/
private Integer isOpen;
/**
* 提前可预定天数,例如设置为1,即今天可预订明天的场
*/
private Integer advanceBookingDay;
/**
* 开放预订时间
*/
private LocalTime startBookingTime;
/**
* 维度
*/
private BigDecimal latitude;
/**
* 经度
*/
private BigDecimal longitude;
/**
* 距离多少公里
*/
private Double distance;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
位置缓存初始化
【初始化类】
当场馆服务启动起来的时候,调用cacheVenueLocations
方法
import com.vrs.service.VenueService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
/**
* @Author dam
* @create 2025/1/28 9:59
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class VenueLocationCacheInit implements CommandLineRunner {
private final VenueService venueService;
@Override
public void run(String... args) throws Exception {
log.info("读取数据库中的场馆信息,将其位置存储缓存到Redis");
venueService.cacheVenueLocations();
log.info("场馆位置缓存成功");
}
}
【位置缓存加载】
这段代码通过流式处理从数据库中查询场馆的位置信息(经度和纬度),等到缓冲区数据到达容量之后,使用 Redis 的管道技术将这些信息批量存储到 Redis 的地理空间索引中。
通过分批处理(每次处理 1000 条数据)和管道技术,代码优化了数据存储的效率,减少了与 Redis 的交互次数,从而提升了性能。优点是降低了数据库和 Redis 的负载,提高了数据写入的速度,同时避免了内存溢出风险。
/**
* 将场馆的位置信息存储到 Redis 中
*/
@Override
@SneakyThrows
public void cacheVenueLocations() {
// 获取 dataSource Bean 的连接
@Cleanup Connection conn = dataSource.getConnection();
@Cleanup Statement stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
stmt.setFetchSize(Integer.MIN_VALUE);
// 查询sql,只查询关键的字段
String sql = "SELECT id,latitude,longitude FROM venue where is_deleted = 0";
@Cleanup ResultSet rs = stmt.executeQuery(sql);
// 每次获取一行数据进行处理,rs.next()如果有数据返回true,否则返回false
List<VenueDO> buffer = new ArrayList<>();
int bufferSize = 1000;
while (rs.next()) {
// 获取数据中的属性
VenueDO venueDO = new VenueDO();
venueDO.setId(rs.getLong("id"));
venueDO.setLongitude(rs.getBigDecimal("longitude"));
venueDO.setLatitude(rs.getBigDecimal("latitude"));
buffer.add(venueDO);
if (buffer.size() >= bufferSize) {
cacheLocations(buffer);
buffer.clear();
}
}
if (buffer.size() >= 0) {
cacheLocations(buffer);
buffer.clear();
}
}
/**
* 使用 Redis 管道将场馆位置添加到 Redis 缓存中
*
* @param buffer
*/
private void cacheLocations(List<VenueDO> buffer) {
// 使用 Redis 管道批量操作
redisTemplate.executePipelined((RedisCallback<?>) (connection) -> {
for (VenueDO venue : buffer) {
// 确保经纬度信息不为空
if (venue.getLongitude() != null && venue.getLatitude() != null) {
// 将场馆的经纬度信息存储到 Redis 中
Point point = new Point(venue.getLongitude().doubleValue(), venue.getLatitude().doubleValue());
connection.geoAdd(RedisCacheConstant.VENUE_LOCATION_KEY.getBytes(), point, venue.getId().toString().getBytes());
}
}
// 管道操作不需要返回值
return null;
});
}
附近场馆条件查询
这段代码实现了一个基于地理位置和多种条件的分页查询场馆信息的功能。
- 首先根据用户提供的经纬度和半径范围,从缓存中查询出附近的场馆ID列表。
- 然后,结合用户输入的其他条件(如场馆名称、类型、状态等),构建查询条件,从数据库中筛选出符合条件的场馆信息。注意构建查询条件的时候,需要使用 in 语句 传入 附近场馆ID列表。
- 接着将查询结果转换为响应对象(DTO),并计算每个场馆与用户当前位置的距离,最后返回分页后的场馆信息列表。
@Override
public PageResponse<VenueRespDTO> pageVenueDO(VenueListReqDTO request) {
List<Long> venueIdList = null;
if (request.getLatitude() != null && request.getLongitude() != null) {
// 先去缓存中,把位置靠近的场馆ID查询出来
venueIdList = this.findVenuesWithinRadius(request.getLongitude(), request.getLatitude(), request.getKm());
}
LambdaQueryWrapper<VenueDO> queryWrapper = Wrappers.lambdaQuery(VenueDO.class);
// 只查询附近的场馆
if (venueIdList != null) {
if (venueIdList.size() > 0) {
queryWrapper.in(VenueDO::getId, venueIdList);
} else {
return new PageResponse(request.getCurrent(), request.getSize(), 0L, null);
}
}
// 根据名字模糊查询
if (!StringUtils.isBlank(request.getName())) {
queryWrapper.like(VenueDO::getName, "%" + request.getName() + "%");
}
// 根据类型查询
if (request.getType() != null) {
queryWrapper.eq(VenueDO::getType, request.getType());
}
// 根据状态查询
if (request.getStatus() != null) {
queryWrapper.eq(VenueDO::getStatus, request.getStatus());
}
// 查询对方开放场馆,或者相同机构的场馆
queryWrapper.eq(VenueDO::getIsOpen, 1).or().eq(VenueDO::getOrganizationId, UserContext.getOrganizationId());
IPage<VenueDO> page = baseMapper.selectPage(new Page(request.getCurrent(), request.getSize()), queryWrapper);
List<VenueRespDTO> venueRespDTOList = new ArrayList<>();
for (VenueDO record : page.getRecords()) {
VenueRespDTO venueRespDTO = new VenueRespDTO();
BeanUtils.copyProperties(record, venueRespDTO);
venueRespDTO.setTypeName(VenueTypeEnum.findValueByType(record.getType()));
venueRespDTO.setStatusName(VenueStatusEnum.findValueByType(record.getStatus()));
// 计算距离并设置到 DTO 中
if (request.getLatitude() != null && request.getLongitude() != null) {
double distance = DistanceUtil.calculateDistance(
request.getLatitude().doubleValue(),
request.getLongitude().doubleValue(),
record.getLatitude().doubleValue(),
record.getLongitude().doubleValue()
);
venueRespDTO.setDistance(distance);
}
venueRespDTOList.add(venueRespDTO);
}
return new PageResponse(request.getCurrent(), request.getSize(), page.getTotal(), venueRespDTOList);
}
/**
* 根据经纬度和半径(公里)查询附近的场馆 ID
*
* @param longitude 经度
* @param latitude 纬度
* @param radiusKm 半径(公里)
* @return 附近的场馆 ID 列表
*/
public List<Long> findVenuesWithinRadius(BigDecimal longitude, BigDecimal latitude, double radiusKm) {
// 获取 GeoOperations
GeoOperations<String, String> geoOps = redisTemplate.opsForGeo();
// 定义查询的中心点和半径
Point center = new Point(longitude.doubleValue(), latitude.doubleValue());
Distance distance = new Distance(radiusKm, Metrics.KILOMETERS);
Circle circle = new Circle(center, distance);
// 执行地理空间查询
GeoResults<RedisGeoCommands.GeoLocation<String>> results = geoOps.radius(
// Redis 中的 key
RedisCacheConstant.VENUE_LOCATION_KEY,
circle
);
// 提取场馆 ID 并返回
return results.getContent().stream()
.map(result ->
{
Long venueId = Long.parseLong(result.getContent().getName());
// double venueDistance = result.getDistance().getValue();
// System.out.println("场馆 ID: " + venueId + ", 距离: " + venueDistance + " 公里");
return venueId;
}
)
.collect(Collectors.toList());
}
【工具类】
该工具列的作用是:给定两个经纬度,求它们之间的距离(单位:千米)
/**
* 根据经纬度结算公里
* @Author dam
* @create 2025/1/28 19:39
*/
public class DistanceUtil {
/**
* 地球半径,单位:公里
*/
private static final double EARTH_RADIUS = 6371;
/**
* 计算两个经纬度点之间的距离(公里)
*
* @param lat1 纬度 1
* @param lon1 经度 1
* @param lat2 纬度 2
* @param lon2 经度 2
* @return 距离(公里)
*/
public static double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS * c;
}
}