基于 Redis GEO 实现条件分页查询用户附近的场馆列表

news/2025/2/1 3:55:54 标签: redis, 数据库, 缓存, GEO, 附近场馆

🎯 本文档详细介绍了如何使用Redis GEO模块实现场馆位置的存储与查询,以支持“附近场馆”搜索功能。首先,通过微信小程序获取用户当前位置,并将该位置信息与场馆的经纬度数据一同存储至Redis中。利用Redis GEO高效的地理空间索引能力,文档展示了如何初始化缓存、批量处理和存储场馆位置信息,以及执行基于距离和多种条件的分页查询。此外,还提供了计算两个地理位置间距离的工具类。此方案适用于开发具备地理定位功能的应用程序,如体育场馆预订系统。
🏠️ HelloDam/场快订(场馆预定 SaaS 平台)

文章目录

GEO__4">Redis GEO 介绍

Redis GEO模块为Redis数据库引入了地理位置处理的功能,使得开发者能够基于地理坐标(经纬度)进行数据的操作和查询。通过使用GEO功能,可以方便地存储带有地理位置信息的数据,并执行如添加地理位置、计算两个位置之间的距离、查找指定半径内所有位置等操作。这些特性非常适合于构建需要处理地理位置的应用程序,比如附近的人或地点搜索功能。Redis GEO背后的技术基于高效的GeoHash算法,将地理位置映射到一个字符串上,从而允许对地理位置进行快速检索。这一功能极大地扩展了Redis在地理空间数据处理方面的能力,使其成为开发具有地理定位功能应用的强大工具。

流程

1、小程序前端获取位置(在小程序中获取当前位置的功能通常是通过调用微信小程序提供的API来实现的)

2、后端将位置存储到数据库

3、附近场馆查询

  • 将位置信息存储到Redis GEO
  • 查询的时候,先根据缓存查询附近的场馆,再带着附近场馆ID和查询条件去数据库中查询

数据库设计

为了实现附近场馆功能,需要存储场馆的经纬度信息

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;
    }
}

http://www.niftyadmin.cn/n/5838996.html

相关文章

goframe 多语言国际化解决方案

项目背景 本项目采用基于JSON配置的多语言国际化&#xff08;i18n&#xff09;解决方案&#xff0c;支持多种语言的无缝切换和本地化。 目录结构 manifest/ └── i18n/├── zh.json # 简体中文├── zh-tw.json # 繁体中文├── en.json # 英语├…

Vue3.0教程003:setup语法糖

文章目录 3.1 OptionsAPI与CompositionAPIOptions API的弊端Composition API的优势 3.2 拉开序幕的setup3.3 setup语法糖 3.1 OptionsAPI与CompositionAPI vue2的API设计是Options风格的vue3的API设计是Composition&#xff08;组合&#xff09;风格的 Options API的弊端 Opt…

【Python】深入探索Python元类:动态生成类与对象的艺术

《Python OpenCV从菜鸟到高手》带你进入图像处理与计算机视觉的大门! 解锁Python编程的无限可能:《奇妙的Python》带你漫游代码世界 元类是Python中一个高级且强大的特性,允许开发者在类的创建过程中插入自定义逻辑,从而动态生成类和对象。本文将全面介绍Python中的元类概…

【漫话机器学习系列】070.汉明损失(Hamming Loss)

汉明损失&#xff08;Hamming Loss&#xff09; 汉明损失是多标签分类问题中的一种评价指标&#xff0c;用于衡量预测结果与实际标签之间的差异。它定义为预测错误的标签比例&#xff0c;即错误标签的个数占总标签数量的比值。 在多标签分类中&#xff0c;每个样本可以属于多…

基于springboot+vue的扶贫助农系统的设计与实现

开发语言&#xff1a;Java框架&#xff1a;springbootJDK版本&#xff1a;JDK1.8服务器&#xff1a;tomcat7数据库&#xff1a;mysql 5.7&#xff08;一定要5.7版本&#xff09;数据库工具&#xff1a;Navicat11开发软件&#xff1a;eclipse/myeclipse/ideaMaven包&#xff1a;…

在彼此的根系里呼吸

爱如草木&#xff0c;需以晨露滋养&#xff0c;而非绳索捆缚。一段健康的亲密关系&#xff0c;恰似两株根系相连却各自向阳的树——风起时枝叶相触&#xff0c;晴空下共享光影&#xff0c;却始终保有向地心深处生长的自由。那些纠缠的根须是信任编织的网&#xff0c;容得下沉默…

CNN的各种知识点(一):卷积神经网络CNN通道数的理解!

卷积神经网络CNN通道数的理解&#xff01; 通道数的核心概念解析1. 通道数的本质 2. 单张灰度图的处理示例&#xff1a; 3. 批量输入的处理通道与批次的关系&#xff1a; 4. RGB三通道输入的处理计算过程&#xff1a;示例&#xff1a; 5. 通道数的实际意义6. 可视化理解(1) 单通…

一文读懂fgc之cms

一文读懂 fgc之cms-实战篇 1. 前言 线上应用运行过程中可能会出现内存使用率较高&#xff0c;甚至达到95仍然不触发fgc的情况&#xff0c;存在内存打满风险&#xff0c;持续触发fgc回收&#xff1b;或者内存占用率较低时触发了fgc&#xff0c;导致某些接口tp99&#xff0c;tp…