资讯动态

海量聊天消息索引架构:从倒排索引到自研引擎的实践

发布时间:2026/10/16 1:03:07 来源:尧图企业网站定制
先说个场景你在一个大型社区平台的聊天频道里待了大半年某天想翻出曾经发过的一份配置模板可聊天记录往上翻了几百页也没找到。这时你才会真正理解平台背后那套支撑几十亿条消息检索的索引系统到底在扛着什么样的压力。这个标题里的“不和谐”指的就是以服务器和频道为组织形态的大型社区聊天平台。这类平台有个共同特点消息量巨大、用户随时在聊、历史内容永远不能丢。于是“如何索引数十亿条消息”就成了一个非常现实的架构问题不是能不能搜而是怎么在写入不停、查询不能卡、机器又不能无限加的前提下把搜索体验做到可用。这篇文章把我做同类项目时的完整思路、架构拆分、踩坑记录和一些关键参数都整理出来希望对正在做海量消息存储或全文检索的同学有参考价值。1. 问题从哪儿来几十亿条消息的搜索难度到底是什么1.1 为什么说普通数据库扛不住全文检索先纠正一个常见误区很多人觉得“数据库不是有索引吗怎么还要专门做检索引擎”。关系型数据库的索引本质上是B树它对精确匹配、前缀匹配、范围查询非常高效。比如“找用户ID等于10086的所有消息”“找2024年1月1日的所有消息”这类查询用普通索引都能秒回。但用户搜索聊天记录时输入的是“配置模板”、“装车方案”、“周末去哪玩”这种自然语言。这些查询不是精确匹配而是“包含某个词”、“包含某几个词中的任意一个”、“按相关度排序”这样的需求。B树对此几乎没有办法只能退化成全表扫描后做字符串匹配。你可以想象一下一张几十亿行的表每行还有几千字节的正文全表扫描意味着要读几十TB的数据就算全用SSD单次查询也要几分钟甚至更久。更麻烦的是相关性排序。数据库的ORDER BY只能按照时间、ID等结构化字段排序没法对“这条消息和搜索词有多相关”做评分。用户搜“如何部署服务”搜出来的是所有包含“部署”或“服务”的消息按时间倒序排列中间夹杂大量无关内容体验会非常差。所以做聊天搜索第一课就是认清OLTP数据库负责存业务数据检索引擎负责处理“模糊、相关性、大规模文本”这类需求两者分工不同。1.2 做索引之前先算清楚三笔账接手这个项目时我们最先做的不是选型而是把账算清楚。所谓“数十亿条消息”最终落地在系统上要回答三个问题写入多快、查询多快、存得起多少。第一笔账是写入吞吐。社区平台的消息量有明显的峰谷白天平稳晚间和活动期间暴涨。我们当时按日均新增接近1亿条来规划高峰时每秒需要扛住几万条写入。这意味着索引系统不能每来一条消息就立刻刷一次磁盘必须走缓冲批量写。第二笔账是查询延迟。用户对搜索的耐心其实比对普通页面还低超过2秒就会觉得“卡了”。但几十亿条消息的检索如果实现不当一次查询要扫描的量级是惊人的。必须在索引结构、分片裁剪、缓存做多层优化。第三笔账是存储成本。消息原文只算一份但倒排索引是“词到文档”的映射会把文本膨胀1.5到3倍再加上元数据、分词词典、排序字段存储成本比想象中高很多。好消息是海量消息里有相当部分属于“冷数据”访问频率低可以分层存放。这三笔账算完之后整个项目的方向就清楚了需要一个分布式、可水平扩展、支持海量写入、查询延迟可控、还要能降低成本的自研索引方案。2. 方案演进从 SQL LIKE 到自研索引引擎2.1 第一代靠数据库硬查在索引系统还没有独立成项目之前很多平台的第一版搜索都是靠数据库硬查的用户输入关键词后端拼一条WHERE content LIKE %关键词%的SQL再配合LIMIT截断。这个方案在小数据量时勉强能跑但数据量过亿后基本就不可用了。LIKE %关键词%这种写法在前面带通配符B树索引直接废掉只能顺序扫全表。我们做过一次压测千万级消息量下的一次模糊查询耗时通常在30秒以上如果并发再上来几个请求数据库CPU直接打满连正常的业务读写都被拖垮。当时线上还出过事故某热门服务器在活动期间用户集中搜索聊天记录数据库主库负载飙到80%以上导致整个聊天服务不可用。这个阶段的教训是搜索这种重查询绝对不能和在线业务共享同一套存储资源。哪怕只是一个简单的关键词查询也会因为全表扫描放大成一次灾难。2.2 第二代外挂开源检索引擎后来我们引入了开源检索引擎。它的核心思想是倒排索引先把文本分词再建立“词→包含该词的所有消息ID集合”的映射。查询时只需查词典、取集合、做交集并集运算完全不需要扫描原文。在千万级到亿级数据量下单次查询可以做到几百毫秒以内效果立竿见影。同时开源检索引擎自带分布式能力支持把索引分成多个分片分布到多台机器上支持副本提高可用性写入走内存缓冲周期性落盘刷新查询走分片路由并行搜索后再合并结果。这套能力非常贴合我们的需求前期落地也顺风顺水消息量在1亿左右时系统运行得很平稳。但当我们真正往几十亿条消息的方向走时问题出来了。最明显的是资源消耗每个分片内部有多个分段搜索时要合并多个分段的查询结果写多用多的场景下分段数量增长很快后台合并线程永远在赶工磁盘IO经常打满查询延迟开始出现明显抖动。另一个问题是集群管理和数据迁移的复杂度节点故障恢复、分片重分配、跨机房同步都需要大量人工介入团队光维护这套集群就占了不少精力。2.3 第三代自研索引引擎的动机开源方案在大数据量、高写入压力下表现不佳我们决定自研索引引擎。请注意这不是说开源引擎不行而是业务场景特殊这个平台的消息结构高度规整服务器、频道、作者、时间、内容查询路径也非常固定主要在一个服务器内搜索完全可以针对场景做极致的裁剪和优化。自研的目标定得很明确去掉通用引擎那些我们根本用不到的复杂度保留倒排索引、分段、合并这些核心能力把资源利用率做到极致同时让整个索引存储层和消息业务深度耦合写入、重试、数据回补都变成内部的原子操作。事实证明这个方向赌对了。自研引擎上线后集群节点数比原来开源方案少了40%单节点吞吐明显提升查询延迟也稳定不少。3. 核心架构拆解分桶、分段、倒排索引三件套3.1 按服务器分桶把全局搜索问题拆成局部问题这是整个索引架构里我认为最重要的一步按服务器ID做哈希分桶。社区聊天平台的数据天然有隔离性用户搜索时绝大多数场景是“在某个服务器内搜索”跨服务器的全局搜索频率极低。利用这一特点我们把几十亿条消息按“服务器ID哈希取模”分到固定数量的桶里比如64个桶。每个桶内部再按时间范围切分索引分片。分桶带来的好处非常直接一条查询来了之后路由层先根据查询条件里的服务器ID算出目标桶只请求对应的那台或那几台机器其余57个桶完全不参与查询。这就把一个“几百亿条消息的全局检索”变成了“几个亿条消息的局部检索”查询扫描数据量直接下降一个数量级。分桶数量不是随便拍的。桶太少单桶数据量太大查询压力和存储不均匀桶太多调度复杂单条消息的写入开销变大。我们的经验是按“预计最大消息量 / 单桶可承载2亿条左右”来估算桶数起步64个桶后续数据增长还可以在桶内继续切分片理论上可以平滑扩容。3.2 倒排索引从“扫文件”变成“找交集”倒排索引这个词听起来高大上举个生活化的例子。想象你要在一堆图书馆书架里找一本“包含‘辣椒’和‘豆瓣’两个词”的菜谱。笨办法是每本书都翻开看一遍倒排索引相当于每本书前面贴了一张标签纸写下这本书提到过哪些词。查“辣椒”时直接看哪些书的标签上有“辣椒”再跟“豆瓣”的标签取交集根本不用翻书。实现上我们对每条消息的正文做分词处理生成一张词典表词→消息ID列表。查询“部署 服务”时取“部署”对应的消息ID集合和“服务”对应的消息ID集合做交集运算再根据时间、作者等条件过滤。这个集合运算可以在内存里用跳表或位图高效完成毫秒级。但倒排索引也有代价。最直接的就是存储膨胀同一份消息要同时维护原文和倒排结构。另一个是分词质量中文没有天然空格需要好的分词器否则“王者荣耀”被拆成“王者”和“荣耀”用户搜“王者荣耀”时可能搜不到精确匹配。我们后来做了词库定制和同义词映射才把搜索命中率提上来。3.3 不可变分段写放大与查询性能的平衡点在索引引擎内部数据是以“分段”为基本存储单位存在的。分段有两个特点一旦写下去就不可变每个分段内自带一份完整的倒排索引。新写入的消息先落到内存攒够一定量后整体刷新为一个新分段落盘。查询的时候搜索请求要并行扫所有分段再把各分段的结果合并返回所以分段数量越多查询越慢。这就逼着我们做后台合并把多个小分段周期性地合并成一个大分段减少分段数量。合并过程中还会顺带处理删除和更新把那些已经过期或者被标记删除的消息真正从索引文件中清理掉。这里有个经验合并频率、触发阈值要结合实际数据增长速度和查询量来调阈值设太小老是在合并设太大分段数量失控查询变慢。我们最终用“合并后分段大小不超过5GB单节点分段数控制在30个以内”作为触发条件效果比较稳。4. 写入链路一条消息从发出到被搜到要经过哪些环节4.1 索引日志先行先把写操作落盘很多人做索引系统时会忽略一个问题索引数据如果丢了怎么办。业务数据库里的消息还在但索引丢了用户就搜不到这条消息这在实际体验中比消息完全消失更隐蔽。为了不丢索引数据我们引入了“索引日志先行”机制类似数据库的Write-Ahead Log。一条新消息进入索引系统后并不会直接改写索引文件而是先追加一条写操作日志到磁盘返回写入成功然后再异步更新内存索引。如果后续进程重启引擎会重放日志把没有来得及写入索引段的消息重新建立索引。这套机制带来的额外开销很小因为日志是顺序写磁盘性能很好但它保证了索引的最终一致性。有一点要注意索引日志不是万能的它只能保证在日志有效期内恢复数据。我们给索引日志做了保留期限比如保留7天超过期限的日志会被清理。也就是说如果某个分片宕机超过7天就只能通过全量重建来恢复索引不能靠增量日志了。4.2 缓冲刷盘攒一批再写别让每条消息都打磁盘如果每条消息都直接写一次磁盘再快的SSD也撑不住。我们的写入路径是消息到达写入节点后先进入一个内存缓冲池缓冲池按大小和时间两个条件触发刷盘比如缓冲池积压达到64MB或者距离上次刷盘超过5秒就把当前这一批消息整体生成一个新分段落盘。这里有个细节索引消息只是“逻辑写入”到内存就返回给上游确认吗不是的。因为搜索引擎追求的是最终可见我们会把刷盘操作放在后台异步执行但索引日志已经保证不丢数据。所以业务侧感知到的写入耗时非常短而实际落盘是批量完成的吞吐量比逐条写提升了不止一个量级。刷盘频率还要考虑查询的实时性。用户刚发完一条消息希望几秒内能在搜索结果里看到。缓冲池刷盘周期如果太长就会出现“消息发出去了但搜不到”的现象。我们最后把刷盘周期定为2到5秒几乎不会让用户感知到索引延迟同时磁盘的压力也控制住了。4.3 后台合并分段太多会拖垮查询写入越快产生的分段就越多。假如每秒产生一个小分段一天下来就有数万个分段而查询请求需要同时扫描所有分段那延迟肯定崩。我们专门为合并设计了一套后台任务当某个分片的分段数量超过阈值比如20个合并线程就会挑出最小的几个分段合并成一个大分段。这个过程会一直持续保证分段数量稳定在合理区间。合并虽然缓解了查询压力但它本身也是一次重度IO操作合并期间磁盘读写会被顶满可能反过来影响写入。我们的做法是给合并线程设置限速比如把合并的写入带宽限制在磁盘总带宽的40%以内剩余带宽留给实时写入。这样既能推进合并又不会拖垮线上写入属于典型的空间换时间思路。5. 查询链路一次搜索请求在系统里完整走一遍5.1 路由裁剪先定位分桶再查用户在某服务器内发起搜索时查询请求先到网关层。网关从请求参数中提取服务器ID按serverID % bucketCount算出目标分桶编号再把请求转发给对应分桶所在的节点组。因为分桶之间完全独立查询只会在目标分桶内进行其他几十个桶的资源完全不用动。这个设计还有一个隐藏优势分桶之间的数据不共享天然隔离了“热点服务器”和“冷门服务器”。如果某个热门服务器搜索量特别大只会影响它自己那个桶不会把整个集群拖垮。我们可以单独给热点桶扩容、加分片甚至做更细致的并发限制运营层面的灵活性高了很多。跨服务器搜索是特例只有极少数管理端场景会使用。处理方式也简单并发请求所有分桶汇总结果后做全局归并。这种查询因为要打全集群我们特地加了一层熔断限制并发数并且只允许低频调用防止被滥用导致集群雪崩。5.2 分片内查询与并发控制请求到达目标分桶后分桶内的逻辑是根据时间范围定位到该查询的分片我们按周来切分索引分片查询时通过时间条件可以跳过大量不相关分片然后并发扫描这些分片里的所有分段。每个分段内部分词器的查询入口先查词典取倒排列表做条件过滤和评分返回该分段内的Top N结果最后再在各分段之间做多路归并得到全局Top N。这里最容易出问题的是并发控制。搜索请求本质上是个IO密集并且CPU密集的操作如果每个分桶同时接受到大量请求每个请求又并发扫描几十个分段线程池很快会被打爆。我们的方案是分桶级别的信号量控制单个分桶同时执行的查询请求上限设为某个固定值比如16超出的请求直接排队等待。这个值需要根据单机性能和查询平均耗时压测得出设太小会浪费机器设太大容易在流量高峰时拖垮节点。5.3 结果排序、分页与缓存搜索结果不是简单地按时间倒序排列。我们实际的排序策略是相关性评分优先相关性接近时再按时间倒序。相关性评分综合了关键词命中次数、关键词在文本中的位置、标题或正文的权重等因素。这个排序是用户满意度的关键很多平台搜索体验差问题不在于搜不到而在于排出来的东西不对。分页我们用的是“游标翻页”替代传统的pageoffset。搜索引擎的排名会随着新消息写入而实时变化如果用户翻到第5页前面4页的数据可能已经更新传统的offset分页会出现重复或遗漏。游标方案用上一页最后一条消息的评分和消息ID作为下一页的起点稳定性好很多也能避开“深分页导致全量排序开销大”的坑。缓存这方面我们只缓存两类数据热门的查询词筛选条件的消息ID列表以及最终返回给用户的前端页面片段。缓存时间很短一般30到60秒防止用户搜到太旧的数据。冷门查询不缓存因为缓存了也没人命中浪费内存。6. 冷热分离全量索引放不下的现实解法6.1 时间窗口滚动几十亿条消息全部放在热索引节点上无论从查询性能还是存储成本看都不现实。我们按时间做分层热数据窗口默认为最近14天所有写入和绝大部分搜索都集中在这一层索引节点用SSD内存充裕查询响应快。超过热窗口的索引分片会自动标记为“半冷”迁移到容量更大的冷存储节点这部分索引仍然保留在检索引擎里但查询性能要求降低允许秒级延迟。这里有个取舍需要说清楚热数据窗口不是越大越好。窗口越大热节点要承载的数据量越大单次查询扫描的分段越多延迟自然上升。14天是我们根据用户行为统计出来的一个平衡点大多数用户搜索的都是最近一周内的消息超过14天的内容访问量断崖式下降放冷层完全够用。为了平滑数据流动每个索引分片在创建时就带上自己的创建时间。后台有一个调度任务定期扫描分片元数据发现超过热窗口时间线的分片就自动执行迁移任务先复制到冷节点再从热节点删除整个过程对外无感。我们还做了数据校验迁移完成后对两个副本做一次哈希比对防止“删了热的冷的却是坏的”这种灾难。6.2 压缩与对象存储回补更老的数据比如超过半年的消息我们直接把它从检索集群里“卸载”转存到对象存储上并用ZSTD等算法压缩索引文件和原文。压缩比很可观文本日志和索引文件一般能压到原来的1/4到1/3。这一层的数据不提供实时搜索只支持一种叫“回补查询”的能力用户明确选择了某个半年以前的时间段系统才会从对象存储拉取对应索引分片加载到一个临时查询节点执行完查询后自动释放。很多人可能会问为什么不干脆把全量数据一直放在检索集群里原因是搜索引擎的成本模型决定的。热查询和冷查询的收益差太多为了极少数历史搜索场景去维护一个全量索引集群性价比太低。用“回补查询临时加载”的方案平时的存储开销只有原始的压缩文件一年下来能省不少机器成本。这套冷热分离还有一个衍生收益索引集群的“形状”变得非常稳定。热节点只维护稳定的数据窗口写放大、合并、磁盘占用这些指标全都在可控范围再也不会因为历史数据越攒越多导致性能一天比一天差。7. 运维与容量设计机器要多少、参数怎么调7.1 容量估算公式准备部署前我们先用一个简单的估算公式来定机器规模不然心里没底。核心参数就几个日新增消息量、平均消息大小、索引膨胀系数、保留时间窗。假设日新增消息1亿条平均每条消息原文300字节那每天新增原文约30GB。倒排索引的膨胀系数按2倍算索引数据约60GB。再加上元数据、存储损耗、日志等额外开销按1.2倍系数一天总存储增量在100GB左右。热窗口14天热索引集群就是1.4TB的可用存储。单台机器按2TB SSD可用空间算存两副本那么热集群至少需要14台存储节点。这只是存储维度的估算实际还得加上查询并发对CPU和内存的要求最后规划下来存储和计算总需求约20台搜索节点作为起始规模。这个公式不精确但用来做前期规划和预算申请足够了。真正的容量规划一定是上线后根据实际监控指标再动态调整固定不变的数字是没有意义的。7.2 硬件选型与副本规划索引节点对机器有特殊要求不能拿普通业务服务器的配置直接套用。CPU要高频因为分词、倒排集合运算、评分都是CPU密集型操作内存要尽量大因为词典和部分热倒排索引要常驻内存磁盘一定要SSD或更新的NVMe设备机械盘在随机读和合并写入场景就是灾难。副本数我们定为2。一主一副的配置在写入多、读也多的场景下够用副本不止承担高可用职责实际查询也会分摊流量。节点恢复流程我们全自动了一个节点宕机后调度系统自动从备份副本补齐数据新节点加入集群后开始接收增量同步追平数据后上线服务。整个过程不需要人工干预但会把“节点离线时间超过日志保留期”这个红线盯得很紧一旦超时就自动触发全量重建这是防止数据不一致的最后一道保险。7.3 稳定性兜底限流、降级与无损重启再好的架构也怕流量洪峰活动期间搜索请求量可能翻5到10倍。我们在网关层做了集群级限流以秒为单位统计总请求量超过设定阈值后直接拒绝新增请求并返回友好提示。这里的关键是限流动作要“快而平滑”不能让大量请求堆积在网关等待队列里把网关也拖死。降级方面我们做了几级预案第一级是关闭跨服务器搜索只保留单服务器内搜索第二级是关闭冷数据回补查询只保留热窗口第三级是关闭相关性评分退回时间倒序排序。每一级降级都只损失一部分体验换来的是核心搜索能力不宕机。无损重启是运维同学最容易忽略的点。索引引擎在做合并或者刷盘时进程如果被强制杀掉重启后可能要重新构建内存索引耗时几分钟。我们给所有节点加了一种“优雅退出”机制先停止接收新请求等当前合并和刷盘任务收尾再把内存状态落盘最后才安全退出。这套机制配合滚动发布索引集群的版本升级基本能做到客户端无感知。8. 高频问题排查实录8.1 消息发出去了搜索结果里却没有这个问题的概率很高排查路径也固定。先看索引日志里是否出现了这条消息的写操作记录如果日志都没有说明上游写入链路就挂了有日志再看内存缓冲池是否已经刷盘如果一直没有触发刷盘条件消息还悬在内存里搜索当然搜不到。这时候需要检查刷盘定时周期有没有被后台合并任务阻塞我们踩过坑合并任务长时间占用IO把刷盘线程饿死了后来调整了合并限速策略才解决。另一种可能是分词问题。中文分词会过滤掉标点和停用词如果你搜索的关键词恰好是“的”、“了”这类词或者被词库切碎了索引中根本没有对应词项那搜不到是正常的。排除手段很简单用同一个关键词去搜一条已知能命中的消息如果还是不行基本就是分词器配置或词库的问题。8.2 写入开始积压消费进度追不上写入链路积压的典型特征是消费消息的位点滞后持续增加内存缓冲池一直处于满水位。先看磁盘IO是不是被合并线程吃满了是的话就调低合并限速参数。然后看刷盘线程是不是在等待某个慢分片个别分片磁盘故障或硬件差异会导致它天然比别人慢最终拖累上游消费。还有一个隐蔽原因是索引日志的写入路径。日志是按分桶写本地盘的如果某个节点磁盘空间不足日志写入会触发重试重试阻塞写入线程池。这个问题的规避方法是给日志分区设置容量预警保留率达到80%时提前告警并清理过期日志而不是等磁盘满了才处理。8.3 搜索延迟突然升高搜索延迟升高的原因大概有三类。第一类某热门服务器的搜索量突增导致其所在分桶的并发信号量一直被占满请求排起了长队。这时先看监控面板上分桶级别的请求量和P99延迟确认热点后再决定是临时扩容还是对热门桶做独立限流。第二类后台合并任务操作了超大分段导致查询扫描大分段耗时变长。解决方法是把合并阈值往下调让大分段尽量在后台就拆分好避免前端扫大段。第三类缓存命中率骤降比如某个流量型事件发生后大量用户搜索新话题热查询缓存全部失效。这种不用做代码变更等流量平稳后缓存自然重新积累但要注意别把缓存击穿变成集群雪崩建议给每个查询加上单次令牌桶保护。8.4 节点内存狂飙内存膨胀的排查点有几个先说最常见的一个倒排索引和词典被大量载入堆内存而JVM堆容量设置不够。搜索引擎的词典通常用FST结构放到堆内FST查询快但堆内存敏感。我们当时调整了策略把大字段的词典改为内存映射文件方式加载堆内只保留热词缓存内存压力立刻缓解了一大半。另一个容易被忽视的是直接内存或堆外内存泄漏。合并和刷盘的数据块如果没正确回收时间久了会累积成完整的内存桶。这种问题的定位手段是给相关场景打上指标定时检查直接内存使用曲线如果曲线只涨不降基本可以确认泄漏再逐步二分禁用某项功能来定位源头。说实话这个系统上线后并没有立刻变得完美而是经历了好几轮“性能监控→定位瓶颈→优化→再监控”的循环。我个人最大的体会是做海量消息索引不要迷信某一种技术或者某一个参数真正可靠的是把你自己的数据特征吃透。搜索这个场景和普通OLTP不同它的优化空间极大但前提是你要愿意花时间去观察监控曲线去理解每一次CPU飙高或查询变慢背后的真实流量pattern。最后再分享一个小技巧任何索引方案上线前一定要先做“数据回放”测试拿真实历史流量回放一遍把线上热点查询和突发流量都模拟出来观察延迟曲线和资源占用。这个测试多花两天时间能在上线后帮你省下两个月的踩坑时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑