news 2026/10/23 23:35:55

CouchDB dreyfus 全文搜索模块架构解析:从 Clouseau 索引管理到分片查询全链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CouchDB dreyfus 全文搜索模块架构解析:从 Clouseau 索引管理到分片查询全链路
  • 数据库
  • 文档数据库
  • 后端

【免费下载链接】couchdb

Seamless multi-primary syncing database with an intuitive HTTP/JSON API, designed for reliability

项目地址:https://gitcode.com/gh_mirrors/co/couchdb
点击查看免费下载

dreyfus 是 CouchDB 中负责全文搜索(Full-Text Search)能力的核心 Erlang 应用,它通过管理一组名为 Clouseau 的 Java 节点,将 Lucene 的索引与查询能力无缝接入 CouchDB 的分布式架构。本文以 dreyfus 模块的 README 为骨架,结合仓库源码逐层拆解其应用结构、HTTP 请求生命周期与索引更新机制,帮助你理解 CouchDB 全文搜索从 HTTP 请求到 Lucene 查询、再到跨分片结果合并的完整链路。

dreyfus 是什么:连接 CouchDB 与 Lucene 的桥梁

dreyfus(源码位于 src/dreyfus)本身并不实现搜索算法,它的职责是"管理 Clouseau 节点以交付全文搜索功能"。Clouseau 是基于 Java 的独立搜索服务,内部使用 Lucene 完成真正的索引构建与查询;dreyfus 则运行在 Erlang VM 中,负责:

  • 解析设计文档(Design Document)中的全文索引定义(indexes字段);
  • 为每个索引函数维护一个独立的 Erlang 索引进程,并通过 RPC 与对应的 Clouseau 索引"双胞胎"(twin)保持同步;
  • 将 HTTP 查询请求分发到数据库的各个分片(shard)上并行执行;
  • 汇总各分片返回的top_docs结果,排序、分页(bookmark)后返回给客户端。

从代码结构看,dreyfus 同时依赖mem3(分片元数据)、ioq(IO 队列,用于向 Clouseau 发起 RPC)、couch_epi(插件服务注册)等应用,见 dreyfus.app.src 中的{applications, [kernel, stdlib, couch_log, config, couch_event, mem3, ioq, couch_epi]}。

模块文件组成:一份源码级代码地图

README 以文件为单位勾勒了 dreyfus 的整体结构,下表结合源码逐一说明每个文件的实际职责:

文件职责(结合源码)
dreyfus.app.src应用资源文件,声明回调模块为dreyfus_app,注册进程为dreyfus_index_manager与dreyfus_sup,描述为 "Clouseau index manager"
dreyfus_app.erl应用回调模块,start/2中直接调用dreyfus_sup:start_link()启动顶层监督者
dreyfus_sup.erl顶层监督者,采用one_for_one策略,唯一的子进程是dreyfus_index_manager(permanent、重启间隔 1000ms、强度 10)
dreyfus_index_manager.erl管理多个dreyfus_index进程的 gen_server,维护 ETS 表?BY_SIG与?BY_PID
dreyfus_index.erl每个索引一个进程(设计文档中一个索引函数对应一个索引),封装索引打开、await、search、info、group1/group2 等回调
dreyfus_index_updater.erl索引更新回调:扫描增量变更文档并同步到 Lucene,处理 purge 与 commit
dreyfus_httpd.erl处理 HTTP 请求,导出handle_search_req、handle_info_req、handle_cleanup_req、handle_analyze_req等
dreyfus_fabric.erl 等 fabric 系列集群分片操作的代理函数集合,包括dreyfus_fabric_search、dreyfus_fabric_cleanup、dreyfus_fabric_group1、dreyfus_fabric_group2、dreyfus_fabric_info
dreyfus_rpc.erl在每个分片上执行的代理函数(search/info/disk_size 等),通过 rexi 在分片节点上运行
clouseau_rpc.erl封装对 Clouseau Java 节点的远程过程调用(open_index、search、update、delete、commit、analyze 等)
dreyfus_bookmark.erl书签管理工具,用于取下一页结果
dreyfus_util.erl各类工具函数,如get_shards、get_ring_opts、sort、export、黑名单检查等

从源码结构看,dreyfus 还包含 dreyfus_config.erl、dreyfus_epi.erl 与 dreyfus_plugin_couch_db.erl,分别负责配置读取、插件服务注册以及监听数据库变更事件,README 未单独列出的这些文件进一步完善了模块的功能闭环。

应用启动与监督树:一次极简的 Erlang 应用

dreyfus 是一个标准 OTP application。启动链条非常清晰:

  1. dreyfus.app.src 声明{mod, {dreyfus_app, []}}与{registered, [dreyfus_index_manager, dreyfus_sup]},即启动时由dreyfus_app回调负责拉起进程,并保证这两个注册进程存在。
  2. dreyfus_app.erl 的start/2调用dreyfus_sup:start_link()。
  3. dreyfus_sup.erl 初始化时把子进程列表通过couch_epi:register_service(dreyfus_epi, Children)注册为 epi 服务,子进程即dreyfus_index_manager。

也就是说,整个 dreyfus 应用在运行时只需保证一个dreyfus_index_manager进程存活,其余索引进程全部由它按需动态派生,这与 README 中"dreyfus_index_manager 管理多个 dreyfus_index 进程"的描述完全吻合。

核心数据结构:dreyfus.hrl 中的记录设计

理解 dreyfus 的关键在于 dreyfus.hrl 中定义的核心记录:

  • #index{}:一个索引的本地表示,字段包括current_seq(当前已同步的数据库更新序号,默认 0)、dbname、ddoc_id、analyzer(分析器)、def(索引函数定义)、def_lang(定义语言,默认javascript)、name(索引名)、sig(签名,nil 表示未计算)。
  • #index_query_args{}:查询参数集合,默认值直接反映了 CouchDB Search API 的行为:limit=25、stale=false、sort=relevance、grouping=#grouping{}、drilldown=[],以及高亮相关参数highlight_pre_tag = <<"<em>">>、highlight_post_tag = <<"</em>">>、highlight_number=1、highlight_size=0。
  • #top_docs{}:本地的 top_docs 表示(README 特别注明"不等同于线上传输格式"),字段为update_seq、total_hits、hits、counts、ranges。
  • #hit{}:单个命中结果,含order(排序键)与fields(字段);注释指出该结构必须与 Clouseau 侧 Java 的 case class 保持一致。
  • #sortable{}:带排序信息的命中记录,order为排序键、shard为来源分片、item为命中本身。

#index_query_args是查询参数在分片间传递的核心载体,dreyfus_util.erl 的export/1会对partition、counts、ranges、include_fields、highlight_fields等字段做裁剪(置为 nil/空),只把必要参数序列化到远端执行,从而减小集群间 RPC 的传输开销。

HTTP 请求的完整生命周期(12 步拆解)

README 用一张时序图描述了请求的生命周期,下面结合源码把每一步落到实处。

第 1 步:从 chttpd 进入 dreyfus_httpd

HTTP 请求(GET/POST的_search端点)由 chttpd 分发到 dreyfus_httpd.erl 的handle_search_req/3。该函数先调用verify_search_available()检查搜索是否可用,再解析查询参数。分片(shard)场景下,请求路径形如/db/_design/ddoc/_search/index,路径段数量决定了索引名在path_parts中的位置。

第 2 步:参数解析与校验

parse_index_params(Req, Db)与validate_index_query负责把 URL 参数转换为#index_query_args{}记录。此后根据Grouping#grouping.by是否为nil分流:

  • by为nil:普通搜索,调用 dreyfus_fabric_search:go/4;
  • by非nil:分组查询,先调dreyfus_fabric_group1:go取分组列表,再调dreyfus_fabric_group2:go取各组结果(对应 README 提到的 fabric 函数族)。

第 3 步:dreyfus_fabric 提交分片任务

dreyfus_fabric_search.erl 的核心动作正是 README 摘录的两行代码:

Shards = dreyfus_util:get_shards(DbName, QueryArgs), Workers = fabric_util:submit_jobs( Shards, dreyfus_rpc, search, [DDoc, IndexName, dreyfus_util:export(QueryArgs)] )
  • get_shards/2(见 dreyfus_util.erl)根据是否指定partition以及stale=ok/stable=true等条件,选择mem3:ushards(用于 stale/stable 查询的"统一分片")或mem3:shards获取分片集合;
  • 任务提交后,fabric 进程通过rexi_utils:recv(Workers, #shard.ref, fun handle_message/3, State, ...)阻塞等待各分片结果,超时上限为fabric_util:timeout("search", "infinity"),单条消息超时search_permsg默认 3600000ms(1 小时)。

第 4 步:dreyfus_rpc 在每个分片并行执行

dreyfus_rpc.erl 的call/5是每个分片上的执行入口,执行顺序为:

  1. check_interactive_mode():若配置couchdb.maintenance_mode为true,直接回复{rexi_EXIT, {maintenance_mode, node()}}并退出,避免维护模式下的日志刷屏;
  2. 打开数据库并计算{_LastSeq, MinSeq} = calculate_seqs(Db, Stale);
  3. dreyfus_index:design_doc_to_index(DDoc, IndexName)生成索引记录;
  4. dreyfus_index_manager:get_index(DbName, Index)获取(或创建)索引进程;
  5. dreyfus_index:await(Pid, MinSeq)按需触发索引更新;
  6. 调用对应搜索函数并把结果通过rexi:reply(Result)返回给发起方 fabric 进程。

第 5~7 步:dreyfus_index → clouseau_rpc → Lucene

dreyfus_index:search_int/2(见 dreyfus_index.erl)把#index_query_args{}通过args_to_proplist转成属性列表(query、limit、refresh、after、sort、counts、ranges、drilldown、高亮参数等),随后调用clouseau_rpc:search(Pid, Props)。而 clouseau_rpc.erl 的所有 RPC 最终都汇入:

rpc(Ref, Msg) -> ioq:call_search(Ref, Msg, erlang:get(io_priority)).

即通过ioq(IO 队列)按当前进程的io_priority优先级向 Clouseau 发起调用,真正在 Clouseau 节点上用 Lucene 完成检索并返回top_docs。返回后clouseau_rpc:search/2会把响应解析成#top_docs{}记录(update_seq、total_hits、hits、counts、ranges)。

第 8~11 步:结果回传与分片合并

top_docs依次经dreyfus_index、dreyfus_rpc回到各分片进程,每个分片以rexi:reply(Result)异步回复发起方。fabric 侧的handle_message({ok, #top_docs{} = NewTopDocs}, Shard, State)(dreyfus_fabric_search.erl)对每个分片结果执行:

Sortable = make_sortable(Shard, NewTopDocs), MergedTopDocs = merge_top_docs(TopDocs, Sortable, Limit, Sort)

merge_top_docs/4(dreyfus_fabric_search.erl)做的事情包括:累加各分片的total_hits;将各分片 hits 拼接后按Sort排序并截取Limit条;对counts、ranges两个 facet 通过merge_facets递归合并(同键累加计数)。每收到一个分片结果就更新一次合并状态,直到fabric_dict:any(nil, C2)为false(所有分片都响应)才停止。该合并逻辑还附带了完整的 EUnit 测试(同文件-ifdef(TEST)段的merge_facets_test),覆盖单层/多层、单键/多键的 facet 合并正确性。

第 12 步:dreyfus_httpd 格式化返回

fabric 返回{ok, Bookmark, TotalHits, Hits, Counts, Ranges}后,dreyfus_httpd.erl 通过hits_to_json将命中转成 JSON、用dreyfus_bookmark:pack打包书签,最后以send_json(Req, 200, {...})返回total_rows、bookmark、rows,以及可选的counts、ranges字段。

索引机制:搜索请求如何触发索引更新

README 的第二张时序图解释了"搜索请求驱动的索引更新",这是 dreyfus 保证最终一致性的核心机制。

第 1 步:计算 MinSeq

dreyfus_rpc.erl 的calculate_seqs/2:

calculate_seqs(Db, Stale) -> LastSeq = couch_db:get_update_seq(Db), if Stale == ok orelse Stale == update_after -> {LastSeq, 0}; true -> {LastSeq, LastSeq} end.
  • 非 stale 查询:MinSeq = LastSeq,即查询前必须把索引至少推进到数据库当前更新序号,保证结果反映最新变更;
  • stale=ok或stale=update_after:MinSeq = 0,含义是"等待索引至少到 update_seq 0"——空索引也满足该条件,因此无需触发更新即可直接返回结果。这也是 stale 查询吞吐更高的根本原因。

第 2 步:设计文档到索引记录

dreyfus_index:design_doc_to_index/2(dreyfus_index.erl)解析设计文档的indexes字段:取language(默认javascript)、索引的analyzer(默认standard)与index(索引函数,缺失时报invalid_design_doc错误)。签名计算方式为:

Sig = couch_util:to_hex(couch_hash:md5_hash(?term_to_bin({Analyzer, Def})))

即对{分析器, 索引函数定义}求 MD5 哈希。如 README 所述,Sig用于判断索引描述是否变化:若设计文档中的索引函数或分析器改变,Sig随之改变,索引将被判定为需要重建。

第 3 步:get_index 与索引进程的懒创建

dreyfus_index_manager:get_index(DbName, Index)(dreyfus_index_manager.erl)走 gen_server 的handle_call({get_index, DbName, #index{sig = Sig} = Index}, ...),逻辑如下:

  • 在ets:lookup(?BY_SIG, {DbName, Sig})中命中已有 Pid → 直接返回{ok, ExistingPid};
  • 未命中 →spawn_link(fun() -> new_index(DbName, Index) end)派生创建进程,把调用方From记入?BY_SIG的等待列表,gen_server 返回{noreply, State}不阻塞自己;
  • 后续再来的同一索引调用会追加到等待列表([{_, WaitList}] when is_list(WaitList)分支);
  • new_index调用dreyfus_index:start_link成功则发{open_ok, DbName, Sig, NewPid},失败则发{open_error, ...};
  • handle_call({open_ok, ...})向等待列表中的所有调用方gen_server:reply(From, {ok, NewPid}),并调用add_to_ets(NewPid, DbName, Sig)更新?BY_SIG与?BY_PID两张 ETS 表。

此外dreyfus_index_manager通过handle_db_event/3监听数据库事件(dreyfus_index_manager.erl):库创建时 cast{cleanup, DbName}清理 Clouseau 端残留;库删除时依据配置couchdb.enable_database_recovery决定 cast{rename, DbName}(启用恢复)还是{cleanup, DbName}。

第 4 步:await 与增量更新

dreyfus_index:await(Pid, MinSeq)(dreyfus_index.erl)判断RequestSeq > Seq时才会触发更新:spawn 一个 updater 进程执行dreyfus_index_updater:update(IndexPid, Index),并把调用方放入waiting_list;若当前RequestSeq =< Seq则直接回复{ok, IndexPid, Seq}。update 完成后handle_info({'EXIT', FromPid, {updated, NewSeq}}, ...)会逐个回复等待列表中的调用方。这里还有一个细节:updater 启动前会经dreyfus_util:in_black_list/3检查索引黑名单,命中则打日志并跳过更新(对应测试文件 dreyfus_blacklist_await_test.erl 与 dreyfus_blacklist_request_test.erl)。

索引更新器详解:从增量扫描到 Lucene 提交

dreyfus_index_updater.erl 的update/2是索引同步的主体,流程为:

  1. couch_db:count_changes_since(Db, CurSeq)统计自上次同步以来的变更数,加上待处理的 purge 变更数,注册search_indexer类型任务到任务状态(含database、design_document、index、progress、changes_done、total_changes),每 500ms 刷新进度;
  2. purge_index/3先处理被 purge 的文档:从 Clouseau 读取get_purge_seq,对已删除的文档调用clouseau_rpc:delete清理 Lucene 索引,并返回需排除的{Id, Rev}列表,避免更新阶段重复写入;
  3. 获取查询服务器进程get_os_process(Index#index.def_lang),通过proc_prompt(Proc, [<<"add_fun">>, Index#index.def])注入索引函数;
  4. couch_db:fold_changes(Db, CurSeq, EnumFun, Acc0, [])从CurSeq起增量遍历变更,对每个文档调用clouseau_rpc:update更新或clouseau_rpc:delete删除(README 中描述的逐文档调用);期间每满 60 秒强制clouseau_rpc:commit一次,防止长时间无提交;
  5. 全部处理完后clouseau_rpc:commit(IndexPid, NewCurSeq)提交并记录新序号,进程以exit({updated, NewCurSeq})结束,通知 dreyfus_index 唤醒等待者。

整个链路验证了 README 的关键论断:dreyfus 通过#index{current_seq}与 Clouseau 侧的 Lucene 提交序号保持一致,实现增量式同步;而 purge 场景的同步则另有 dreyfus_purge_test.erl 专项覆盖。

关键配置项与运维提示

从源码中可以确认以下直接影响 dreyfus 行为的配置:

配置位置/默认值作用
dreyfus.nameclouseau_rpc.erl,默认clouseau@127.0.0.1Clouseau 节点的 Erlang 节点名,dreyfus 通过该原子名发起 RPC;部署时须与实际 Clouseau 节点保持一致
couchdb.enable_database_recoverydreyfus_index_manager.erl,默认false数据库被删除时,为true则对索引执行rename(配合恢复),为false则直接cleanup
couchdb.maintenance_modedreyfus_rpc.erl,默认false为true时所有分片搜索直接回复maintenance_mode错误并退出
fabric.search/fabric.search_permsgdreyfus_fabric_search.erl,搜索总超时默认infinity,单消息超时默认 3600000ms控制集群搜索的等待超时

其中 Clouseau 节点名配置是部署全文搜索时最容易出错的点:dreyfus 与 Clouseau 之间是 Erlang 分布式节点互联(erlang:nodes(hidden)中查找,必要时net_adm:ping探测),节点名、cookie 与网络可达性缺一不可。

测试覆盖:行为即契约

dreyfus 的 EUnit 测试位于 src/dreyfus/test/eunit,与 README 描述的行为一一对应:

  • dreyfus_blacklist_await_test.erl / dreyfus_blacklist_request_test.erl:验证被列入黑名单的索引不触发更新、不响应查询;
  • dreyfus_config_test.erl:验证配置读取逻辑;
  • dreyfus_purge_test.erl:验证 purge 后索引的正确清理;
  • dreyfus_test_util.erl:公共测试工具(启动测试 Clouseau、构造设计文档等)。

小结

dreyfus 的架构可以概括为一条清晰的职责链:dreyfus_httpd 收请求 → dreyfus_fabric 分片分发与结果合并 → dreyfus_rpc 分片执行 → dreyfus_index 索引进程 → clouseau_rpc 桥接 → Clouseau/Lucene 完成检索;而索引同步则由calculate_seqs计算的最小序号驱动,经dreyfus_index_updater增量同步到 Lucene。理解这条链路后,无论是排查"为什么 stale 查询不返回最新结果"(MinSeq=0的语义),还是"为什么改了索引函数后索引要重建"(Sig的 MD5 判定),都能迅速在 src/dreyfus/src 中找到对应实现。

  • 数据库
  • 文档数据库
  • 后端

【免费下载链接】couchdb

Seamless multi-primary syncing database with an intuitive HTTP/JSON API, designed for reliability

项目地址:https://gitcode.com/gh_mirrors/co/couchdb
点击查看免费下载

相关推荐

上一篇:misakaX深度解析:基于TrollRestore漏洞的iOS系统定制化技术实现与应用指南
下一篇:终极免费AI瞄准助手Aimmy:5分钟解决你的游戏瞄准难题

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!