搜索与热搜:Meilisearch 索引同步、热榜任务与前端聚合搜索

悦目图库

用户敲下"星空",回车。一秒钟后,页面同时展示出图片、帖子、空间、用户四类结果,旁边的"热搜榜"上也躺着"星空"这个词——它昨天还没上榜。这背后其实是三套独立又咬合的系统:搜索(把用户的话变成结果)、索引同步(让搜索能搜到站里的新内容)、热榜(把大家搜过什么变成榜单)。

这篇把这三件事全拆开。主角是 Meilisearch——一个比 ES 轻得多、却在这个项目里扛起搜索和热榜双份活的搜索引擎。你会发现,真正的难点不在"搜索"本身,而在搜索之外:怎么让索引跟上数据库、怎么把"谁在搜什么"变成一份靠谱的热榜。

为什么是 Meilisearch,而不是 ES

搜索框架的默认答案通常是 Elasticsearch,但这个项目选了 Meilisearch。为什么?先看它的连接配置:

@Bean
public Client meiliSearchClient() {
    try {
        Config config = new Config(host, apiKey);
        Client client = new Client(config);
        // 启动时验证连接,顺带打出版本号
        String version = client.getVersion();
        log.info("Successfully connected to Meilisearch server. Version: {}", version);
        return client;
    } catch (Exception e) {
        log.error("Failed to initialize Meilisearch client", e);
        // 失败也返回一个 client,防止 Spring Boot 启动失败
        return new Client(new Config(host, apiKey));
    }
}

两个细节值得说。第一,启动时 getVersion() 验证连接——Meilisearch 挂了,启动日志立刻能看到,不用等第一个搜索请求炸掉。第二,catch 里照样返回一个 client 而不是抛异常——注释写得很明白:"防止 Spring Boot 上下文启动失败"。一个搜索组件挂掉不该拖垮整个站点,这是"非核心组件故障隔离"的典型思路。

选型理由,落到这个场景就是三点:。Meilisearch 单机几百 MB 内存就能跑,ES 光 JVM 堆就要几个 G,对一个图片社区来说,省下的内存能多跑好几个服务。。它的近实时索引和前缀搜索对"打字即搜"的体验是天然友好的。够用。图片站要的是标题、描述、标签的全文搜索,不需要 ES 那套复杂的分析器生态。

当然它不是银弹。ES 的分布式、分片、复杂查询,Meilisearch 都没有。但一个中小站点用不上那些——选型不是选最强的,是选够用且省心最久的。这个判断,和上一篇选预渲染而不是 SSR,是同一个道理。

再补一个实际的对比维度:部署和维护成本。ES 是 Java 写的,光启动就要几个 G 内存,还要操心分片、副本、集群发现这些分布式的事;Meilisearch 是 Rust 写的,一个二进制文件,一条命令就能跑,索引在磁盘上按需加载。对一个运维精力有限的独立站点来说,这省下来的不是一点半点——省下来的内存和心智,可以花在真正的问题上。

还有一个体验细节:Meilisearch 自带容错(typo tolerance),用户搜"星岸"也能匹配到"星空",因为它的默认容错引擎允许相邻字符的轻微错误。这对中文搜索尤其宝贵——拼音输入法的用户经常打错字,容错直接把"打错了搜不到"这个最伤体验的问题解决了大半。这些"开箱即用"的能力,正是轻量搜索引擎对独立站最大的价值。

六个索引,一张网

Meilisearch 的索引不是自动建的,要在启动时显式配置。initIndices 一口气建了六个索引,每个都明确指定了"哪些字段可搜索、哪些可过滤、哪些可排序":

private void initIndices() {
    initIndex(INDEX_NAME_PICTURE, new String[]{"name", "introduction", "category", "tags"},
            new String[]{"userId", "spaceId", "category", "tags", "reviewStatus", "isDelete", "isDraft"},
            new String[]{"createTime", "updateTime", "viewCount", "shareCount", "likeCount", "hotScore"});

    initIndex(INDEX_NAME_USER, new String[]{"userName", "userAccount", "userProfile"},
            new String[]{"userRole", "isDelete"},
            new String[]{"createTime", "updateTime"});

    initIndex(INDEX_NAME_POST, new String[]{"title", "content", "category", "tags"},
            new String[]{"userId", "category", "tags", "status", "isDelete"},
            new String[]{"createTime", "updateTime", "hotScore"});

    initIndex(INDEX_NAME_SPACE, new String[]{"spaceName", "spaceDesc"},
            new String[]{"userId", "spaceType", "spaceLevel", "isDelete"},
            new String[]{"createTime", "updateTime"});

    initIndex("search_keyword", new String[]{"keyword"},
            new String[]{"type", "keyword"},
            new String[]{"count", "updateTime"});
}

三组字段名各有讲究。searchable(可搜索)决定用户搜什么能命中——图片搜名称/简介/分类/标签,帖子搜标题/内容/分类/标签。filterable(可过滤)决定查询时能用哪些条件收窄——比如"只看某个用户的图片""只看已审核通过的",这些都要声明才能过滤,是 Meilisearch 的安全设计。sortable(可排序)决定结果按什么排——注意图片索引里有个 hotScore,这是后面热榜/推荐打分的伏笔,搜索结果可以按热度排序。

还有两个隐藏索引:search_keyword(关键词的计数索引,热榜的源头)和 rag_memory(RAG 记忆,另一个功能)。一个搜索功能,背后的索引网络比你想的广。

再细看 filterable 的设计,它其实是在给搜索"加护栏"。Meilisearch 默认不允许按未声明的字段过滤——想按 reviewStatus(审核状态)过滤,必须先声明它是 filterable。这个约束看似麻烦,实则是防止了"查询时传任意字段"的安全和性能隐患:字段白名单定死了,恶意请求传再多过滤条件,索引也只认声明过的几个。

sortable 里藏着运营的巧思:图片索引的 hotScore 和帖子的 hotScore 都在可排序列表里。这意味着搜索结果不只是"相关度排序",还能切成"按热度排序"——运营想让什么内容更容易被发现,调一下 hotScore 就行。搜索的排序字段,其实是运营的一个暗门,而这个门要提前在索引配置里留好。

启动时:索引为空,就全量灌一遍

全量与增量同步机制

索引配好了,但里面没有数据,搜索等于空转。所以启动时要做一次"初始化判断":

public void run(String... args) {
    // 只初始化索引配置,不删除已有索引
    initIndices();

    // 检查是否需要全量同步:索引内无文档 = 首次部署
    boolean needFullSync = false;
    String[] indices = {INDEX_NAME_PICTURE, INDEX_NAME_USER, INDEX_NAME_POST, INDEX_NAME_SPACE};
    for (String indexName : indices) {
        if (getMeiliSearchDocumentCount(indexName) == 0) {
            needFullSync = true;
            break;
        }
    }
    if (needFullSync) {
        fullSync();
    }
}

判断标准很朴素:索引里有没有文档。没有,说明是第一次部署或者数据被清了,就全量同步;有,说明之前已经同步过,跳过。这个判断避免了每次重启都全量灌一遍数据库——那对几百万张图片来说是灾难。

全量同步本身也有讲究:分页拉取 + 并发写入,还按类型开关控制:

public void fullSync() {
    if (pictureSyncEnabled) fullSyncPictures();
    if (userSyncEnabled) fullSyncUsers();
    if (postSyncEnabled) fullSyncPosts();
    if (spaceSyncEnabled) fullSyncSpaces();
}

全量同步图片时,先查 MySQL 里符合条件的总数(未删除、非草稿、公共空间),再按批次(batchSize=500)分页、并发(4 线程线程池)写入 Meilisearch。批次之间还有 BATCH_SLEEP_TIME(100ms)的间隔——不是为了让数据库喘口气,是为了不把 Meilisearch 打爆。全量同步是"一次性的大动作",最怕的是把刚起来的服务又压垮。

并发数(4 线程)和批次大小(500)也都是调出来的。太激进,Meilisearch 的写入会超时重试,反而更慢;太保守,几百万条数据要同步几小时。500 条一批、4 个线程并发、批间 100ms 喘息,是这台机器上测出来的甜点区。这些数字没有普适性,但它背后的思路是通用的:全量同步要压测,不能拍脑袋——先跑一小批测出稳定速度,再放全量,比直接压满省心得多。

还有个容易被忽略的点:全量同步的每个类型都可以用配置开关单独控制(pictureSyncEnabled 等)。这意味着某个索引出了问题,可以单独关掉它的全量,不影响其他索引。细粒度的开关,是故障隔离的第一道防线——别把所有同步绑死在一个开关上。

每五分钟:增量同步的错峰艺术

全量同步只在首次部署跑。日常的新增、修改、删除,靠的是增量同步——四个定时任务,每 5 分钟跑一次:

@Scheduled(fixedDelay = INCREMENTAL_SYNC_DELAY_MILLIS) // 每5分钟,初始延迟20秒 + 随机0-30秒
public void incrementalSyncPictures() {
    executeIncrementalSync(this::incrementalSyncPicturesInternal, "picture");
}

@Scheduled(fixedDelay = INCREMENTAL_SYNC_DELAY_MILLIS, initialDelay = 20000)
public void incrementalSyncUsers() {
    executeIncrementalSync(this::incrementalSyncUsersInternal, "user");
}

@Scheduled(fixedDelay = INCREMENTAL_SYNC_DELAY_MILLIS, initialDelay = 40000)
public void incrementalSyncPosts() {
    executeIncrementalSync(this::incrementalSyncPostsInternal, "post");
}

@Scheduled(fixedDelay = INCREMENTAL_SYNC_DELAY_MILLIS, initialDelay = 40000)
public void incrementalSyncSpaces() {
    executeIncrementalSync(this::incrementalSyncSpacesInternal, "space");
}

注意 initialDelay 是错开的:图片 20 秒、用户 40 秒、帖子 40 秒、空间 40 秒。为什么错开?因为四个任务如果同时启动、同时去查数据库和写 Meilisearch,刚上线的瞬间就是一波并发尖峰。错开初始延迟,让同步任务"排队上岗",是运维里最朴素的削峰手段。注释里还提到"随机延迟 0-30 秒"——进一步打散,避免多实例部署时所有实例的同步任务撞在同一秒。

增量同步内部查的是"更新时间在某个时间窗之后"的记录,把这段时间内新增或修改的数据同步进索引。这个时间窗配合"5 分钟一次"的频率,保证新内容最多 5 分钟就能被搜到——对一个"用户刚上传就想被搜到"的图片社区来说,这个延迟是可接受的。

多实例部署时,错峰还有个额外的作用:假设有 3 个实例,如果它们完全同时执行同步任务,数据库会同时收到 3 份同样的查询和写入——不是数据错乱(幂等),但白白增加三倍压力。incrementalRandomDelayMax(随机延迟 0-30 秒)就是为了让每个实例的同步错开,避免"三兄弟撞一起"。

另外注意 executeIncrementalSync 是包了一层统一调度的:内部有失败计数、重试、跳过逻辑。增量同步的目标是"每次只处理增量",所以它天然是幂等的——同一个窗口的数据同步两遍也不会出错,只是浪费一点。这种"幂等 + 错峰 + 失败重试"的组合,让定时同步在没人盯的情况下也能长期稳定运行。

每天一次:一致性检查和告警

增量同步是"乐观"的——它假设大部分时间一切正常。但总有意外:删了没同步、改了一半、任务失败了。所以每天还有一次一致性检查

public void dailyConsistencyCheck() {
    // 清理各类已删除/无效数据
    cleanDeletedPictures();
    cleanDraftPictures();
    cleanDeletedUsers();
    cleanDeletedSpaces();

    // 检查并修复数据一致性
    checkAndFixDataConsistency();
}

它做两件事:清理——把 MySQL 里已经删掉或变成草稿、但索引里还残留的记录删掉;修复——如果 MySQL 和 Meilisearch 的文档数差异超过阈值(CONSISTENCY_CHECK_THRESHOLD = 10),就触发对应类型的全量同步。

设计上有个值得点出的取舍:增量同步追求"及时",一致性检查追求"正确"。增量负责把新数据快速推进去,一致性检查负责把漏网的、错位的纠正回来。两者一个负责效率、一个负责兜底,配合使用才是一个健康的索引体系。只看增量不看一致性,索引会越跑越脏。

更关键的是告警机制。整个任务里到处都是 sendAlertEmail(...)——同步失败、一致性异常、告警超过阈值,都会发邮件给管理员。同步失败还会自动重试(retryTimes 次、retryInterval 间隔、MAX_RETRY_WAIT_TIME 30 秒封顶)。这套"失败重试 + 邮件告警 + 阈值熔断"的组合,让一个定时任务在没人盯的情况下也能自我恢复、自我上报。无人值守的系统,靠的不是人记得看,是系统自己会喊

重试的细节也值得展开:retryTimes 控制重试次数,retryInterval 控制每次间隔,还有 MAX_RETRY_WAIT_TIME 30 秒的上限——重试不能无限等下去,到 30 秒就放弃,宁可失败也不阻塞任务队列。重试的对象是"单次查询或写入",不是整个任务——任务本身靠定时调度兜底,下次还会跑。

告警也有熔断:ALERT_FAILURE_THRESHOLD = 3——连续 3 次告警发送失败,就停止告警,防止邮件服务本身挂了的时候告警堆积成垃圾邮件。这些细节看着琐碎,但合起来就是一个"会自我恢复、自我上报、自我熔断"的定时任务。运维的成熟度,很多时候体现在这些'系统自己会喊'的细节里,而不是体现在监控大屏有多炫。

聚合搜索:一个接口,四类结果

聚合搜索页面展示

聚合搜索与多端索引

搜索接口是 POST /search/all。它的核心不是"搜"——搜是 Meilisearch 干的——而是聚合:一个关键词,同时在图片、用户、帖子、空间四个索引里搜,再把结果合并成一个页面返回。

@PostMapping("/all")
@BucketRateLimit(key = "search", dimension = RateLimitDimension.IP, permitsPerMinute = 80, permitsPerHour = 800, burst = true, message = "搜索过于频繁,请稍后再试")
public BaseResponse<Page<?>> searchAll(@RequestBody SearchRequest searchRequest, HttpServletRequest request) {
    // 敏感词过滤
    if (searchRequest != null && StringUtils.isNotBlank(searchRequest.getSearchText())) {
        String originalText = searchRequest.getSearchText();
        // ... 对搜索词做敏感词过滤,替换成 *
    }
    return ResultUtils.success(searchService.doSearch(searchRequest));
}

聚合搜索处理流程图

两个细节。第一,限流按 IP——搜索是公开接口,不登录也能搜,所以按来源 IP 限流(每 IP 每分钟 80 次),防止爬虫用搜索接口刷爆服务。第二,搜索词要先过敏感词过滤——用户搜什么会进热榜,如果不过滤,敏感词也能刷上热搜。这个"搜什么之前先过滤"的顺序,是内容合规在搜索层的第一道闸。

doSearch 内部:解析搜索类型(全部/图片/用户/帖子/空间),按类型分别调 Meilisearch 对应索引,再把结果封装成分页返回。这个"一个接口聚合多索引"的模式,是聚合搜索的标准做法——对前端来说一个调用、一个渲染,对后端来说各索引独立查询、合并结果。

还有个查询前的细节:搜索词会被规范化——去掉首尾空格、统一处理特殊字符。因为 Meilisearch 的匹配是精确到 token 的,用户搜" 星空 "(带空格)和"星空"应该是一样的结果,但如果不规范化,索引里的 token 就匹配不上。SearchRequest 里还有 type 字段——用户可以选择只搜图片、只搜帖子,或者全类型搜。全类型搜是"聚合",单类型搜是"分诊",同一个接口两种用法。

限流维度从 a19 的按用户换成了按 IP,也是有讲究的:搜索是游客也能用的公开功能,按用户限流会漏掉未登录的爬虫。按 IP 限流虽然可能误伤共享 IP 的合法用户,但配合 burst = true(允许突发),正常用户基本无感。限流维度要和接口的开放程度匹配,这是选维度时的第一原则。

再说说多索引合并的边界:聚合搜索的每个类型结果是独立分页的——图片返回一页、帖子返回一页,前端用 tab 切换。为什么不合并成一个统一的列表?因为不同类型的结果结构差异太大(图片有缩略图、帖子有正文摘要、用户有头像),硬塞进一个列表,要么牺牲展示效果,要么写一堆分支判断。用 tab 各展示各的,类型清晰、渲染简单,代价是用户一次只能看一类。聚合搜索的"聚合"是指一次请求拿全,不是把结果混成一个,这个边界要想清楚。

搜索一次,记三笔账

搜索一次,记三笔账

搜索不只是返回结果,每一次搜索都是一条"用户想找什么"的信号,得记下来。recordSearchKeyword 同时记三笔:

// 1. 实时热榜:关键词在 Redis ZSet 里计数 +1(只对已存在的关键词)
stringRedisTemplate.opsForZSet().incrementScore(zsetKey, searchText, 1d);

// 2. 如果关键词在 Meilisearch 里不存在(新词),进"待创建"集合
stringRedisTemplate.boundSetOps("es_keyword_new:" + type).add(searchText);

// 3. 无论新旧,都进"待增量"哈希,攒着批量写回 Meilisearch
stringRedisTemplate.boundHashOps("es_keyword_increment:" + type)
        .increment(searchText, 1);

这三笔账的用途完全不同:ZSet 是热榜的实时层——每个关键词一个分数(搜索次数),随时能取 Top N;"待增量"哈希是给 Meilisearch 的——搜索次数不直接写 Meilisearch(那是低频写引擎),而是先攒在 Redis,由批处理任务定期合并写回;"待创建"集合专门记那些 Meilisearch 里还不存在的词——新词第一次出现,要先建记录再计次。

这里藏着一个很聪明的设计:热榜的实时计数走 Redis,不直接走 Meilisearch。因为热榜要"秒级看到变化",而 Redis ZSet 的 incrementScore 是 O(log n) 的高频操作;Meilisearch 的文档更新是低频写,不适合每次搜索都写一次。两条路分开:高频实时计数进 Redis,低频归档写进 Meilisearch 和 MySQL。

另外还有一笔"用户个人账"——recordUserSearchKeyword 把每个用户搜过什么记进 UserSearchRecord 表,这就是搜索历史功能的数据源。

这个"先 Redis 高频、再归档低频"的两条路设计,其实是整个热榜架构的缩影——后面热榜那几节全是这个思路的放大版。记三笔账的代码看似简单,但它确定了整个热榜数据流的起点:搜索动作 → Redis 实时计数 → 批处理写回 Meilisearch/MySQL。数据流从哪进、在哪存、怎么归档,都在这三行里定死了。

还有一个一致性细节:recordSearchKeyword 是"尽力而为"的——它包在 try-catch 里,失败只 log.warn,不影响搜索主流程。为什么?因为"记一笔搜索次数"丢了,损失只是热榜少计一次,不值得为此让搜索请求失败。非核心动作失败要静默降级,不能拖垮核心流程——这个原则贯穿整个搜索链路,后面热榜的三层降级也是同一个思路。

热榜的实时层:Redis ZSet 滚动的次数

热榜的"实时层"是 Redis ZSet:hot:search:realTime:{type},value 是关键词,score 是搜索次数,每搜一次 incrementScore 加一。取榜就是 reverseRangeWithScores 按分数倒序取前 N 个。

为什么用 ZSet?因为它天生就是"排行榜"数据结构——按 score 排序、支持范围查询、支持增量更新,全是排行榜的刚需。换成 Redis List 或者 Hash,要么排序要自己写,要么增量要自己管。选对数据结构,排行榜就完成了一半——这是 Redis 使用里最值钱的一句经验。

实时层的定位是"秒级可见":用户搜完,热榜应该立刻能看到这个动作的累积。Redis ZSet 的 incrementScore 是 O(log n),几十万关键词的榜单也能承受高频写入。而且 ZSet 天然支持"只取前 N 个"——reverseRangeWithScores(key, 0, N) 一次调用就是排行榜,不需要额外排序。这就是为什么热榜的实时层选 ZSet 而不是别的数据结构:不是因为它流行,是因为它把"计数 + 排序 + 截取"三件事全包了。

这里还有个容易被忽略的点:ZSet 的 score 是浮点数,存搜索次数这种整数绰绰有余,还顺便支持了趋势计算里"乘以时间衰减系数"这类小数运算——如果是整数型的计数器,衰减就只能四舍五入,精度就丢了。一个数据结构的类型选对,能让后面的计算省掉一堆类型转换的麻烦,这也是"选对数据结构"的又一层含义。

不过实时 ZSet 有个问题:它只记"累计次数",不区分"今天搜的多"和"昨天搜的多"。如果一个词昨天很火、今天没人搜了,它在 ZSet 里的分数还在,会一直占着榜。所以热榜不能只看实时计数,还要算"热度"和"趋势"——这就是下一层要干的活。

热榜的计算层:5 分钟把实时变正式

实时计数是"过程",热榜的"正式榜单"要靠计算层定期生成。syncHotSearch 每 5 分钟跑一次,为每个类型拿分布式锁,然后算:

@Scheduled(fixedRate = 5 * 60 * 1000) // 每5分钟,因为趋势计算依赖1小时前的数据
public void syncHotSearch() {
    for (String type : SEARCH_TYPES) {
        RLock lock = redissonClient.getFairLock(String.format(HOT_SEARCH_SYNC_LOCK_KEY, type));
        if (lock.tryLock(5, calculateDynamicLockTimeout("hot_search_sync_" + type), TimeUnit.SECONDS)) {
            syncHotSearchForType(type);
        }
    }
}

两个细节。第一,Redisson 公平锁——多实例部署时,同一类型的热榜只能有一个实例在算,避免重复写入。用公平锁而不是普通锁,是防止某个类型一直抢不到锁导致任务"饿死"。第二,动态锁超时——calculateDynamicLockTimeout 根据历史执行时长动态算锁超时,防止锁被一个卡住的任务占死。这些"分布式任务"的细节,单实例时永远用不上,但一上多实例就是硬需求。

syncHotSearchForType 是核心,它读实时 ZSet,算趋势,写 MySQL,更新 Redis 缓存:

// 1. 读实时 ZSet(限制范围,减少内存占用)
var tuples = stringRedisTemplate.opsForZSet()
        .reverseRangeWithScores(zsetKey, 0, TREND_CALCULATION_LIMIT);

// 2. 对关键词做长度校验和截断(数据库字段长度限制)
// 3. 仅对前 N 条计算趋势(优化性能)
calculateTrendsForTopItems(hotSearchList, type);

// 4. 批量插入或更新到 MySQL
hotSearchMapper.batchInsertOrUpdate(hotSearchList);

// 5. 更新 Redis 缓存(给 /hot 接口读)
updateCache(type, hotSearchList);

代码里有一句注释很关键:"趋势计算依赖 1 小时前的数据"。热榜不是光看"谁搜得多",还要看"谁在变热"——一个词 5 分钟前没人搜、现在突然火起来,它的"趋势"应该把它顶上榜。calculateTrendsForTopItems 大概就是拿当前计数和一段时间前的计数对比,算出上升幅度。这个"热度 = 次数 + 趋势"的模型,让热榜既有厚度(搜得多)又有活力(在变热),而不是一份死气沉沉的计数表。

顺带一提,关键词写进 MySQL 前有个不起眼但很重要的细节:长度校验和截断。数据库 keyword 字段最长 128 或 512 个字符,用户要是搜一个超长字符串(或者恶意构造),不截断就直接报数据库错误。防御性地截断,是这种"外部输入要进存储"场景的标配。

趋势计算 calculateTrendsForTopItems 还有一层性能考虑——注释里写了"仅对前 N 条计算趋势"。为什么只算前 N 条?因为趋势是个相对概念,只有榜单顶部的词才需要关心"它是不是在变热",排在一千名开外的词算了也没人看。为了一个没人看的词去查一小时前的数据,是浪费。优化不是把所有计算都做对,是把不必要的计算都砍掉,这在排行榜这种"只有头部有意义"的场景里尤其明显。

动态锁超时 calculateDynamicLockTimeout 也值得多说一句:锁超时如果写死,一个任务卡住会把锁占死,其他实例干等。动态计算的意思是"根据最近几次执行的平均时长,给一个略宽松的锁超时"——既能让正常任务跑完,又能在任务卡死时及时释放锁。这个"自适应超时"的套路,分布式系统里很值得借鉴。

趋势的具体算法虽然没贴全,但思路可以讲透:它比较"当前计数"和"一小时前的计数",算出一个增长率。为什么用一小时而不是更短?因为太短的窗口受随机波动影响大——一个词被一个帖子引了一波流量,5 分钟内暴增,不代表它是真正热起来的内容;一小时的窗口能过滤掉大部分短期噪声,留下"持续在变热"的信号。这就是注释里"趋势计算依赖 1 小时前的数据"的来历。热榜的窗口粒度,决定了它是追热点还是追噪声,这个参数是整个热榜味道的关键。

热榜的兜底层:三层降级

热榜的三层降级

热榜最怕的是"没数据"——榜单空了比榜单错更难看。所以 syncHotSearchForType 做了三层降级:

第一层:Redis 实时 ZSet(最高优先级,秒级数据)
  ↓ 没有
第二层:Meilisearch 的 search_keyword 索引(近实时的归档计数)
  ↓ 没有
第三层:MySQL 里的默认热词(兜底,保证榜单永不空)

warmUpCache 也是这套降级的预热版:缓存不存在时,先从 MySQL 预加载排名前 1000 的搜索词到 Redis(Hash 结构,24 小时过期),让 /hot 接口有现成的缓存可读,不用每次现算。

这套"实时 → 归档 → 兜底"的降级链,体现了热榜功能的定位:它是个锦上添花的展示,不能因为它的失败影响主功能,更不能让它自己变成"空白事故"。榜单数据来源可以降级,但"永远有榜单"这件事不能妥协。

warmUpCache 的"只在不存在时预加载"也有讲究:preloadMysqlTopKeywordsIfNeededhasKey 检查缓存是否存在,存在就跳过。为什么?因为缓存一过期就重建,但如果缓存还在(说明数据还新鲜),重建就是白费。这个"先查后建"的幂等逻辑,避免了每次预热都重复加载。预加载的 Top 1000 关键词存进 Redis Hash(keyword → count),24 小时过期——足够 /hot 接口在高峰期直接读缓存,不用回源查 MySQL 或 Meilisearch。缓存的 TTL 也是权衡:太短频繁重建,太长数据过期,24 小时对这个场景是个合理的中间值。

前端 /hot 接口还带校验:type 必须是 picture/user/post/space 之一,size 必须在 1-100 之间,按 IP 限流——对外接口的每一层防御都做足。

前端:搜索页和热榜页

前端这边,SearchPage.vue 是聚合搜索的入口:输入框、搜索按钮、结果列表(图片/帖子/空间/用户四个 tab),旁边是热搜榜。RankingPage.vue 是更完整的榜单页。

搜索页调用一个接口:

import { searchAllUsingPost, getHotSearchKeywordsUsingGet } from '@/api/searchController'

const res = await searchAllUsingPost({ searchText, type, current, pageSize })

热搜榜的数据从 /search/hot 接口来,点击热搜词会触发 searchByTag——把热榜词当成新的搜索词去搜。这个"热搜词可点击"的交互,把热榜和搜索闭环了:用户看热榜 → 点热词 → 搜出结果 → 这个词又计了一次数 → 更热。热榜因此有了"自我强化"的特性,也让真正受欢迎的内容越来越容易被发现。

RankingPage.vue 是榜单的"正式展厅",分维度展示(作品榜/作者榜),数据来自作者排行、帖子排行等接口。搜索热榜和作品榜单是两套系统——一个是"大家在搜什么",一个是"站里什么最受欢迎",各有各的数据源和计算逻辑。

搜索页还有个交互细节:输入框通常带防抖——用户停下来几百毫秒才真正发起搜索,而不是每敲一个字符都请求一次。这个防抖在搜索场景是必须的:没有它,一次打字能触发十几次搜索请求,既浪费带宽又把限流额度烧光。防抖的时长也有讲究——太短拦不住快速输入,太长显得响应迟钝,300 毫秒左右是个常用值。

热搜词的可点击(searchByTag)让热榜成了搜索的"入口列表"。体验上的闭环同样关键:热榜词点击后,前端会把词填进搜索框再触发搜索——用户看到"星空"上榜,点一下,就看到搜索结果。这个闭环让热榜不只是一个"看看"的榜单,而是一个"点就能搜"的引导。

踩坑记录

这条搜索和热榜链路的坑,我按"索引、数据、合规"三块归了类——分组看,每块的坑根源其实很集中。

索引这块的坑。 最初没给索引配 searchableAttributes,Meilisearch 搜索所有字段,又慢又不准——initIndices 明确声明"搜哪些、过滤哪些、排序哪些"后,速度和质量一起回来。搜索引擎的索引配置,是搜索体验的隐藏开关。还有同步的几跤:四个类型的同步任务同时启动,数据库和 Meilisearch 同时扛四波查询写入——initialDelay 错开才平;全量同步跑完,第二天新增的图片搜不到——因为增量只处理"窗口内更新"的记录,批量改老标题不改 updateTime 就漏了;每日一致性检查最初只查删除不查修改——加上数量差异检测才算真"一致";首次部署重启索引为空触发全量,几百万图片瞬间灌入压垮刚起的服务——后来加批次限制、并发控制、启动后再跑。同步的坑,全是"节拍"的坑:错峰、限速、覆盖所有变化、别和启动抢资源。

数据这块的坑。 热榜被超长字符串撑爆——用户搜个几百字符的串,写 MySQL 直接报"字段太长",加上长度校验和截断才解决;分布式锁最初没加,单实例没事、一上多实例两个实例同时算同一类型热榜重复写入——Redisson 公平锁 + 动态超时解决。热榜词搜一次记两次也在这组:搜索接口记一次、热榜点击又触发一次搜索再记一次——后来确认"请求到达后端"是唯一计次点。凡是外部输入要落库的长度校验是底线,计数点要唯一

合规这块的坑。 搜索接口忘了敏感词过滤,一个敏感词反复搜也能冲上热榜——在搜索词进记录之前加过滤解决;过滤最初只放在"展示结果"那一步,等于"堵漏"——挪到"搜索词进系统"的第一步,还没记热榜计数、还没进历史就被替换成星号,才从"堵漏"变成"截源"。内容合规的闸门要放在数据进入系统的第一道口,越靠前成本越低

收尾:搜索之外,才是功夫

把这条链路收起来,你会发现:"搜索"本身只占一小半功夫,真正的工程在搜索之外——索引怎么跟数据库保持同步、热榜怎么从"谁搜得多"提炼出"什么在变热"、数据来源怎么在多层之间降级不崩溃。这些才是把"能搜"变成"好搜"、"热搜"变成"靠谱热榜"的关键。

三句话总结这篇:

索引要同步,不是一次灌完就完。 全量兜底、增量保鲜、一致性检查纠正,三个动作缺一不可,配合"失败重试 + 邮件告警"才是健康的索引体系。

热榜要算趋势,不是数次数。 实时 ZSet 记次数,计算层算"次数 + 趋势",三层降级保底,让榜单既有厚度又有活力、永不空白。

搜索要过滤,不是裸奔。 搜索词进系统前先过敏感词,热榜词落库前先截断长度,对外接口都带校验和限流——内容合规和健壮性,都得从第一道口就开始。

如果你也要做一个带搜索功能的站,不妨照着这条链路检查:你的索引跟上数据库了吗?你的热榜是在数次数还是在算热度?你的搜索词,在进系统之前过滤了吗?最后说句实在的:这篇拆的搜索系统,没有一项是"别人不会"的高深技术——Meilisearch 谁都会装,Redis ZSet 谁都知道,定时任务更是标配。但这个项目的价值在于,它把这三样东西咬合成了一个完整的闭环:搜索、索引、热榜互相喂数据、互相兜底,任何一个环节的失败都不会让整体崩溃。这种"咬合"不是设计出来的,是踩坑踩出来的——每一层的降级、每一个开关、每一处防御,背后都有一次线上事故。架构的成熟度,是用事故换来的,而这正是比任何框架都值钱的东西。

如果你要给自己站上加搜索,记住这篇的一个核心判断:搜索不是搭个引擎就完事,它是搜索 + 索引 + 热榜三套系统的咬合。引擎谁都能装,真正拉开差距的,是索引怎么保持新鲜、热榜怎么算得靠谱、任何一个环节出问题怎么不拖垮整体。这三个问题想明白,你的搜索就真的"能用"了。

搜索做到这里,"能跑"和"能用"之间的差距其实就三个问题:索引跟得上新内容吗?热榜反映的是真实热度吗?聚合结果让用户找得到东西吗?三个都答对,搜索才算真正立住。