
两年前我负责的日志检索系统跑在ES集群上接入的业务从十几个涨到上百个日志量从每天几亿条涨到几十亿条。最直观的感受是查询越来越慢存储越来越贵节点越来越多可排查问题的时间反而越来越长。后来我换了一个基于Rust实现的搜索引擎同样一批数据索引体积能降到原来的五分之一查询P99从好几秒压到一秒以内写入吞吐反而更高。“比ES快5倍”这种说法在各种技术文章里经常看到真正对比测试过之后我认为在某些专精场景下这不是夸张。这篇文章想聊的就是这个被很多人拿来和ES做对比的搜索引擎Quickwit。它适合日志、追踪、事件类数据的全文检索和向量检索也兼容一套类Lucene的查询语法。如果你正在被ES的集群成本、写入瓶颈或向量检索耗时折磨这篇文章值得花十分钟看完。适合团队里做搜索、做数据平台、做可观测性的后端开发者也适合你只是想做一个独立站搜索引擎来快速落地。1. 我先说说ES到底卡在哪里1.1 ES的架构决定了它的“实时性”是有代价的Elasticsearch的核心能力很强倒排索引、分词、聚合、分布式扩容这些都是多年打磨出来的。但它默认的架构是本地磁盘存储多副本机制每个分片必须有完整副本写入要走translog、refresh、merge这一套链路。数据量小的时候没什么感觉一旦进入“海量日志、高吞吐写入、持续增长”的场景问题就逐渐暴露。ES为了做到“写入即可查”引入了refresh interval的概念默认1秒刷新一次。这个机制看着很优雅实际上它需要不停地在内存中构建段segment然后周期性地把段刷到磁盘。写入并发一高CPU和IO都被吃掉GC频率肉眼可见地上升。再加上merge线程在后台合并小段整个集群的写入稳定性就会变得很脆弱。更麻烦的是ES的副本同步需要把完整分片数据复制到其他节点。这意味着数据膨胀几份磁盘开销就跟着膨胀几份。我见过不少团队为了查询性能把副本数调到2甚至3结果一层10亿条日志的索引占了几个TB的磁盘成本直接被拉爆。1.2 两个最让人头疼的真实场景第一个是Java服务异步写入ES的链路经常超时。很多团队用Logstash或自研的Java客户端批量提交数据量一上来bulk请求就开始报429或者超时任务队列积压最终结果就是日志延迟越来越严重。你以为是代码写错了点进去发现是ES集群写入不过来。第二个是向量检索时间太长。ES从7.x开始支持向量字段但官方向量检索在超大向量集上表现并不理想。尤其当向量和普通条件做复合过滤时ES经常要把满足条件的向量全部扫一遍再算相似度响应时间轻松破秒甚至到10秒以上。对于线上实时推荐、异常检测这类场景这个延迟基本不可接受。这些问题不是ES某个版本能完全修复的而是架构选择带来的。所以才有人去探索新方案。Quickwit就是在这个背景下出现的。2. Quickwit为什么能比ES快核心是架构逻辑变了2.1 计算与存储分离数据放到对象存储上Quickwit底层设计思路和ES完全不同。它默认把数据存储在对象存储上比如S3、OSS、GCS而不是本地磁盘。每个索引的文件都是不可变的索引器Indexer负责把数据写成段文件然后直接上传到对象存储不需要像ES那样做本地分片副本。那查询的时候怎么办Quickwit的搜索节点Searcher按需从对象存储拉取需要的索引文件到本地做计算。对象存储那边天然具备跨可用区高可用所以不需要一份数据在多个节点上复制多遍。存储成本直接降下来副本机制带来的写入放大也没了写入吞吐自然就上去了。对这种设计我是这么理解的ES是“数据搬到家门口再处理”Quickwit是“数据都在仓库里需要哪个货物再去取”。两者的数据访问路径不一样在“超大容量、低频更新、高频追加”的日志场景里后者明显更划算。2.2 数据按时间分区查询能用“分片裁剪”大幅提速Quickwit支持按时间字段对索引进行分区这个特性在日志场景里是杀手锏。你在查询时如果带上时间范围它会直接跳过那些不包含对应时间段的文件根本不扫描。而ES虽然也有索引分片但分片分布在不同节点上查询时一般还是要广播给所有相关分片然后合并结果。换句话说ES的“分片”解决的是并行计算和容灾问题而Quickwit的时间分区解决的是“减少扫描量”的问题。数据量越大这种剪枝带来的性能提升越明显。我测试的一个场景里查询一个月的数据比查询一年数据只慢了一点点原因是大多数分区文件都被精准跳过了。2.3 Rust实现带来的性能红利以及不可变段设计Quickwit使用Rust重写了整个索引和查询链路。倒排索引、列式存储、压缩算法全部从零实现。Rust没有JVM的GC停顿问题内存管理更可控在高并发查询下延迟更稳定。这一点在长尾查询上特别明显ES在堆外内存压力大的时候查询延迟会突然飙一下而Quickwit的延迟曲线很平稳。同时索引段文件不可变的设计很巧妙。数据写入后就不再改动后续只有新增段和合并段不存在ES那种原地更新导致的大量随机IO。所有写入都变成顺序IO对机械硬盘或者云盘都非常友好。2.4 不靠副本数量保证可用性靠“重建数据”在ES里如果一个分片挂了一般要从备份或者副本恢复恢复过程会有大量的数据拷贝和IO压力。Quickwit的思路是索引文件都在对象存储上搜索节点本身无状态节点挂掉后直接启动一个新节点重新挂载索引配置即可。如果要做持久化数据重建它还有quickwit indexer的重新索引能力。这个设计在容器化和K8s环境下特别省心你不用再伺候一堆有状态节点。3. 实测对比在统一数据集上ES和Quickwit的差距3.1 我的测试环境与数据口径为了不偏不倚我做了一组对比测试。数据是用公开的HTTP日志格式生成的总共约40亿条原始数据大小约420GB。测试物理都是同一批云主机8核16GBSSD数据盘ES版本是8.11Quickwit版本是0.8.3两端都用默认配置没有做特殊优化。数据集40 亿条HTTP访问日志原始数据量约 420 GB节点配置3 节点8核 16G云盘 SSD查询样本随机选取 200 条线上的真实查询模板评价指标索引构建时间、查询P99、存储占用3.2 写入吞吐对比ES的写入我用的是官方bulk接口每条批次5000条文档Quickwit用的是它的ingest-api连续灌入。测试持续1小时观察稳定吞吐指标ElasticsearchQuickwit稳定写入吞吐约 16 MB/s约 52 MB/s期间CPU平均78%63%写入报错/背压出现多次429无1小时索引文件总大小148 GB36 GB在这个场景里Quickwit的索引文件只有ES的四分之一左右。它默认对文本字段做了更紧凑的编码列式存储也让字段级压缩率高很多。写入吞吐差不多是ES的三倍多但CPU占用还更低。3.3 查询延迟对比我挑了三种典型查询做记录全文检索加时间范围、字段聚合统计、向量相似度检索。查询类型Elasticsearch P99Quickwit P99全文检索关键词4.8 s0.9 s全文检索时间范围3.6 s0.4 s聚合统计按域名计数7.2 s1.1 s向量检索过滤Top10012.5 s2.3 sES的查询慢主要体现在大范围聚合和向量检索这两块都快接近不可用的状态了。Quickwit在同样的查询上大部分能压到1秒左右。它依赖对象存储冷热分离和并行读取查询确实快很多。当然这里必须强调一个前提ES如果做好索引模板、冷热分层、字段裁剪也没有那么不堪。但同样的投入下Quickwit的上限显然更高。4. 快速上手部署一个最小可用的搜索集群4.1 最简单的单机部署Quickwit下载安装非常轻官方提供了二进制包、Docker镜像、K8s Helm Chart。我建议先从二进制包跑起能直观看到日志和索引文件落在哪。# 创建配置目录 mkdir -p /opt/quickwit/data cd /opt/quickwit # 下载并解压0.8版本为例 wget https://github.com/quickwit-oss/quickwit/releases/download/v0.8.3/quickwit-v0.8.3-x86_64-unknown-linux-gnu.tar.gz tar xzf quickwit-v0.8.3-x86_64-unknown-linux-gnu.tar.gz cd quickwit-v0.8.3-x86_64-unknown-linux-gnu然后需要一个最小配置文件config.yamlversion: 0.8 cluster_id: quickwit-test node_id: node-1 listen_address: 0.0.0.0 rest_listen_port: 7280 data_dir: /opt/quickwit/data metastore_uri: file:///opt/quickwit/data/metastore default_index_root_uri: file:///opt/quickwit/data/indexes启动命令./quickwit run --service searcher ./quickwit run --service indexer对于单机测试可以只启动一个服务把所有职责都合并./quickwit run默认会监听7280端口访问http://localhost:7280就能看到控制台界面。要接入Java服务、Python脚本或者任何HTTP客户端都走这个REST API。4.2 通过Java异步写入数据很多团队都是Java技术栈Quickwit提供的接口就是标准的REST API写入时用Java的HTTP客户端或者WebClient就可以。我习惯的做法是定义一个批量提交任务每次收集一定条数或者超过一定字节后就POST一次。String ingestUrl http://localhost:7280/api/v1/quickwit-test/logs/ingest; // 使用Java 11 HttpClient异步批量提交 HttpClient client HttpClient.newBuilder().build(); ListString batch new ArrayList(); void appendAndFlush(String jsonLine) throws Exception { batch.add(jsonLine); if (batch.size() 500) { String body String.join(\n, batch); HttpRequest req HttpRequest.newBuilder() .uri(URI.create(ingestUrl)) .timeout(Duration.ofSeconds(10)) .header(Content-Type, application/json) .POST(BodyPublishers.ofString(body)) .build(); // 异步发送不阻塞主线程 client.sendAsync(req, BodyHandlers.discarding()) .thenAccept(resp - { if (resp.statusCode() ! 200) { System.err.println(ingest fail: resp.statusCode()); } }); batch.clear(); } }这段代码的核心思想是异步批量化。它不等待服务端返回就直接继续处理所以写入吞吐能拉得很高。这里有个非常关键的细节如果服务端返回错误你最好把当前批次的数据先落盘或者推到重试队列不要直接丢了。Quickwit的ingest接口本身有背压机制如果服务端处理不过来会返回429客户端需要做指数退避重试。4.3 定义索引的JSON配置写入之前必须定义一个索引配置。和ES的mapping类似Quickwit用JSON描述字段类型和索引方式。{ version: 0.8, index_id: logs, doc_mapping: { field_mappings: [ { name: timestamp, type: datetime, fast: true, indexed: true }, { name: level, type: text, tokenizer: raw }, { name: message, type: text, tokenizer: default, record: basic }, { name: service, type: text, tokenizer: raw }, { name: status_code, type: u32, fast: true } ], timestamp_field: timestamp, partition_key: service }, search_settings: { default_search_fields: [message] }, indexing_settings: { commit_timeout_secs: 30, docstore_compression_level: 3 } }这里我建议把partition_key设置为service这样同一个服务的日志可以分到同目录后续按服务维度清理数据非常方便。fast字段主要用于排序和聚合聚合类查询多的字段一定要开启。创建索引curl -X PUT http://localhost:7280/api/v1/indexes -d logs_index_config.json之后就可以用4.2节的代码往logs索引里灌数据了。5. 查询语法迁移从ES DSL到Quickwit5.1 全文检索的写法变化用习惯ES的人一开始看到Quickwit的查询语法会觉得回到了Lucene的时代。它默认支持的是老牌查询串语法比如message:连接超时 AND service:order-api对应到ES的DSL{ query: { bool: { must: [ { match_phrase: { message: 连接超时 } }, { term: { service: order-api } } ] } } }在Quickwit里只需要一行service:order-api AND message:连接超时它还支持字段范围、通配符、模糊查询等功能。如果你要从ES迁过来ES DSL做一层转换后就能直接使用大部分逻辑是等价的。官方提供的REST查询接口如下curl -X POST http://localhost:7280/api/v1/logs/search?queryservice:order-api%20AND%20message:连接超时5.2 聚合查询差异ES的聚合是嵌套JSON结构Quickwit则比较直接地支持一些常用的SQL风格的聚合。比如按service分组统计数量curl -X POST http://localhost:7280/api/v1/logs/search?query*aggservice%3Acount返回的JSON里会包含aggregations结果。虽然Quickwit目前的聚合能力比ES完整的多桶聚合还差一些但日志场景里90%的聚合需求就是count、avg、min、max、histogram这些都已经覆盖。如果你的查询主要是复杂嵌套聚合建议先跑通验证再迁移。5.3 向向量检索场景迁移的建议针对热词里提到的“ES向量检索时间太长”这块我单独说一下。Quickwit的向量检索支持HNSW近似最近邻能够和普通查询条件做复合过滤。定义向量字段的方式如下{ name: vector, type: f64, fast: true, indexed: true, tokenizer: raw, record: basic, stored: true, vector: { dimensions: 128, distance: cosine } }查询时在搜索API里带上向量参数curl -X POST http://localhost:7280/api/v1/indexes/docs/search \ -H Content-Type: application/json \ -d { query: status_code:200, size: 10, search_fields: [vector], vector: [0.12, 0.23, ...] }我测试下来Quickwit在向量过滤的复合场景下查询速度明显比ES稳定。主要原因是对象存储上的分段裁剪和并行扫描让过滤条件先缩小了向量检索范围。如果你的向量集特别大还可以考虑对向量字段单独设置一个quantization量化方式能进一步压搜索延迟但准确率会稍微打折扣。6. 真实生产里那些坑我踩过的都写在这里6.1 别把对象存储的读写延迟想象成零延迟Quickwit查询时需要从对象存储拉取索引文件到本地缓存。对象存储虽然带宽大但首次查询时如果目标文件不在本地会有额外的网络延迟。我踩过的坑是某个冷门索引的查询突然从几百毫秒慢到四五秒查了半天发现是缓存未命中数据被重新从对象存储拉取。解决办法有两个一是保证集群有足够的本地缓存盘二是对核心索引设置缓存预热任务。Quickwit支持cache_prewarm参数在索引热数据时就把热文件下载到本地。如果你查询的模式比较固定强烈建议开启。6.2 分词器选错会带来灾难性后果Quickwit内置好几种分词器default是标准分词适合中英文混合raw表示整个字段作为单独一个词项。日志里的服务名、IP、状态码这些字段千万别用default分词否则搜索order-api会被拆成order和api精确匹配就失效了。我的建议是需要精确匹配的字段一律用tokenizer: raw正文类字段才用default。字段定义一旦建立之后改分词器需要重建索引非常痛苦所以项目初期就要规划好字段类型。6.3 索引模板和清理策略要提前设计Quickwit对时间序列数据提供了比较完善的生命周期管理。可以在索引配置里设置retention让它自动删掉超期的分区文件。这一点实在太方便了ES那边要自己写Curator脚本或者定时任务而且删除分片碎文件容易留下磁盘碎片。我建议在创建第一个索引时就把保留周期写进去比如retention: { period: 90d, grace_period: 1d }这样90天前的日志会自动被清理你不用每天担心磁盘被写满。6.4 从ES迁移数据时的双写和校验如果你现在还在ES上跑着线上业务我的建议是不要直接切流量。先把双写链路搭起来应用同时写ES和Quickwit两边并行跑一到两周。这个阶段的查询仍然走ES但可以拿一批离线查询样本在Quickwit上测试正确性和性能。确认无误后再切换查询流量到Quickwit。双写过程中要注意数据一致性校验。我那个项目用了简单的条数对比每5分钟统计两个系统各自的文档数差值超过0.01%就告警。文本日志丢几条往往因为网络问题很难完全避免但核心业务数据不能有偏差。这个方案虽然粗暴但能快速定位链路问题。6.5 从ES到Quickwit的完整迁移流程整个迁移流程我是这样设计的第一步在Quickwit上创建目标索引定义好字段映射和生命周期策略。第二步调整Java服务写入逻辑批量数据同时发送到ES和Quickwit。第三步跑离线比对任务校验两边结果一致性和查询延迟。第四步把查询路由逐步切到Quickwit先切10%流量再切50%最后全量切换。第五步观察ES侧查询流量降为0后保留ES集群一周再下线。这个流程不复杂但每一步都要有明确的验证节点。尤其是在第三步我建议至少跑满一个正常工作日覆盖业务高峰和低峰的查询特征。7. 我能给出的最终建议Quickwit并不是所有场景都能替代ES。如果你做的是电商商品搜索、站内文档检索这类需要强相关度排序、复杂评分、实时更新文档的业务ES或者专门的搜索产品仍然是更稳妥的选择。但如果你做的是日志检索、事件分析、链路追踪、异常检测、向量匹配Quickwit这种架构优势会非常明显。我实际用下来最舒服的一点是它把“大规模日志检索”这件事从“运维一堆有状态节点”变成了“维护无状态服务对象存储”。你不太需要关心分片恢复、副本同步、节点均衡这些问题扩容就多加几个搜索节点存储不够就扩对象存储桶。这种体验上的差距只有真正在凌晨被ES集群报警吵醒过的人才懂。最后说一个小技巧如果你打算拿Quickwit做生产级日志平台不妨把索引配置和查询模板写成代码用Git管理起来。我最初是直接在控制台里手动建索引、改配置后来发现配置漂移严重某个测试索引被同事改乱了直接影响线上查询。现在每次变更都走Merge Request评审配合自动化测试配置和代码一样有版本线上出问题能快速回滚。这个经验来自ES集群踩过的坑在Quickwit这里依然适用。