ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Elasticsearch在IM消息检索中的工程化落地:索引、同步与集群运维实战

Elasticsearch在IM消息检索中的工程化落地:索引、同步与集群运维实战 几年前我们团队接手一个日活几十万的 IM 项目线上用户反馈最多的问题之一就是“搜不到历史消息”。当时消息表用 MySQL 存储检索靠LIKE %关键词%数据量到千万级之后一个群聊搜索能卡出 3 秒以上接口超时率飙升。后面我们决定引入 Elasticsearch 做消息检索。但真正动手之后才发现ES 落地到 IM 场景不是“装个集群、写个接口”那么简单索引怎么建、数据怎么同步、深分页怎么解决、集群怎么运维全是坑。这篇文章就记录我从 0 到 1 把 Elasticsearch 工程化落地到 IM 项目里的完整过程包括设计思路、索引建模、写入链路、查询优化、集群运维和踩坑记录适合正在做 IM、客服系统、社区评论搜索等场景的同学参考也适合部署了 ES 但被数据同步和性能问题折磨的团队拿来对照自查。1. 项目背景与选型IM 检索为什么绕不开 Elasticsearch1.1 消息检索的痛点和需求拆解IM 场景的检索和其他业务搜索不太一样。用户行为非常高频光是“搜索聊天关键词”这一个入口高峰期每秒查询就有上千次消息数据是典型的写入多、更新少但历史数据要永久保留而且消息内容属于强隐私数据不能随便交给外部服务。除了数据特征IM 检索的需求类型也很杂。平时我们接触最多的搜索入口包括全局搜索搜联系人、群名、聊天记录、会话内搜索只看和某个人的聊天记录、群聊记录筛选按发送人、文件类型、时间范围过滤、还有“提到我的”“图片/文件”等结构化筛选。每种场景对应不同的查询模式用 MySQL 的索引很难统一优化。当时我们还专门统计过线上最主要的几个慢查询反馈到研发团队的问题基本都集中在三点第一模糊查询导致全表扫描第二多条件组合筛选没法走联合索引第三翻页越往后越慢。这些痛点恰好是 Elasticsearch 擅长的方向——倒排索引做全文检索多个 filter 条件并行过滤分布式架构天生支持海量数据。1.2 方案选型对比为什么不是 MySQL、ClickHouse 或者 SaaS在确定使用 Elasticsearch 之前我们其实对比过好几类方案这里把当时的思考过程分享出来可能对正在选型的人有帮助。MySQL 系的方案最直接全表加索引、分库分表、用LIKE或者全文索引。但我们很快否掉了理由很现实IM 消息表写入量已经很高分库分表虽然能解决存储问题但“跨分片搜索排序”在业务层实现起来非常痛苦而且 MySQL 全文索引对中文支持一般词组分词效果差长句搜索基本不可用。ClickHouse 我们当时也重点研究了。它在聚合分析场景确实强查询几十亿行数据毫秒级返回但有两个问题一是高并发点查和关键字搜索不是它的强项二是消息内容检索需要分词支持ClickHouse 生态里虽然有一些 tokenization 方案但工程化成熟度不如 ES。如果你只是做“用户行为日志分析”“监控指标聚合”ClickHouse 很合适但做 IM 消息搜索ES 更对口。还有一些 SaaS 搜索服务比如直接接入 Algolia、Meilisearch 这类托管产品。省事是真的但我们考虑两个问题一是 IM 消息属于强隐私数据很多敏感内容不适合过外部服务二是这类服务按请求量和存储量计费IM 场景下消息量和搜索请求量都很高长期成本不可控。最终我们决定自建 Elasticsearch 集群。1.3 在 IM 项目里 ES 适合做什么、哪些功能不建议做想清楚需求边界很重要。ES 不是万能药在 IM 里我最常用的能力是全文检索、过滤聚合和排序分页。全文检索解决“输入关键词找消息”过滤聚合解决“按类型找文件”“统计某个人发了多少条”排序分页解决“最新消息排在前面、翻页不重复”。有一类需求要特别谨慎把 ES 当关系型数据库用频繁 update 消息状态、做事务操作、强依赖实时一致性。ES 是近实时Near Real-Time系统文档刷新有延迟写入后 1 秒内可能才能被搜索到。IM 场景里像“撤回消息后立即不能被搜到”这种需求建议靠业务层做二次过滤或者把状态字段同步到 ES 后接受短时间延迟不要指望 ES 像 MySQL 一样强一致。还有一类是“精确计数场景”比如“统计某个群最近一年的消息总数”这类聚合用 ES 没问题但如果需要每秒都精确更新最好还是落一份数据到 Redis 或 ClickHouse。我们就把“最近 N 条消息预览”这类高频低延迟需求放在 RedisES 只负责搜索结果和筛选。2. 索引建模IM 数据如何落到 ES 才不翻车2.1 消息索引的核心字段设计索引设计是 ES 工程化落地的第一步也是返工成本最高的地方。我们的消息索引字段前后迭代了三版核心字段沉淀下来大概是这样message_id消息全局唯一 ID类型用 keyword作为文档唯一标识。conversation_id会话 ID群聊或单聊的唯一标识keyword 类型。这个字段非常关键几乎所有查询都会带它。sender_id发送人 IDkeyword。msg_type消息类型比如 text、image、file、systemkeyword。content消息文本内容text 类型配置分词器。send_time消息发送时间date 类型建议用毫秒时间戳存储。at_user_ids被 的用户 ID 列表keyword 数组支持“提到我的”搜索。is_recalled是否撤回boolean这个字段建议保留虽然前面说近实时但配合业务兜底过滤很实用。设计 mapping 时最大的一个坑是不要把所有字符串字段都定义成 text。IM 消息里 sender_id、conversation_id 这类是精确筛选字段用 keyword 即可content 才是需要分词的字段用 text。如果混着写会导致很多查询走了错误的字段类型性能差而且结果不对。2.2 分片与副本怎么规划分片数在设计初期最容易拍脑袋但一旦定了就不好改因为 ES 不支持直接修改已有索引的主分片数。我们当时踩过的坑是一开始为了图省事每个索引固定 5 个分片结果消息量增长后部分分片明显过热查询和写入都集中到某几个分片上导致节点负载不均衡。比较稳妥的做法是按日/月创建索引每个索引的分片数根据单日数据量预估。比如我们的业务日增消息大概 2000 万条左右单条文档只有几百字节算下来单日数据量约 5GB 到 10GB。结合单分片 30GB 到 50GB 的推荐上限单日索引 1 到 2 个主分片就够了。集群总节点 3 个每个分片配 1 个副本这样既保证容灾又不会因为副本太多浪费磁盘。还要强调一点分片数不是越多越好。分片太多会导致每个分片都很小segment 文件数量庞大查询时需要合并的上下文变多反而降低性能。我们后来统一按照“单分片 30GB 左右”的节奏来规划不再纠结具体数字运维清爽很多。2.3 分词器选型中文搜索必须单独处理IM 消息搜索里中文分词是绕不开的环节。如果直接使用 ES 默认的 Standard Analyzer中文会被切分成单字搜“红烧肉”匹配“红烧牛肉”都会出问题召回率过高、准确率太低。我们生产环境用的是 ik 分词器版本必须和 ES 主版本严格匹配。实际配置时我通常会用ik_max_word做索引分词尽可能把长词、组合词拆出去搜索时用ik_smart做查询分词保证切词更精准减少无效命中。比如“北京欢迎你”这句话索引时拆得越细越容易召回搜索时则拆成更少、更明确的词能提高准确率。分词器选完后不能直接上线一定要用一个接近真实业务的数据集做分词效果验证。我们曾经因为ik_max_word对用户昵称的过拟合造成误匹配后来在索引配置里增加了停用词列表把一些无意义的语气词、脏话、业务黑名单词过滤掉搜索质量才稳定下来。2.4 索引生命周期管理别让索引和磁盘一起爆炸IM 消息是持续增长的如果没有索引生命周期管理ILM磁盘迟早被打满。我们最早就是没做滚动索引一个索引里堆了三个月的消息shard 数量也不合理最后集群 OOM 好几次。当前我们的策略是按月创建索引配合 ILM 做冷热分层hot 阶段最近 3 天的索引保持在热节点SSD承担高并发读写。warm 阶段3 天到 3 个月的索引迁移到温节点普通机械盘减少副本数节省存储。cold 阶段3 个月以上的索引可以强制合并 segment然后进入只读状态甚至归档到低频存储。索引别名是配套的工程化手段。我们让上层业务始终通过一个“读别名”去查所有历史索引比如im_message_read写入端通过“写别名”指向最新索引。好处是业务方感知不到索引切换升级索引 mapping 或者做数据回放时只要重建一个新索引、切换别名就行不需要改业务代码。3. 写入链路数据同步、双写和一致性问题3.1 同步方案选型双写、MQ 还是 binlog索引设计好后接下来就是数据怎么进去的问题。IM 消息的写入链路非常核心因为系统先写 MySQL再写 ES两边的数据一致性直接决定搜索结果的准确性。我们评估过三种同步方式。第一种是业务代码双写即发消息时同时写 MySQL 和 ES。这种方式最简单但有两个问题一是多一次远程调用增加发送链路延迟二是双写失败处理麻烦MySQL 写成功、ES 写失败状态怎么补偿很头痛。第二种是 MQ 异步同步。我们最终选了这种方式消息先正常入库同时把消息 ID、会话 ID、内容等关键字段发到 Kafka由专门的消费者线程读取并写入 ES。这样发送链路不感知 ES 的存在ES 出问题也只影响搜索不影响用户发消息。第三种是基于 binlog 订阅。通过 Canal/Debezium 这类工具订阅 MySQL binlog把变更同步到 ES。适合已有系统不方便改代码的场景但我们当时新系统还在开发期直接用 MQ 更简单高效。3.2 消息写入的幂等与顺序保障MQ 异步化的最大代价是重复消息和乱序问题。消息发送端可能会重试消费端也可能在写入 ES 后因为网络超时被判定失败又消费一次。如果不做幂等同一个 message_id 会有多条文档。解决办法很直接用message_id作为 ES 文档的_id这样重复写入时ES 会按_id覆盖更新天然幂等。我们在消费逻辑里明确用_id作为唯一键上游无论如何重试最终落到 ES 的文档只有一条。顺序问题在 IM 消息场景相对好处理因为消息本身是近似 append-only 的同一个会话的消息按 send_time 排序即可。我们遇到的顺序问题主要出现在“撤回”“编辑”这些更新操作上解决思路是给消息加一个version字段在写入或更新时做版本判断旧版本来了直接丢弃。3.3 全量回放的实战流程存量数据怎么灌进 ES也是工程化绕不开的环节。当时百万级历史消息需要一次性回放我们写了一个迁移任务流程大概是按消息 ID 分批从 MySQL 查询数据每批 500 条。每批数据组装成 ES bulk 请求每批 1000 条并发控制在 10〜20 个线程。批量写入期间关闭 refresh 间隔例如设置refresh_interval: 30s减少 segment 频繁刷新。全部完成后手动执行一次_forcemerge把 segment 合并到大文件提升查询性能。千万不要直接在生产集群上用默认配置做全量回放否则磁盘 IO 会瞬间被打满影响线上正常写入。如果你担心回放占资源可以先用一个独立的小集群回放验证数据量之后再挂到线上别名上。4. 查询优化高并发下 IM 搜索会踩的坑4.1 深分页问题不要再用 from size对 IM 场景来说翻页搜索是高频操作但 ES 默认的from size方式在深分页场景下非常致命。ES 要先将每个分片上的 fromsize 条数据全部取出来然后排序合并最终返回 size 条。翻到 10000 条以后查询耗时暴涨集群内存也被拖垮。我们生产环境限制from size最大 10000超过的话直接返回错误提示同时强制走search_after方案。search_after 的原理是上一页返回的最后一条结果里有一个用于排序的值比如send_time和message_id的组合下一页查询直接告诉 ES“从这条记录之后开始查”可以稳定地深翻页。具体实现上要注意排序字段必须唯一且稳定。我们组合了send_time和message_id两个字段确保同一毫秒的消息也有确定的先后顺序避免翻页时出现重复或漏数据。4.2 用 routing 把查询压力分摊到不同节点IM 搜索有个特点大量搜索集中在热门会话和热门群聊。如果不做控制可能会出现某个大群的消息检索把所有流量都打到同一批分片上。我们的解决方案是使用routing参数写入时按conversation_id做路由同一个会话的消息落到同一个分片上查询时带同样的 routing 值ES 就知道只需要查 1 个分片而不是全部分片。routing 的效果非常明显。我们有一个头部用户群单群消息千万级之前每次查这个群都要扫全索引热节点 CPU 经常飙到 90% 以上。加上 routing 后同一个群的消息落在一个分片上查询时只扫描单个分片CPU 直接降到 20% 左右。而且 routing 还让缓存命中率更高相同会话反复查询时性能提升更明显。但也要注意routing 会让数据分布不均。如果会话热度差异过大某些分片的数据量会明显多于其他分片。我们接受这个不均衡因为换来的是查询性能的大幅提升。如果你的集群没有这种热点场景不建议乱用 routing。4.3 慢查询排查与 DSL 优化线上搜索接口变慢时第一步不是看服务端代码而是先查 ES 的慢日志。我们在 ES 配置里开启了慢查询日志设置index.search.slowlog.threshold.query.warn为 1 秒一有慢查询就会打印出完整的 DSL 和耗时分布。这能快速定位到底是哪一步慢。实际排查中发现IM 搜索慢一般有几个原因不带conversation_id却做全局查询。全库查询无可避免建议业务层严格控制搜索范围能带会话约束尽量带上。在content字段上用了模糊正则查询或者wildcard。这类查询无法利用倒排索引性能极差。如果真有这种需求建议用 ngram 分词或者专门的模糊匹配方案。filter 过多导致缓存失效。ES 的 filter context 会缓存 bitmap但缓存容量有限如果条件组合太多样化缓存命中率很低每个查询都要重新过滤。这种情况要么减少组合维度要么对 filter 结果做业务层 Redis 缓存。聚合查询中cardinality高基数字段导致内存暴涨。IM 搜索里我们基本不用这类聚合除非在离线统计场景。写 DSL 时还有一个经验能用filter就用filter不要全堆在must里。因为 filter 不参与相关度打分能走缓存性能远高于 query context。比如按会话、消息类型、发送人过滤这些条件通通放进 filter只有关键词匹配才放 must。5. 集群运维与部署工程化5.1 版本选择与 Windows/Linux 部署注意点ES 的版本选择非常影响后续稳定性和生态兼容性。当时我们评估新版 ES 之后决定生产环境沿用稳定性更高的 7.x 分支因为大部分客户端库、分词插件、监控组件都优先适配 7.x。新版 9.x 已经发布功能很强但配套生态还没完全跟上团队如果不是特别需要新特性建议先压测验证再上。部署环境这块很多人开发机是 Windows随手把 ES 解压后双击elasticsearch.bat启动。开发环境这么玩没问题但要记住几个细节路径不能有中文字符或空格否则启动可能报错不能用管理员权限运行ES 会拒绝 root 用户启动默认 JVM 堆只有 1GB数据量大一点就 OOM开发环境至少要调高到 2GB〜4GB。生产 Linux 部署时最重要的一条是关闭 swap开启bootstrap.memory_lock避免 JVM 内存被换到磁盘导致性能雪崩。5.2 JVM 堆、磁盘、线程池调优ES 是 Java 应用JVM 堆设置很有讲究。堆太小会导致频繁 GC堆太大又会挤占操作系统 page cache 的空间。我们生产环境的经验是堆总大小设置为物理内存的一半上限不超过 31GB推荐范围是 4GB〜31GB超过 31GB 后对象指针压缩失效反而浪费内存。机器 64GB 内存的话JVM 堆设为 31GB 左右剩余留给 Lucene 做文件缓存。磁盘 IO 在 IM 搜索场景同样关键。ES 的写入和查询都依赖磁盘建议数据目录用 SSD 而不是机械盘。我们上线初期图省事用了普通云盘结果高峰期段查询毛刺非常明显后来改成 SSD 才稳定。另外索引数据目录和日志目录要分开避免日志写满磁盘后把索引 data 目录挤爆。线程池这个点容易被忽略。ES 内部有 search 线程池、write 线程池默认配置在绝大多数场景够用。但如果你的业务查询特别密集可以检查一下thread_pool.search.queue_size如果出现RemoteTransportException或RejectedExecutionException说明队列满了。我们的处理方式是增大搜索线程池队列、限制单次查询并发度同时从业务层面做限流和缓存。5.3 监控、告警和容量规划ES 集群没有监控就像开车不看仪表盘。我们接入了 Prometheus Grafana 体系核心监控项包括集群状态green/yellow/red颜色一变就要立刻处理。CPU、内存、磁盘使用率、IO wait。节点 JVM heap 使用率、GC 次数和时间。索引写入和查询的 QPS、延迟、拒绝数。分片数量和状态是否有 unassigned shards。告警阈值我们是这样定的heap 使用率超过 75% 持续 5 分钟告警磁盘使用率超过 85% 告警预留扩容时间search 延迟超过 1 秒的请求比例超过 5% 告警。磁盘容量规划更要提前算我们因为消息量增长快差点在月底被“磁盘只读”打脸后来索性给每个索引都做了容量监控报表每周预判一次未来 4 周的增量。6. 常见问题排查与避坑实录6.1 集群状态 Yellow/Red 的处理ES 集群状态最直观的健康指标。大量 IM 项目的 ES 集群处于 yellow 状态却没人管这种现象其实问题不大——如果是副本分片未分配集群依然可用但没有冗余保护节点一挂就有丢数据风险。遇到 yellow 状态我们统一的排查路径是看未分配分片原因通过GET _cluster/allocation/explain查看具体信息然后针对性处理。最常见原因是磁盘空间不足、节点离线、分片数据损坏。如果是磁盘水位问题清理旧索引或者扩容磁盘就能恢复如果是节点离线等节点恢复后分片会自动重新分配。red 状态则意味着有主分片未分配部分数据完全不可读。这种情况要马上查 unassigned shards 的原因必要时用 reroute 手动分配。最差的一种情况是主分片数据损坏只能通过快照恢复所以我们每天凌晨固定做一次快照上传到对象存储这是容灾的底线。6.2 脑裂问题配置缺失导致的集群分裂ES 集群脑裂的原因通常很简单节点配置选举参数不合理。早年我们只有 3 个节点却忘了设置discovery.zen.minimum_master_nodes有一天网络抖动3 个节点各自认为自己是主节点分成两个集群写入互相覆盖数据乱成一团。修复方案我一直记到现在集群节点数为 N最小主节点数设置为N/2 1向下取整。3 个节点就配置为 25 个节点配置为 3。这样即使网络分区也只有多数派那边能选出主节点有效避免脑裂。新版 ES 配置项名称变成了discovery.seed_hosts和cluster.initial_master_nodes核心思想一样部署前一定要认真核对。6.3 索引堆积与磁盘暴涨IM 消息增长快索引如果不按时清理磁盘迟早会被塞满。我们遇到过最惊险的一次是 ILM 策略里 cold 阶段配置失误导致三个月前的索引没有及时合并归档磁盘使用率从 60% 一路涨到 95%集群自动切到只读模式搜索接口全部失败。那次之后我把索引生命周期管理重新梳理了一遍形成固定流程每天巡检一次磁盘水位每月自动触发一次 forcemerge三个月以上的索引统一走归档和只读。同时给所有按时间分组的索引创建了删除策略超过保留期的直接删绝不留情。IM 业务虽然需要长期查历史消息但 3 年前的聊天记录真的很少有人会翻这种低频数据压缩归档就够了。6.4 搜索不到或结果不对的排查搜索不到数据是 IM 搜索最容易被投诉的问题。我们排查的固定思路是先确认数据有没有写入 ES通过GET index/_count看文档数是否有变化然后直接用 message_id 查单条文档确认字段是否写入完整最后检查搜索 DSL 的分词效果用analyzeAPI 看关键词被切成了什么。很多“搜不到”的问题其实出在分词上。比如用户搜“哈哈哈”ik 分词可能把它过滤成单字在倒排索引里找不到匹配。我们的对策是对短词和语气词做特殊处理搜索时增加少量match_phrase或模糊匹配兜底同时业务层做“输入建议”和“热门搜索词”引导。另外撤回消息的过滤逻辑也容易出错我们后来统一在业务查询层对结果集做二次过滤避免 ES 端 Boolean 条件太多反而不好排查。7. 工程化复盘落地 Elasticsearch 过程中的几条硬经验7.1 先定 SLA再谈性能开发团队最容易犯的错误是一上来就追求高并发、低延迟但 IM 搜索的 SLA 到底该是多少很多人没想清楚。我们在引入 ES 之前和产品、运营对齐了一个目标搜索接口 p95 延迟不超过 800ms搜索可用性不低于 99.9%数据同步延迟不超过 3 秒。有了这个基线再去倒推集群规模、同步链路、缓存策略所有技术决策都有了判断标准。比如数据同步延迟不超过 3 秒这个目标决定了我们不能用 binlog 订阅这种可能产生分钟级延迟的方案而应该用 MQ 实时消费也决定了 ES 的 refresh 间隔不能设置太长否则数据写入后看不到。如果一开始没有 SLA团队很容易在“要不要加 Redis 缓存”“要不要用异步写入”这类问题上反复争论。7.2 搜索质量要在测试和 Code Review 阶段就管好工程化不只是技术还包括研发流程。IM 搜索这块我们吃过很多亏比如线上出现内容违规词被搜索展示、隐私内容误匹配等严重问题这些光靠测试手点根本发现不了。后来我们逐步形成了一套自动化测试和代码审查标准每个搜索功能除了正常的接口测试还要自动生成一批异常输入用例包括超长关键词、空关键词、特殊符号、SQL 注入尝试、敏感词组合等用 pipeline 跑到测试环境里做回归。代码 review 时重点看两个地方一是有没有在查询语句里拼接用户输入且不过滤二是有没有把业务逻辑复杂全部丢给 ES 做。我们后来用了一些工程化工具会自动扫描代码变更里的高风险 ES 查询模式比如裸的wildcard、全表match_all等直接在 review 阶段就拦下来。这让线上事故少了很多强烈推荐有条件的团队都试试。7.3 上线演进路径灰度、开关和降级ES 接入 IM 主链路是高风险操作不能一把梭上线。我们的计划分三步走第一步旁路验证。消息写入照常同步到 ES但搜索接口不切流量只是定期对比 ES 结果和 MySQL 结果的差异。这一步跑了大概一周把分词和排序问题暴露得七七八八。第二步灰度放量。先开放给内部员工用再开放给 1% 用户最后逐步到 10%、50%、100%。每个阶段都盯着搜索成功率、延迟、数据同步延迟这三个指标任何一个异常就立刻切回开关。第三步降级预案。如果 ES 集群挂了搜索接口要能快速降级到简单的 MySQL 查询哪怕只返回最近 100 条结果也不能让用户看到“搜索服务不可用”的错误页。我们在网关层做了开关一旦监控发现 ES 集群异常10 秒内就能全局降级等集群恢复后再切回来。这套降级机制在线上真实发生过两次都是因为集群升级操作失误导致的短暂不可用。虽然业务上搜索中断了几分钟但因为有降级预案用户的聊天、发消息功能完全不受影响投诉量远低于预期。做 ES 在 IM 项目里的工程化落地最大的感受是ES 本身性能确实强但真正决定项目成败的是数据链路、索引模型、查询边界和运维规范这些“周边工程”。尤其是数据同步和查询兜底这两个环节设计得不好ES 再快也白搭。如果让我再重做一次我会更早就把 SLA、监控和降级方案定下来而不是等出了问题再补救。另外想分享一个小技巧ES 的_id设计很重要把message_id直接作为_id很多幂等和去重问题都能省掉一半。希望这篇记录能帮你少走一点弯路。
返回列表