Feed 流推送与附近商户:从推模式到 GeoHash,一条 Timeline 的完整旅程


前言

先问你一个问题。

你刷朋友圈,下拉刷新------你看到的动态,是怎么到你手机上的?

如果你的答案是"后台查数据库,SELECT * FROM feed ORDER BY create_time DESC"------那再问一个。

你关注的某个用户有 100 个粉丝。他发了一条动态。这 100 个粉丝同时下拉刷新------数据库要执行 100 次 SELECT,每次都扫描全表、做多表 JOIN、按时间排序。100 个人没问题。10 万人呢?100 万人呢?

这两个问题,就是 Feed 流系统的核心难点。不是"能不能查出数据",而是写的时候多做一点,还是读的时候多做一点------这笔账怎么算

这篇文章,我把 O2O 平台(Online to Offline,线上到线下本地服务平台)里 Feed 流推送 + 附近商户检索的完整设计拆开,从推模式的 ZSET(Redis 有序集合,按 score 自动排序)收件箱、时间游标分页,到大 V 场景的推拉结合冷热分离,再到 Redis GEO 的 GeoHash 底层原理------每一层都有代码,每一个设计决策都有"为什么"。


一、Feed 流到底难在哪?

先画边界。Feed 流不是"查数据排序"五个字能概括的------本质上是一个读写扩散的权衡问题

复制代码
                     ┌─ 写扩散 ──────── 发一条动态要写 N 个收件箱
                     │
用户发动态 ──→ Feed 系统 ─┼─ 读扩散 ──────── 刷一次 Timeline 要查 M 个发件箱
                     │
                     ├─ 翻页重复 ────── 传统 offset 对新数据不免疫
                     │
                     ├─ 存储膨胀 ────── 收件箱无限增长,Redis 内存撑爆
                     │
                     └─ 大 V 困境 ────── 粉丝过亿,推模式写扩散是灾难

五个问题,本质上只围绕一个核心决策:推模式还是拉模式。这个决策影响后面所有的架构设计。

推模式(Push / Fan-out on Write)

用户发动态时,把这条动态 ID 主动写入所有粉丝的 Redis ZSET 收件箱。粉丝读 Timeline 时,直接从自己的收件箱按时间倒序拉取------读操作极快,O(logN+M)(对数级复杂度,数据量翻倍,查询步数只加一步)。

代价是写扩散:发 1 条动态,要写 N 次(N = 粉丝数)。

拉模式(Pull / Fan-out on Read)

用户发动态时只写一条到自己发件箱。粉丝读 Timeline 时,实时查询所有关注人的发件箱,按时间合并排序------写操作极轻,O(1)。

代价是读扩散:刷 1 次 Timeline,要查 M 个发件箱(M = 关注数),再合并排序。

选型的本质

复制代码
                         推模式                          拉模式
                     写扩散 / 读轻                       写轻 / 读扩散

发动态成本              O(N),N=粉丝数                      O(1)
读Timeline成本          O(logN+M),读自己收件箱              O(M*logK),查M个发件箱再合并

适用场景              读多写少,粉丝量可控                 读少写多,存在大V
典型产品              微信朋友圈                          微博(大V用拉模式)

我的项目是校园 O2O 平台------用户量千级,大 V 粉丝最多几百人,用户每天刷 Timeline 的频率远高于发动态的频率。读多写少 + 粉丝量小 → 推模式

下面逐层拆解。


二、核心设计:推模式 + ZSET 收件箱

2.1 为什么是 ZSET?

收件箱的核心需求只有一个:按时间倒序取最新 N 条

ZSET(Redis 有序集合,底层是跳表)天然满足------member(成员,相当于"学号")存动态 ID,score(分值,相当于"总分")存发布时间戳。ZREVRANGEBYSCORE(按 score 范围倒序取元素)按时间倒序取,时间复杂度 O(logN+M)。不需要去 MySQL 做 ORDER BY create_time DESC 的全表扫描。

2.2 发布动态(写扩散)

java 复制代码
// hm-dianping/src/main/java/com/hmdp/service/impl/BlogServiceImpl.java
@Service
public class BlogServiceImpl implements IBlogService {

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private BlogMapper blogMapper;
    @Autowired
    private FollowService followService;

    // ========== 发布动态(写扩散 - Fan-out on Write) ==========
    @Transactional
    public void publishBlog(Blog blog) {
        // 1. 保存博文到 MySQL(持久化,正文存储)
        blogMapper.insert(blog);

        // 2. 查询所有粉丝 ID
        List<Long> followerIds = followService.getFollowerIds(blog.getUserId());

        // 3. 推送到每个粉丝的收件箱(ZSET)
        //    member = 博文ID
        //    score  = 发布时间戳(毫秒)
        long score = System.currentTimeMillis();
        for (Long followerId : followerIds) {
            String inboxKey = "feed:inbox:" + followerId;
            redisTemplate.opsForZSet().add(inboxKey, blog.getId().toString(), score);

            // 收件箱容量限制:保留 score 最高的 1000 条
            redisTemplate.opsForZSet()
                .removeRange(inboxKey, 0, -1001);
        }
    }
}

关键细节

  • 博文正文存 MySQL,收件箱只存 ID 。ZSET 里只有 Long 型 ID + Long 型时间戳,一条记录约 100 字节;正文放 MySQL 用 selectBatchIds 批量查
  • ZREMRANGEBYRANK(按排名删除元素)在 ZADD(向 ZSET 添加元素)之后执行。每次写入后裁剪超出的旧数据,保证收件箱永不超过 1000 条
  • -1001 的语义 :ZRANGE(按排名范围取元素)按 score 升序排列,removeRange(inboxKey, 0, -1001) 删除排名从 0 到倒数第 1001 的全部元素------保留 score 最高的 1000 条,删除旧的

2.3 读取 Timeline(时间游标分页)

java 复制代码
    // ========== 读取 Timeline(时间游标分页) ==========
    public List<Blog> getTimeline(Long userId, Long cursor, int size) {
        String inboxKey = "feed:inbox:" + userId;

        // 游标分页:按 score 范围查询,替代传统 offset
        // cursor = 上一次返回的最后一条的 score(时间戳)
        // 查询 score 在 [0, cursor) 区间内的前 size 条
        Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(
                inboxKey,
                0,              // min score(0 = 最早的时间戳)
                cursor - 1,     // max score(cursor 的上一条,不包含 cursor 本身)
                0,              // offset(从 score 范围内匹配的第一条开始)
                size            // 取多少条
            );

        if (tuples == null || tuples.isEmpty()) {
            return List.of();
        }

        // 提取博文 ID 列表
        List<Long> blogIds = tuples.stream()
            .map(t -> Long.valueOf(t.getValue()))
            .toList();

        // 批量查 MySQL 获取博文详情(正文、点赞数、评论数等)
        List<Blog> blogs = blogMapper.selectBatchIds(blogIds);

        // 按收件箱顺序重新排序(MySQL IN 查询不保证顺序)
        Map<Long, Blog> blogMap = blogs.stream()
            .collect(Collectors.toMap(Blog::getId, b -> b));
        return blogIds.stream()
            .map(blogMap::get)
            .filter(Objects::nonNull)
            .toList();
    }
}

关键细节

  • cursor 初始值 :第一页传 System.currentTimeMillis()(当前时间戳),表示"从现在开始往前翻"
  • cursor 更新:每次返回的最后一条的 score 作为下一次请求的 cursor
  • cursor - 1 :减 1 是为了不包含上一次的最后一条,防止重复(因为 reverseRangeByScoreWithScores 的 max 是 inclusive 的)
  • MySQL 查回后重新排序selectBatchIds 底层是 WHERE id IN (...),MySQL 不保证返回顺序与 IN 列表一致,必须按收件箱的原顺序重新排列

✅ 推模式 + ZSET 方案解决了:读 Timeline 的延迟问题------粉丝直接从自己的收件箱取,O(logN+M) 毫秒级返回,不依赖数据库 JOIN 和排序;写扩散的代价在粉丝量千级的校园场景完全可接受
❌ 但还需要解决:翻页重复(offset 对新数据不免疫)→ 下一章的时间游标分页;收件箱无限膨胀 → 第四章的容量上限;大 V 写扩散爆炸 → 第五章的推拉结合


三、时间游标分页:为什么不用 offset?

3.1 offset 分页的致命缺陷

传统分页用 SQL 的 LIMIT + OFFSET:

sql 复制代码
-- 第一页
SELECT * FROM feed WHERE user_id = 1 ORDER BY create_time DESC LIMIT 10 OFFSET 0;
-- 第二页
SELECT * FROM feed WHERE user_id = 1 ORDER BY create_time DESC LIMIT 10 OFFSET 10;

问题不在于 SQL------在于两次查询之间,数据变了

复制代码
第一页请求 → 返回动态 #100 ~ #91(最新的 10 条)
    ↓
  用户 A 发了一条新动态 #101(插入到最前面)
    ↓
第二页请求 → OFFSET 10 → 跳过 #100 ~ #91?
  不------#101 插到最前面,原来的 #100 变成了第 2 条,#91 变成了第 11 条
  → 用户第二页看到的是 #91 ~ #82
  → #91 重复了!(第一页最后一条 = 第二页第一条)

Redis ZSET 的 ZREVRANGE 也有同样的问题------两次 ZREVRANGE key start stop 调用之间,新写入的 score 会改变整个排序。

3.2 时间游标如何解决

核心思路:不用"跳过前 N 条",而是用"score 小于某个值的后 N 条"

java 复制代码
// 第一页:cursor = 当前时间戳
reverseRangeByScoreWithScores("feed:inbox:" + userId,
    0,              // 最小 score(0 = 最早)
    cursor - 1,     // 最大 score(当前时间 - 1)
    0,              // 从这个 score 范围内的第一条开始
    10);            // 取 10 条

// 假如返回的最后一条 score = 1718352000000
// 第二页:cursor = 1718352000000
reverseRangeByScoreWithScores("feed:inbox:" + userId,
    0,                  // 最小 score
    cursor - 1,        // 最大 score = 1718351999999
    0,
    10);

本质区别

复制代码
offset 分页:                         游标分页:
"给我第 21~30 条"                     "给我 score < X 的最近 10 条"
↓                                    ↓
依赖"第几条"这个绝对位置             依赖"score 值"这个相对位置
↓                                    ↓
新数据插入 → 绝对位置漂移            新数据 score > X → 不在查询范围内
→ 翻页重复/遗漏                      → 对增量数据天然免疫

3.3 游标分页的两个限制

① 不能跳页。 用户不能直接跳到第 5 页------只能一页一页往下翻。但对于 Feed 流,没人会跳到第 50 页看一周前的动态,这个限制可以接受。

② score 碰撞。 两条动态同一毫秒发布怎么办?简单方案:score = timestamp * 1000 + (blogId % 1000),用博文 ID 的后几位打破碰撞。
🔑 小鱼点睛

面试官问"游标分页能不能跳页"是在考你对这个方案局限性的理解------不回避短板,而是讲清楚为什么这个短板在你的场景里可以接受,这才是合格的架构回答。同时可以提一句:如果产品真的需要跳页(比如微博的"跳到指定日期"),可以改用"时间戳游标 + 日期索引"的混合方案------先定位到日期,再在该日期内用游标翻页。


四、ZSET 收件箱容量:会不会无限增长?

4.1 容量上限 + 淘汰策略

每个用户的收件箱 feed:inbox:{userId} 是一个 ZSET。不设上限的话,用户量上来后 Redis 内存会无限膨胀。

我的方案:每用户最多 1000 条。

java 复制代码
// 每次写入后立即裁剪
redisTemplate.opsForZSet().add(inboxKey, blogId, score);
redisTemplate.opsForZSet().removeRange(inboxKey, 0, -1001);
//                                             ↑      ↑
//                                           rank 0  rank=倒数第1001
//                                    删除最旧的     保留最新的1000条

4.2 内存估算

复制代码
每条记录 ≈ 100 字节 (member 8B + score 8B + ZSET 内部跳表开销 ~84B;跳表 = Skip List,一种多层链表结构,查找时像坐电梯一样跳过不必要的楼层,因此效率为 O(logN))
1000 条 × 100B  = 100KB / 用户
10 万用户       ≈ 10GB(Redis 可承受)

实际上,大量用户不活跃,收件箱是空的或只有几十条------实际占用远小于理论最大值。

4.3 面试官的追问:用户量到了一千万怎么办?

10GB × 100 = 1TB,单机 Redis 扛不住。此时两个手段:

① Redis Cluster(集群)分片。userId hash(哈希,一种将任意数据映射到固定范围数字的算法)分布收件箱,每个分片独立承担一部分用户:

复制代码
hash(userId) % 分片数 → 定位到具体 Redis 节点
feed:inbox:{userId} 只存在一个节点上

② 冷热分离。 30 天未登录的用户清空收件箱,登录时用拉模式冷启动(冷启动 = 用户登录时才临时聚合 Timeline,而不是一直维护着收件箱):

java 复制代码
// 用户登录时
if (user.getLastLoginTime().before(thirtyDaysAgo)) {
    // 清空收件箱(太久没登录,里面全是过期数据)
    redisTemplate.delete("feed:inbox:" + userId);
    // 拉模式冷启动:实时聚合所有关注人的最新动态
    List<Blog> coldStart = pullModeGetTimeline(userId);
    // 重建收件箱(预热)
    rebuildInbox(userId, coldStart);
}

✅ 容量上限防住了:单用户内存上限可控 → 可以线性推算集群总容量
❌ 但防不住:突发热点------某个大 V 一夜爆火,粉丝从 500 涨到 50 万,推模式写扩散瞬间爆炸。这就是下一节要解决的问题。


五、大 V 场景:推拉结合(冷热分离)

5.1 推模式的致命弱点(阿克琉斯之踵)

推模式有一个前提假设------每个用户的粉丝量不大。这个假设在校园 O2O 平台成立(用户量千级,粉丝最多几百人),但一旦出现大 V(粉丝百万级),推模式写扩散的代价是灾难性的:

复制代码
普通用户发动态:ZADD 500 次    → 500 个粉丝收件箱,瞬间完成
大 V 发动态:   ZADD 500 万次  → 500 万次网络 IO,Redis 主线程阻塞

这就是微博/推特早期踩过的坑------纯推模式在大 V 面前直接崩溃。

5.2 推拉结合的方案

不是我发明------这是微博/推特的经典架构,核心思路:按用户类型做路由

用户类型 判定标准 动态分发方式
普通用户 粉丝 < 1万 推模式:发动态 → 写入所有粉丝收件箱
大 V 粉丝 ≥ 1万 拉模式:发动态 → 只写自己的发件箱,不扩散
活跃用户 近 7 天登录 收件箱里包含:普通关注者的推内容 + 关注大 V 的拉内容(聚合)
非活跃用户 近 30 天未登录 不维护收件箱,登录时临时聚合 → 重建收件箱

5.3 聚合逻辑实现

java 复制代码
/**
 * 推拉结合模式下的 Timeline 读取。
 * 普通用户的内容在收件箱里(推),大 V 的内容需要实时拉取。
 */
public List<Blog> getTimelineMixed(Long userId, Long cursor, int size) {
    // 1. 从自己的收件箱取(普通用户的推内容)
    Set<ZSetOperations.TypedTuple<String>> inbox = redisTemplate.opsForZSet()
        .reverseRangeByScoreWithScores(
            "feed:inbox:" + userId,
            0, cursor - 1, 0, size
        );

    // 2. 查询自己关注的大 V 列表
    List<Long> bigVIds = bigVService.getFollowedBigVs(userId);
    if (bigVIds.isEmpty()) {
        // 没有关注大 V → 纯推模式,直接返回收件箱内容
        return fetchBlogsFromTuples(inbox);
    }

    // 3. 并发从大 V 的发件箱拉取最新动态
    List<CompletableFuture<List<ZSetOperations.TypedTuple<String>>>> futures = bigVIds.stream()
        .map(uid -> CompletableFuture.supplyAsync(() ->
            redisTemplate.opsForZSet()
                .reverseRangeByScoreWithScores(
                    "feed:outbox:" + uid,
                    0, cursor - 1, 0, size / 2
                )
        ))
        .toList();

    // 4. 合并所有来源,按 score 降序排序
    List<ZSetOperations.TypedTuple<String>> allTuples = new ArrayList<>();
    if (inbox != null) allTuples.addAll(inbox);
    for (CompletableFuture<List<ZSetOperations.TypedTuple<String>>> future : futures) {
        try {
            List<ZSetOperations.TypedTuple<String>> result = future.get(200, TimeUnit.MILLISECONDS);
            if (result != null) allTuples.addAll(result);
        } catch (TimeoutException e) {
            // 大 V 发件箱查询超时 → 降级,跳过这个大 V
            log.warn("大V发件箱查询超时,降级跳过 uid={}", uid);
        }
    }

    // 按 score 降序排序,取前 size 条
    allTuples.sort((a, b) -> Double.compare(b.getScore(), a.getScore()));
    List<ZSetOperations.TypedTuple<String>> topTuples = allTuples.stream()
        .limit(size).toList();

    return fetchBlogsFromTuples(topTuples);
}

关键设计

  • 并发拉取 :大 V 的发件箱查询用 CompletableFuture(Java 异步编程工具------把多个任务同时扔出去执行,最后一起收结果,不用一个一个排队等)并发执行,不串行等待
  • 超时降级:单个大 V 查询超时 200ms 则跳过,不阻塞整体返回
  • size/2 策略:每个大 V 只取半页,因为最终还要合并,取太多浪费

5.4 更进一步:Timeline 温缓存

在推和拉之间加一层"温缓存"(介于热数据 Redis 和冷数据 MySQL 之间的中间层------有 TTL 的短期缓存,比 Redis 永久数据"凉"一点,比 MySQL"热"一点,所以叫"温")------给大 V 粉丝预聚合好的 Timeline,TTL(Time To Live,缓存过期时间)很短(1-2 分钟):

复制代码
大 V 发动态 → 只写自己的发件箱(O(1))
              ↓
粉丝读 Timeline → 查 Timeline 缓存
                  ↓ 命中 → 直接返回(O(1))
                  ↓ 未命中 → 聚合所有关注者的发件箱 → 写入缓存(TTL 1min) → 返回

这样,即使是大 V 粉丝的 Timeline 读取,大部分请求也命中缓存------只要缓存 TTL 足够短,用户感知不到延迟。
🔑 小鱼点睛

面试官问"大 V 怎么办"不是在期待你推翻推模式------而是在考你"能不能看到问题的边界,并给出合理的权衡"。推拉结合就是业界标准答案------它承认推模式的天花板,同时给出了平滑过渡的方案。如果面试官继续追问"百万大 V 发一条动态,推拉结合里拉模式要查百万粉丝的关注列表",你的回应是:实际上不需要------大 V 的粉丝读 Timeline 时,是从自己的关注列表里查到大 V,再去大 V 发件箱拉取。这是 O(粉丝关注数) 的复杂度,不是 O(大V粉丝数)。


六、附近商户检索:Redis GEO 实战

6.1 为什么不用 MySQL 空间函数?

MySQL 8.0 有 ST_Distance_Sphere,但有两个问题:

sql 复制代码
-- MySQL 方式:计算每个商户到用户位置的距离,排序取最近 20 个
SELECT *, ST_Distance_Sphere(POINT(115.85, 28.68), POINT(lng, lat)) AS distance
FROM shop
ORDER BY distance
LIMIT 20;
  • 全表扫描:即使建了空间索引 SPATIAL INDEX(专门加速地理位置查询的索引类型,MySQL 用 R 树实现),对于"附近商户"这种 OLTP(Online Transaction Processing,在线事务处理------用户实时操作型业务)高频查询,毫秒级延迟很难保证
  • 磁盘 IO:MySQL 的数据在磁盘上(B+树------一种多叉平衡树,叶子节点存数据页,适合磁盘按块读取),Redis 的数据在内存里(跳表),本质差距

Redis GEO 在内存中基于 ZSET 实现,5km 半径检索几百毫秒 → 降到几毫秒。

6.2 初始化 + 检索

java 复制代码
// hm-dianping/src/main/java/com/hmdp/service/impl/ShopServiceImpl.java
@Service
public class ShopServiceImpl implements IShopService {

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ShopMapper shopMapper;

    // ========== 启动时加载商户坐标到 Redis GEO ==========
    @PostConstruct  // Spring 注解:Bean 初始化完成后自动执行,适合做"启动时预加载数据"这种一次性工作
    public void initShopGeo() {
        List<Shop> shops = shopMapper.selectAll();
        for (Shop shop : shops) {
            // GEOADD shop:geo longitude latitude member
            redisTemplate.opsForGeo().add("shop:geo",
                new Point(shop.getLongitude(), shop.getLatitude()),
                shop.getId().toString());
        }
    }

    // ========== 附近商户检索 ==========
    public List<Shop> searchNearby(double longitude, double latitude,
                                   int radiusKm, int limit) {
        String geoKey = "shop:geo";

        // GEOSEARCH:Redis 6.2+ 统一替代 GEORADIUS / GEORADIUSBYMEMBER
        GeoSearchCommandArgs args = GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance()     // 返回距离
            .sortAscending()       // 按距离升序
            .limit(limit);         // 最多返回 N 条

        GeoResults<RedisGeoCommands.GeoLocation<String>> results =
            redisTemplate.opsForGeo().search(
                geoKey,
                // 搜索中心:用户当前坐标
                new GeoReference.GeoCoordinateReference(longitude, latitude),
                // 搜索半径:5km
                new Distance(radiusKm, DistanceUnit.KILOMETERS),
                args
            );

        // 解析结果
        List<Shop> shops = new ArrayList<>();
        for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
            String shopId = result.getContent().getName();
            double distance = result.getDistance().getValue(); // 距离(km)

            Shop shop = shopMapper.selectById(Long.valueOf(shopId));
            shop.setDistance(distance);
            shops.add(shop);
        }
        return shops;
    }
}

6.3 GEOSEARCH vs GEORADIUS

Redis 6.2 之前有两个命令:GEORADIUS(按坐标搜)和 GEORADIUSBYMEMBER(按已存在的成员位置搜)。Redis 6.2 用 GEOSEARCH 统一替代:

复制代码
旧:GEORADIUS shop:geo 115.85 28.68 5 km WITHDIST ASC
新:GEOSEARCH shop:geo FROMLONLAT 115.85 28.68 BYRADIUS 5 km WITHDIST ASC

旧:GEORADIUSBYMEMBER shop:geo "shop:1001" 5 km WITHDIST ASC
新:GEOSEARCH shop:geo FROMMEMBER "shop:1001" BYRADIUS 5 km WITHDIST ASC

一个命令支持按坐标搜 + 按成员搜 + 圆形搜 + 矩形搜,API 更干净。
✅ GEO 工程方案防住了:高频附近商户查询的延迟------内存检索替代磁盘 IO,毫秒级返回;地理位置查询的复杂度------GEOSEARCH 一个命令覆盖所有搜索模式,API 统一不散乱
❌ 但还没讲透:GEOSEARCH 凭什么毫秒级?底层用了什么数据结构?边界情况怎么处理?→ 下一章拆 GeoHash 底层原理


七、GeoHash 底层:5km 毫秒级返回的秘密

7.1 Redis GEO 底层就是 ZSET

Redis 没有为 GEO 单独实现数据结构------它复用了 ZSET。GEO 的每个操作,最终都落在 ZSET 的命令上:

复制代码
GEOADD    → ZADD    (经纬度编码成 score,member 不变)
GEOSEARCH → ZSCAN   (在 score 范围上做过滤 + 距离计算)

7.2 GeoHash 编码原理

GeoHash 的核心思想:把二维的经纬度编码成一维的字符串------通过不断对经纬度范围做二分逼近。

以南昌红谷滩(115.85°E, 28.70°N)为例:

经度编码(范围 -180, 180
复制代码
Step 1: 115.85 在 [0, 180] 右半 → bit=1, 区间缩到 [0, 180]
Step 2: 115.85 在 [90, 180] 右半 → bit=1, 区间缩到 [90, 180]
Step 3: 115.85 在 [90, 135] 左半 → bit=0, 区间缩到 [90, 135]
Step 4: 115.85 在 [112.5, 135] 右半 → bit=1, 区间缩到 [112.5, 135]
... 重复 15 次
纬度编码(范围 -90, 90
复制代码
Step 1: 28.70 在 [0, 90] 右半 → bit=1, 区间缩到 [0, 90]
Step 2: 28.70 在 [0, 45] 左半 → bit=0, 区间缩到 [0, 45]
Step 3: 28.70 在 [22.5, 45] 右半 → bit=1, 区间缩到 [22.5, 45]
... 重复 15 次
交叉合并

把经度和纬度的 bit(二进制位,0 或 1)交替合并------经度放偶数位,纬度放奇数位:

复制代码
经度: 1 1 0 1 ...
纬度: 1 0 1 ...
      ↓ 交叉合并
合并: 11 10 01 ... → 每 5 位转成一个 Base32 字符
                      ↑ Base32 = 用 32 个字符(0-9, a-z 去掉 a/i/l/o)
                        来表示二进制数据的编码方式

最终得到的字符串如 wtc6v------前缀越相同,位置越接近(这是 GeoHash 的核心性质,让我们能做前缀索引)。

7.3 精度对照

GeoHash 长度 精度 适用场景
5 位 ±2.4km 城市级别
6 位 ±0.61km 区/镇级别
7 位 ±0.076km 街道级别
8 位 ±0.019km 建筑级别

7.4 为什么毫秒级返回

GEOSEARCH 的执行过程:

复制代码
1. 将搜索中心的经纬度编码成 GeoHash
2. 根据搜索半径确定需要扫描的 GeoHash 范围(通常 1-9 个格子)
3. 每个格子对应 ZSET 的一段 score 范围 → ZRANGEBYSCORE 查候选集
4. 对候选集逐一计算 Haversine 距离(球面距离公式------地球是球体,不能用平面上两点间直线距离的勾股定理,必须用经纬度算弧面距离),过滤出满足半径的成员
5. 按距离排序返回

第 3 步是 O(logN + M)------跳表索引定位 + 顺序扫描,全部在内存中完成,没有磁盘 IO。

第 4 步只在候选集上做精确距离计算------GeoHash 前缀匹配已经把大部分无关数据排除掉了,候选集通常很小。

综合下来,5km 半径检索几百个商户 → 几毫秒。

7.5 边界效应:为什么不能只搜一个格子

GeoHash 有一个著名的坑------前缀相同 ≠ 距离接近(存在边界效应)

两个位置可能非常接近(比如相距 10 米),但刚好在 GeoHash 格子边界的两侧------它们的 GeoHash 前缀不同:

复制代码
        格子A             格子B
    ┌──────────┐    ┌──────────┐
    │  商户1   │    │  商户2   │
    │    ●─────┼────┼──●       │  ← 两个商户只隔了 10 米
    │          │    │          │     但分属不同 GeoHash 格子
    └──────────┘    └──────────┘

解决方案:搜索时不仅搜当前格子,还搜周围 8 个格子 (3×3 的 GeoHash 邻域)。这就是 GEOSEARCH 内部帮你做的事------Spring Data Redis 的 GeoSearchCommandArgs 默认会扩展邻域格子。
✅ GEO 方案防住了:MySQL 空间查询的高延迟 → Redis 内存检索毫秒级返回;GeoHash 前缀匹配快速缩小候选集 → O(logN+M) 时间复杂度
❌ 但防不住:GEO 数据需要预热加载------重启后 `@PostConstruct` 全量加载,商户量大(百万级)时启动慢;商户坐标变更需要同步更新 Redis,没有自动同步机制(需配合 Outbox + 消息队列做 GEO 更新)


八、总结

Feed 流设计决策树

复制代码
                        你的场景是?
                       /           \
              读多写少              写多读少
              /                       \
       粉丝量多大?                 拉模式
       /         \
  粉丝 < 1万    粉丝 ≥ 1万
     /               \
  推模式           推拉结合
 ZSET收件箱      普通用户→推
 时间游标分页     大V→拉
 容量上限1000     Timeline温缓存

核心金句

推模式和拉模式的本质不是"哪个更好",而是"你把复杂度放在写的时刻,还是读的时刻"------选哪个,取决于你的用户是读得多还是写得多,你的粉丝量是百级还是亿级。

一条动态的完整旅程

复制代码
用户发动态
  │
  ├─→ [ 判断用户类型 ]
  │     ├─ 普通用户 → 推模式
  │     │   ├─→ 博文存 MySQL(持久化)
  │     │   └─→ 动态ID ZADD 进所有粉丝的 ZSET 收件箱
  │     │        └─→ 收件箱 > 1000 条?→ ZREMRANGEBYRANK 淘汰旧数据
  │     │
  │     └─ 大 V → 拉模式
  │          └─→ 博文存 MySQL + 动态ID ZADD 进自己的发件箱
  │
  ▼
粉丝刷 Timeline
  │
  ├─→ [ 查自己的 ZSET 收件箱 ](推内容,O(logN+M))
  ├─→ [ 并发查关注大 V 的发件箱 ](拉内容,超时降级)
  ├─→ [ 按 score 降序合并 ]
  ├─→ [ 时间游标分页返回 ](对增量数据免疫)
  │
  ▼
附近商户检索
  │
  ├─→ 用户坐标 → GeoHash 编码
  ├─→ GEOSEARCH 搜索中心邻域格子
  ├─→ ZSET score 范围查询(候选集,O(logN+M))
  ├─→ Haversine 精确过滤(排除边界效应的误召回)
  └─→ 按距离升序返回(毫秒级)

📖 附录:术语速查表

正文中遇到看不懂的名词,回这里查。每个术语的详细原理在对应章节里都有展开。

术语 一句话解释 类比帮助理解
Feed 流 朋友圈/微博那种"持续刷新看动态"的信息流 像刷抖音------不断下滑,看新内容
Timeline 个人主页的时间线,即"按时间倒序排列的动态列表" 你的朋友圈页面
推模式(Push) 用户发动态时,把动态主动塞进每个粉丝的收件箱 快递员把包裹送到每家每户门口
拉模式(Pull) 用户发动态时只存自己那,粉丝自己来取 快递放在驿站,收件人自己去拿
写扩散 发 1 条动态,要往 N 个粉丝那里各写 1 次 复印 N 份传单,挨家挨户发
读扩散 刷 1 次 Timeline,要从 M 个关注者那里各读 1 次再合并 想看最新消息,得跑 M 个朋友家各问一遍
ZSET Redis 的"有序集合"------每个元素带一个分数(score),自动按分数排序 班级成绩排名表------学号是 member,总分是 score,按分排序
score ZSET 中每个元素关联的分值,用来排序 成绩排名里的"总分"
member ZSET 中的元素本身(这里是动态 ID) 成绩排名里的"学号"
offset 分页 传统的"跳过前 N 条,取后 M 条"分页方式 翻书:翻过前 10 页,看第 11 页
游标分页(Cursor) 用上一次最后一条的 score 作为"锚点",取它之前的 N 条 书签:每次从上次读到的那行继续往下读
大 V 粉丝量极大的用户(百万/千万级) 微博上的明星、大网红
冷热分离 活跃用户的数据放快速存储(Redis),不活跃的用慢速方案(MySQL) 常用的东西放桌上,不常用的收柜子里
GeoHash 把经纬度(二维坐标)编码成一串字母数字(一维字符串)的算法 把地图切成一个个小格子,每个格子编个号
Haversine 公式 计算地球球面上两点之间真实距离的数学公式 地球是圆的,不是平的------不能用勾股定理算两点距离
TTL Time To Live,数据在缓存中存活的时间,到期自动删除 牛奶的保质期------过期就扔
跳表(Skip List) ZSET 底层的数据结构,一种多层链表,查找效率 O(logN) 电梯------每层都停是普通链表,跳表是"快速电梯"只停关键楼层
B+ 树 MySQL InnoDB 索引的数据结构,数据存在叶子节点,适合磁盘读写 一本字典的目录------先查目录(索引),再翻到正文页(数据)
O(logN) 时间复杂度表示法------数据量翻倍,查询步数只多一步 二分查找:100 万条数据,最多比 20 次就能找到
O(1) 不管数据量多大,查询步数始终是常数 直接翻到书的第 50 页------不管书有 100 页还是 1000 页
Redis Cluster Redis 集群------把数据分片存到多台机器上,突破单机内存限制 一个仓库不够放 → 开分仓,按规则决定东西放哪个仓
SPATIAL INDEX MySQL 的空间索引,用于加速地理位置查询 在地图上给每个区域建个快速索引,不用全地图扫描
OLTP 在线事务处理------用户实时操作型的业务(如下单、发动态) 收银台结账------一个接一个实时处理
@PostConstruct Spring 注解------在 Bean 初始化完成后自动执行的方法 游戏加载完后的"自动存档"

*我是程序员小鱼,持续输出 Java 后端面试系列,关注不迷路。🐟*

相关推荐
一个做软件开发的牛马1 小时前
Spring Boot Web 开发实战:RESTful API 设计、统一异常处理、参数校验与拦截器
java·后端
江畔柳前堤1 小时前
github实战指南00-命令在哪里执行?
人工智能·线性代数·oracle·数据挖掘·github·word
小bo波1 小时前
Java反射机制——运行时"透视"类的秘密
java·jvm·反射·源码分析·动态代理·进阶·spring底层·框架原理
IT 行者1 小时前
GitHub Spec Kit 实战(三):写一份能管住所有 spec 的 /speckit.constitution
java·github·ai编程·claude
java1234_小锋1 小时前
Spring Boot 的核心注解 @SpringBootApplication 由哪三个注解组成?
java·spring boot·后端
::呵呵哒::1 小时前
在macOS/Linux上优雅管理多个JDK版本:环境变量与别名配置指南
java·linux·macos
IT 行者1 小时前
GitHub Spec Kit 实战(二):写一份不偏的 /speckit.specify
java·github·ai编程·claude
就改了1 小时前
ElasticsearchRestTemplate使用方法详解!!!
java·elasticsearch·springboot
独隅1 小时前
IntelliJ IDEA 在 Linux 上的完整安装与使用指南
java·linux·intellij-idea