1. 从“附近的人”到“附近的店”为什么我们需要Redis GEO不知道你有没有这样的经历周末想找个咖啡馆坐坐打开手机上的点评应用它总能“聪明”地把你家楼下、公司附近或者你当前所在位置周边的店铺优先推给你。这个看似简单的“附近商户”功能背后其实藏着一个技术上的经典难题如何在海量的商户数据中快速、准确地找出用户周围一定距离内的店铺并且还能支持分页浏览如果只用传统的关系型数据库比如MySQL来做思路大概是这样每张商户表里都有经纬度字段每次查询时用那个著名的“球面距离公式”Haversine公式去计算用户坐标和每个商户坐标的距离然后筛选出距离小于阈值的最后再排序、分页。听起来逻辑很清晰对吧但问题就出在性能上。一旦你的商户数量达到十万、百万级别每次查询都要对全表做一次复杂的数学计算和排序数据库的CPU会瞬间飙升查询响应时间可能从几十毫秒变成几秒钟用户体验就非常糟糕了。这时候就该轮到我们的“秘密武器”——Redis GEO数据结构登场了。Redis大家都很熟悉一个速度快到飞起的内存数据库。它的GEO功能简单来说就是专门为处理地理位置数据而生的。它内部使用了一种叫做Geohash的编码算法把二维的经纬度坐标转换成一维的字符串并且这个字符串有一个神奇的特性前缀匹配的字符串其对应的地理位置也相近。基于这个原理Redis可以像处理有序集合Sorted Set一样高效地进行范围查询。想象一下我们把所有商户的坐标按照店铺类型比如美食、咖啡、超市分类提前“扔”进Redis的GEO集合里。当用户发起“附近5公里内的咖啡馆”查询时应用不再需要去折磨数据库而是直接向Redis发出一个指令“以我为中心画一个半径5公里的圆把圆里所有类型为‘咖啡’的店铺ID按距离从近到远排好队给我返回第二页的20个结果。” Redis几乎能在毫秒级别返回结果。这个速度的提升对于高并发的点评、外卖、打车类应用来说是质的飞跃。所以今天我们就来手把手基于一个类似“黑马点评”的项目场景彻底搞懂如何用Redis GEO来实现商户的附近搜索并且解决一个随之而来的、容易被忽略的“坑”如何在高性能的地理查询基础上优雅地实现分页我会把我实际项目中踩过的坑、优化过的细节都分享出来保证你跟着做一遍就能把这个功能稳稳地落地到自己的项目里。2. 玩转Redis GEO从命令到实战代码在动手写业务代码之前我们得先和Redis GEO的几个核心命令混个脸熟。别担心它们比想象中简单。2.1 核心命令五分钟上手Redis GEO的命令不多但个个都是精华。你可以打开redis-cli跟着我一起敲一遍感受一下。GEOADD埋下地理坐标的“种子”这是最基础的命令用来向一个GEO集合本质上是一个特殊的Sorted Set中添加成员。语法是GEOADD key longitude latitude member [longitude latitude member ...]。# 添加北京几个火车站的坐标 GEOADD geo:station 116.378248 39.865275 北京南站 116.42803 39.903738 北京站 116.322287 39.893729 北京西站执行后Redis会把这些地点存储到名为geo:station的键中。这里的member通常是店铺ID、用户ID等唯一标识符在我们的项目里就是shopId。GEODIST计算“两点一线”的距离想知道两个地点之间直线距离有多远用它。GEODIST key member1 member2 [unit]unit可以是m米、km千米、mi英里、ft英尺。# 计算北京南站到北京西站的直线距离单位米 GEODIST geo:station 北京南站 北京西站 m # 返回结果可能是 8234.56 约8.2公里GEOSEARCH这才是附近搜索的“王牌”这是Redis 6.2版本引入的新命令功能强大且语法更直观它替代了旧的GEORADIUS。GEOSEARCH key [FROMMEMBER member] [FROMLONLAT longitude latitude] [BYRADIUS radius unit] [BYBOX width height unit] [ASC|DESC] [COUNT count]。# 假设天安门的坐标是 (116.397427, 39.90925) # 搜索天安门附近10公里内的所有火车站按距离升序排列最多返回50个 GEOSEARCH geo:station FROMLONLAT 116.397427 39.90925 BYRADIUS 10 km ASC COUNT 50这个命令会返回在指定圆形范围内的所有member。BYBOX参数则允许你搜索一个矩形区域在某些场景下也很有用。重点来了它返回的结果默认就是按照距离排序的这为我们后续的分页处理提供了极大的便利。GEOSEARCHSTORE把搜索结果存起来GEOSEARCHSTORE destination source ...它的参数和GEOSEARCH几乎一样唯一区别是它不直接返回结果给客户端而是把搜索结果存储到另一个destination键中。这个命令在需要缓存复杂的地理查询结果或者进行多步骤地理计算时非常有用。2.2 Spring Data Redis中的GEO操作在实际的Java项目中我们很少直接裸写Redis命令而是通过Spring Data Redis这样的框架来操作。它提供了一套面向对象的API用起来更顺手。添加一个坐标点// stringRedisTemplate 是常用的Redis操作模板 stringRedisTemplate.opsForGeo().add(geo:shop:1, new Point(116.403847, 39.915526), shop_1001);这里的“geo:shop:1”是键1可以代表店铺类型ID。Point对象封装了经度和纬度“shop_1001”就是店铺ID。进行附近搜索// 这是实现我们核心功能的关键代码片段 Distance distance new Distance(5, Metrics.KILOMETERS); // 搜索半径5公里 GeoResultsRedisGeoCommands.GeoLocationString results stringRedisTemplate.opsForGeo() .search( geo:shop:1, // 指定GEO集合的key GeoReference.fromCoordinate(new Point(116.397427, 39.90925)), // 中心点坐标 distance, // 搜索半径 RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs() .includeDistance() // 非常重要要求在结果中包含距离信息 .limit(100) // 限制返回总数用于后续内存分页 .sortAscending() // 按距离升序排列 );拿到GeoResults对象后我们就可以从中解析出店铺ID和对应的距离了。includeDistance()这个选项务必记得加上否则返回的结果里只有ID没有距离用户体验就不完整了。3. 实战第一步把百万商户数据“搬”进Redis理论懂了命令会了接下来就是真刀真枪地干。第一步我们得把数据库里已有的、或者未来新增的商户坐标高效地导入到Redis中。这里有个关键策略分类存储。3.1 为什么不能把所有店铺混在一个Key里你可能会想创建一个叫geo:shops:all的键把所有店铺都塞进去查询的时候指定类型过滤不就行了这个想法很危险。Redis的GEOSEARCH命令本身不支持在搜索时按member的某个属性比如类型进行过滤。它只认坐标范围。如果把所有类型的店铺都放在一起每次查询“附近的咖啡馆”Redis会先把用户周围5公里内所有类型的店铺餐馆、超市、健身房…都找出来可能在内存中形成一个有几千个ID的集合然后我们的应用服务再从中过滤出类型是咖啡馆的。这相当于把过滤的计算压力从Redis转移回了应用服务器并且传输了大量无用数据完全违背了使用Redis GEO做性能优化的初衷。正确的做法是按店铺类型分桶存储。也就是为每一种店铺类型创建一个独立的GEO集合键。例如geo:shop:1- 存储所有“美食”类店铺的坐标geo:shop:2- 存储所有“咖啡奶茶”类店铺的坐标geo:shop:3- 存储所有“超市便利”类店铺的坐标这样当用户查询“附近5公里的咖啡馆”时我们直接对geo:shop:2这个键执行GEOSEARCHRedis返回的结果天生就是目标类型的店铺高效又精准。3.2 数据同步与初始化脚本对于存量数据的初始化我们通常写一个一次性的加载脚本。这里我给出一个Spring Boot测试类中的示例它演示了如何从MySQL中分批读取店铺数据并按类型分组写入Redis。Test void loadShopData() { // 1. 从数据库查询所有店铺信息假设数据量很大这里要小心内存 ListShop shopList shopService.list(); // 2. 按照店铺类型ID进行分组 MapLong, ListShop shopsByType shopList.stream() .collect(Collectors.groupingBy(Shop::getTypeId)); // 3. 遍历每个分组写入对应的Redis GEO键 for (Map.EntryLong, ListShop entry : shopsByType.entrySet()) { Long typeId entry.getKey(); ListShop shopsOfThisType entry.getValue(); // 构建Redis Key例如shop:geo:1 String redisKey RedisConstants.SHOP_GEO_KEY typeId; // 准备批量写入的数据 ListRedisGeoCommands.GeoLocationString locations new ArrayList(); for (Shop shop : shopsOfThisType) { // 确保shop对象有经纬度信息 if (shop.getX() ! null shop.getY() ! null) { locations.add(new RedisGeoCommands.GeoLocation( shop.getId().toString(), // member: 店铺ID new Point(shop.getX(), shop.getY()) // 坐标 )); } } // 批量添加性能远高于循环调用单次add if (!locations.isEmpty()) { stringRedisTemplate.opsForGeo().add(redisKey, locations); log.info(成功加载类型 {} 的 {} 个店铺坐标到 {}, typeId, locations.size(), redisKey); } } }几个实战细节批量操作使用add方法传入一个GeoLocation列表而不是在循环里单条插入这能减少网络往返次数提升初始化速度几十倍。空值判断总有那么一些店铺坐标是缺失的写入前一定要判断避免插入空的或无效的坐标点。键名设计像shop:geo:{typeId}这样的命名清晰明了便于管理和后续通过模式匹配进行清理。对于增量数据新注册的店铺我们可以在店铺创建或更新的业务逻辑中同步地调用GEOADD命令将其坐标添加到对应类型的GEO集合中。为了保证数据一致性这里通常需要和数据库操作放在同一个事务里或者通过监听数据库的Binlog变更来实现异步同步。4. 核心挑战附近搜索与分页的“完美联姻”数据准备好了简单的附近搜索比如返回前100个用GEOSEARCH一句命令就能搞定。但产品经理马上会提出下一个需求“列表得分页啊一页20条用户可以上拉加载更多。” 问题来了Redis GEO的原生命令不支持OFFSET和LIMIT参数来实现分页。它只能通过COUNT限制返回的总数。这意味着我们不能像操作数据库那样说“给我第2页的数据”。那怎么办呢这里我分享两种最常用的方案并详细讲解我们项目中采用的“内存分页”方案。4.1 方案对比内存分页 vs 游标分页方案一游标分页Cursor-based Pagination这更像一种“无限滚动”的模式。第一次查询时Redis会返回结果和一个游标Cursor。请求下一页时客户端带上这个游标Redis就从上次结束的地方继续返回后续结果。听起来很理想但遗憾的是Redis GEO的GEOSEARCH命令不支持游标。这个方案行不通。方案二内存分页Client-side Pagination这是目前最主流、最实用的方案。思路是向Redis请求比实际需要更多的数据比如前N条然后在应用程序的内存中进行截取得到真正属于当前页的数据。优点实现简单逻辑直观与现有的分页接口兼容性好。缺点存在“放大查询”的问题。比如用户要第100页每页20条我们就需要让Redis先查出前2000条数据然后在内存里扔掉前1980条返回最后20条。如果并发很高对应用服务器内存和Redis网络带宽有一定压力。但对于绝大多数应用用户浏览深度很少超过10页这个开销是可接受的。我们的项目就采用了内存分页方案。它的关键在于如何精准地计算每次需要向Redis请求多少数据。4.2 接口设计与参数传递首先改造我们的查询接口让它同时支持“普通分类分页”和“带地理位置的分类分页”。GetMapping(/of/type) public Result queryShopByType( RequestParam(typeId) Integer typeId, RequestParam(value current, defaultValue 1) Integer current, RequestParam(value x, required false) Double x, RequestParam(value y, required false) Double y) { // 将参数传递给Service层 return shopService.queryShopByType(typeId, current, x, y); }typeId和current当前页码是必传的。x和y用户经纬度改为非必传。这是一个重要的设计当用户没有授权定位或者主动选择“全城搜索”时我们就退回到普通的按类型数据库分页查询。这样保证了功能的兼容性和用户体验的连贯性。4.3 服务层逻辑实现详解接下来是重头戏Service层的实现逻辑。我会逐行分析并解释为什么这么做。Override public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) { // 1. 判断是否为地理位置查询 if (x null || y null) { // 非地理查询走普通数据库分页 PageShop page query() .eq(type_id, typeId) .page(new Page(current, SystemConstants.DEFAULT_PAGE_SIZE)); return Result.ok(page.getRecords()); } // 2. 计算分页参数核心 int pageSize SystemConstants.DEFAULT_PAGE_SIZE; // 假设每页10条 int startIndex (current - 1) * pageSize; // 当前页在完整结果集中的起始索引 (from) int endIndex current * pageSize; // 我们请求Redis时需要获取到的结束索引 (end) // 3. 构建Redis Key并执行GEO查询 String key RedisConstants.SHOP_GEO_KEY typeId; Distance radius new Distance(5, Metrics.KILOMETERS); // 搜索半径5公里 GeoResultsRedisGeoCommands.GeoLocationString results stringRedisTemplate.opsForGeo() .search( key, GeoReference.fromCoordinate(x, y), radius, RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs() .includeDistance() // 必须包含距离 .limit(endIndex) // 关键告诉Redis我们要前 endIndex 条数据 .sortAscending() ); // 4. 处理Redis查询结果 if (results null || results.getContent().isEmpty()) { return Result.ok(Collections.emptyList()); } ListGeoResultRedisGeoCommands.GeoLocationString content results.getContent(); // 4.1 处理“页码超出范围”的情况 // 比如总共只有15个店铺用户请求第3页startIndex20直接返回空列表 if (content.size() startIndex) { return Result.ok(Collections.emptyList()); } // 4.2 内存分页截取当前页所需的数据段 ListLong shopIds new ArrayList(pageSize); MapLong, Double distanceMap new HashMap(pageSize); // 用于存储店铺ID和距离的映射 content.stream() .skip(startIndex) // 跳过前面所有页的数据 .limit(pageSize) // 只取当前页大小的数据量 .forEach(item - { String shopIdStr item.getContent().getName(); Long shopId Long.valueOf(shopIdStr); shopIds.add(shopId); // 存储距离单位是查询时指定的此处为公里 distanceMap.put(shopId, item.getDistance().getValue()); }); // 5. 根据ID批量查询店铺详情 if (shopIds.isEmpty()) { return Result.ok(Collections.emptyList()); } // 关键保持顺序按照Redis返回的ID顺序查询数据库 String idStr shopIds.stream() .map(String::valueOf) .collect(Collectors.joining(,)); ListShop shops query() .in(id, shopIds) .last(ORDER BY FIELD(id, idStr )) // 使用MySQL的FIELD函数固定顺序 .list(); // 6. 将距离信息注入店铺对象 for (Shop shop : shops) { shop.setDistance(distanceMap.get(shop.getId())); } // 7. 返回结果 return Result.ok(shops); }这段代码有几个需要特别注意的“坑”和优化点limit(endIndex)的妙用这是内存分页的灵魂。我们不是向Redis要pageSize条数据而是要当前页码 * 每页条数条数据。比如查第3页每页10条我们就让Redis返回前30条。这样我们在应用内存里skip(20)再limit(10)就能准确拿到第21-30条也就是第3页的数据。顺序一致性Redis返回的店铺ID列表是按距离排好序的。我们用这个ID列表去数据库查详情时必须保持这个顺序否则“最近”的店铺可能就乱套了。这里我用了一个小技巧ORDER BY FIELD(id, ...)。这个MySQL函数能按照我们给定的ID顺序来排序结果集。其他数据库也有类似功能如PostgreSQL的ORDER BY array_position(ARRAY[...], id)。距离信息的传递我们在Redis查询时通过includeDistance()拿到了每个店铺的精确距离。这个信息需要塞回给前端展示。我选择在Shop实体类里加一个distance字段或DTO里在查询数据库后通过提前准备好的Map进行匹配赋值。空结果和边界处理一定要判断Redis返回是否为空以及请求的起始索引是否已经超过了结果集总数。这些边界情况处理不好容易导致空指针或者逻辑错误。5. 性能优化与生产环境注意事项功能实现只是第一步要让它在生产环境稳定高效地跑起来还得考虑更多。5.1 依赖版本与连接池配置首先确保你的Spring Data Redis和Lettuce客户端版本支持Redis 6.2的GEOSEARCH命令。我踩过坑旧版本可能只支持废弃的GEORADIUS。在pom.xml里显式声明版本是个好习惯。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId exclusions !-- 排除可能存在的旧版本 -- exclusion groupIdio.lettuce/groupId artifactIdlettuce-core/artifactId /exclusion /exclusions /dependency !-- 引入较新版本 -- dependency groupIdio.lettuce/groupId artifactIdlettuce-core/artifactId version6.1.8.RELEASE/version /dependency其次连接池必不可少。在高并发场景下频繁创建和销毁Redis连接是性能杀手。在application.yml中配置Lettuce连接池参数spring: redis: lettuce: pool: max-active: 20 # 最大连接数根据业务压力调整 max-idle: 10 # 最大空闲连接 min-idle: 5 # 最小空闲连接 max-wait: 2000ms # 获取连接最大等待时间5.2 缓存穿透与雪崩的预防我们的GEO数据是提前全量加载的一般不存在“查不到”的情况所以缓存穿透风险较低。但要警惕缓存雪崩如果Redis集群宕机所有地理位置查询都会瞬间压垮数据库。为此我们需要一个降级方案。我通常在Service层代码中加入一个简单的降级开关// 伪代码通过配置中心或Feature Flag控制 Value(${geo.search.enabled:true}) private boolean geoSearchEnabled; public Result queryShopByType(...) { // 如果GEO搜索功能被禁用或Redis不可用则直接降级到数据库分页查询 if (!geoSearchEnabled || !redisHealthCheck()) { log.warn(GEO搜索降级使用数据库查询); return queryFromDatabase(typeId, current); // 普通的数据库分页方法 } // ... 正常的GEO查询逻辑 }同时确保数据库层面的typeId和经纬度字段上有合适的复合索引这样即使降级查询也不会慢到不可接受。5.3 数据更新与一致性商户信息不是一成不变的。店铺可能会搬迁、关闭或者更正坐标。这就涉及到GEO数据的更新。更新策略在商户管理后台当修改了店铺坐标或类型时除了更新数据库必须同步更新Redis。坐标修改先从旧的GEO集合中移除该店铺ZREM key member然后向新的或同类型GEO集合中添加新坐标GEOADD。类型修改先从旧类型对应的GEO集合中移除再添加到新类型对应的GEO集合中。最终一致性对于不要求强一致性的场景比如店铺坐标更新后几分钟内搜索结果还是老的可以接受可以通过监听数据库变更日志如Canal异步更新Redis。对于要求强一致性的场景则需将Redis更新与数据库更新放在同一个本地事务中或使用分布式事务方案如Seata但这会牺牲一些性能。5.4 监控与扩展示例上线后一定要做好监控。重点关注两个指标Redis GEO命令的耗时通过slowlog命令监控GEOSEARCH的执行时间如果变慢可能是单个Key下的成员数量过多比如某个热门类型下有几十万个店铺需要考虑按城市或区域进行二次拆分例如geo:shop:1:beijing。应用服务器内存因为使用了内存分页要警惕深度分页比如用户翻到第1000页导致应用服务器一次性加载过多数据。可以在代码中对endIndex即请求Redis的limit值设置一个上限比如最大只允许查询前1000条记录并给用户友好的提示“搜索结果过多请缩小搜索范围”。这个基于Redis GEO的附近搜索方案经过我在多个项目中的实践在千万级用户、百万级商户的场景下依然能提供毫秒级的响应。它完美地平衡了性能、开发复杂度和用户体验。当你看到自己实现的应用能像美团、大众点评一样“嗖”地一下列出周围的店铺时那种成就感就是对我们开发者最好的奖励。