附近的人功能如何设计
问题理解(先对齐需求)
面试官您好,关于“附近的人”功能,我先明确几个核心点:
用户量级:千万级DAU(按大厂标准聊)
实时性:位置要相对新鲜,但不必强实时,可以容忍几秒到几分钟的延迟
核心操作:上传坐标、搜索附近的人、分页、按距离排序
隐含需求:高并发、低延迟、数据一致性不必强求,可用最终一致
核心需求拆解 📋
需求类型 具体要求 技术指标
功能需求 查看指定范围内的用户按距离排序支持性别 / 年龄筛选实时更新位置 查询延迟 < 100ms支持百万级同时在线
非功能需求 隐私保护高并发数据一致性容灾备份 位置精度误差 < 50m可用性 99.99%
整体架构设计 🏗️
image
核心流程:
用户上线 / 移动时,上报经纬度到位置服务
位置服务更新数据库和缓存
用户查询附近的人时,先查缓存,缓存 miss 再查数据库
结果按距离排序并过滤后返回给用户
核心技术方案 ⚙️
- 地理位置索引:GeoHash 算法 📍
这是实现附近的人最核心的技术,将二维经纬度编码为一维字符串:
编码长度越长,精度越高(6 位≈61m,7 位≈76m)
相邻区域的 GeoHash 前缀相同
查询时只需匹配相同前缀的用户,再计算精确距离
示例:北京天安门的 GeoHash 是wx4g0ec1,附近 500 米内的用户 GeoHash 都以wx4g0e开头。
- 数据存储方案 💾
Redis Geo:首选方案,Redis 3.2 + 原生支持 Geo 命令
GEOADD:添加用户位置
GEORADIUS:查询指定半径内的用户
GEODIST:计算两个用户之间的距离
优点:高性能、支持原子操作、自带排序
MySQL 空间索引:作为持久化存储
使用POINT类型存储经纬度
创建SPATIAL INDEX加速查询
用于 Redis 缓存失效后的兜底查询
为什么首选 Redis Geo
“附近的人”本质是一个 LBS(基于位置服务)的地理位置检索,典型需求是:
传入一个坐标(lat, lng)和半径 r,返回半径内的所有用户,并按距离排序。
我选 Redis Geo,原因很简单:
方案 核心数据结构 优缺点
MySQL 经纬度字段 + 球面距离公式 普通索引 需全表扫,无法利用索引,千万级直接跪 ❌
MySQL Spatial (R-tree) 空间索引 边界查询可用,但排序和距离计算仍较重,高并发扛不住 ❌
MongoDB 2dsphere Geohash + B树 功能可以,但生态和运维成本大,调用链路长 ⚠️
Redis Geo ZSET + Geohash 原生内存操作,读写极快,单机10w+ QPS ✅
Redis Geo 底层是把经纬度编码成 Geohash 字符串,塞进一个 Sorted Set。Score 就是这个 Geohash 转化成的 52 位整数。这样一来,附近的点,其 Geohash 前缀相同,落在 ZSET 的相邻区间,范围查询就是 ZSET 的 ZRANGEBYSCORE,时间复杂度 O(log(N)+M),非常快。
我画个简单的结构图:
image
- 高性能查询优化 🚀
是
否
用户查询请求
缓存是否命中?
直接返回缓存结果
查询Redis Geo
计算精确距离并排序
过滤用户信息
更新缓存
缓存预热:热门区域提前加载用户位置到 Redis
分页查询:每次只返回前 50-100 个用户,避免数据量过大
结果缓存:查询结果缓存 10-30 秒,减轻数据库压力
异步更新:用户位置更新通过 Kafka 异步处理,不阻塞主流程
🧠4. 核心实现:Redis 命令与编码细节
业务层就这么几条命令:
① 用户位置更新
GEOADD nearby:users 116.404 39.915 user:1001
每次用户打开 App 或移动一定距离后上报,我们实时执行这条。高并发下用 Pipeline 或 Redis Cluster 的 hash tag 保证同一个用户落在相同分片。
② 搜索附近的人
GEORADIUS nearby:users 116.405 39.916 5 km WITHDIST COUNT 20 ASC
返回距离、用户ID,按距离升序,带分页?Redis 没有游标,但我们用 COUNT 和客户端保存的 offset 做逻辑分页。更深层的分页不推荐,通常限制前 N 页,比如 200 条。
③ 扩展性考虑
如果用 Redis Cluster,Geo 相关的 key 必须用 hash tag 保证在一个节点:
GEOADD {nearby}:users …
搜索时也这样指定。同时我们按城市或大区域预分片,避免单个 ZSET 过大(建议一个 key 内用户数 < 50 万,过大用多 key 分片)。
📊 整体架构(带图更直观)
image
位置服务 无状态,方便水平扩展。
Redis Geo 分片:比如 {beijing}:nearby:users,{shanghai}:nearby:users。
搜索完后得到一堆 user id,批量去 Redis 缓存里拿头像昵称,缓存未命中穿透到 MySQL。
用户位置更新同时异步发一条 MQ,用于轨迹存储、风控等。
进阶考虑与优化 🎯
隐私保护 🔒
用户可随时开启 / 关闭 “附近的人” 功能
位置信息只保留最近 24 小时,过期自动删除
支持模糊位置显示(如 “距离 100 米内” 而非精确坐标)
禁止非好友查看用户详细位置高并发处理 ⚡
读写分离:读操作走 Redis,写操作异步同步到 MySQL
分片存储:按 GeoHash 前缀分片,将不同区域的用户分散到不同 Redis 节点
限流保护:限制单个用户每秒查询次数,防止恶意刷接口容灾与扩展 🛡️
Redis 集群部署,主从复制 + 哨兵模式保证高可用
MySQL 主从复制,读写分离
多机房部署,异地容灾
支持水平扩展,用户量增加时只需添加 Redis 节点🔹分页的坑
GEORADIUS 没有服务端游标,所以若每次都传 offset 200,其实 Redis 内部会计算全部结果然后截取,浪费 CPU。解决:业务限定最大翻页,比如第 10 页之后直接拒绝;或在前端交互上做无限滚动加载一小段,同时缓存“看过的人”去重。🔹热点数据
某个城市的 Geo Key 可能成为热点,比如北京。方案:
同一城市内再按地理位置做二级分片:{beijing:1}:nearby,{beijing:2}:nearby,查询时根据请求坐标决定查哪个分片和相邻分片(Geohash 边界)。
主从读分离,本地缓存常用搜索结果。
6. 🔹用户频繁移动
不能每次手机抖一下就更新 Redis。客户端做阈度上报:移动超过 50 米,或超过 2 分钟,才上传一次。
- 🔹踢除离线用户
Redis 里只留活跃用户。用 TTL?不行,Geo 不能直接对 member 设过期。我们的做法:
额外维护一个 online:users 的 ZSET,用最后心跳时间做 score。定时任务扫出过期用户,一并从 Geo Key 和在线 Key 中删除。
或者 Geo 成员设一个“逻辑过期”时间戳在 Value 中,这里 Redis Geo 只存 member 是 user:id,不能存额外字段,所以需要另一个 key 存时间,异步清理。
8. 🧩一些深层追问的思考
Q: 如果不满足 Redis Geo,比如要在附近人中按兴趣过滤?
那就需要 Geo 提供候选集,再在应用层做交集。或用 MongoDB + 多条件索引,但会牺牲一些性能。大厂可能会自研 LBS 引擎,基于 Geohash 建立倒排索引。
Q: 数据倾斜怎么处理?
热点城市多分片,并且监控每个分片大小,动态分裂。
Q: 如何保证服务的最终一致性?
位置更新可接受短暂不一致。Redis 本身高性能,做 AOF 持久化,挂了从 RDB 恢复 + 用户心跳重建在线状态。
总结 ✨
“附近的人”核心就三句话:
用 Redis Geo 扛住海量位置读写,底层 ZSET + Geohash 精准索引。
架构上做水平分片、按区域拆分 key,避免大 key 和热点。
用户状态、分页、业务过滤都做保护,防止滥用和性能劣化。
如果需要更高维度的组合查询,可以引入 ES 的地理位置类型,但单纯一个“附近”功能,Redis Geo 是最轻量高性能的解法。
这个设计方案以Redis Geo为核心,结合 GeoHash 算法实现高效的地理位置查询,通过缓存、异步更新、分片等技术保证高并发性能,同时充分考虑了用户隐私保护和系统容灾能力。可以轻松支撑百万级同时在线用户,查询延迟控制在 100ms 以内,完全满足互联网大厂的业务需求。
附近的人功能:核心代码 + 技术难点全解 🔥
完全贴合大厂面试标准,直接可背、可写、可讲,代码简洁有亮点,难点一针见血!
核心代码(Java + Redis Geo)✨
这是面试最加分、最能体现真实开发经验的代码,基于 Spring Boot + RedisTemplate 实现。
- 依赖(Spring Boot 环境)
}
4. 返回实体
@Data
public class NearUserDTO {
private Long userId;
private Double distance; // 距离多少米
// 可扩展:昵称、头像、性别、年龄等
}
5. 位置更新(GEOADD)—— 附带 LUA 原子保活
// 用户上报位置,同时刷新在线心跳
public void updateLocation(String userId, double lng, double lat) {
String geoKey = resolveGeoKey(lng, lat); // 按城市分片,如 “{beijing}:nearby:users”
String onlineKey = “online:heartbeat”;
long now = System.currentTimeMillis();
// 使用 Lettuce 异步 + Pipeline 减少 RTT redisTemplate.executePipelined((RedisCallback<Object>) connection -> { byte[] geoKeyBytes = geoKey.getBytes(); byte[] member = userId.getBytes(); // GEOADD key longitude latitude member connection.geoCommands().geoAdd(geoKeyBytes, new Point(lng, lat), member); // 同时更新在线心跳 ZSET,score = 当前时间戳 connection.zSetCommands().zAdd(onlineKey.getBytes(), now, member); return null; });}
