- 数据库
- 文档数据库
- 后端
【免费下载链接】couchdb
Seamless multi-primary syncing database with an intuitive HTTP/JSON API, designed for reliability
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。启动链条非常清晰:
- dreyfus.app.src 声明
{mod, {dreyfus_app, []}}与{registered, [dreyfus_index_manager, dreyfus_sup]},即启动时由dreyfus_app回调负责拉起进程,并保证这两个注册进程存在。 - dreyfus_app.erl 的
start/2调用dreyfus_sup:start_link()。 - 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是每个分片上的执行入口,执行顺序为:
check_interactive_mode():若配置couchdb.maintenance_mode为true,直接回复{rexi_EXIT, {maintenance_mode, node()}}并退出,避免维护模式下的日志刷屏;- 打开数据库并计算
{_LastSeq, MinSeq} = calculate_seqs(Db, Stale); dreyfus_index:design_doc_to_index(DDoc, IndexName)生成索引记录;dreyfus_index_manager:get_index(DbName, Index)获取(或创建)索引进程;dreyfus_index:await(Pid, MinSeq)按需触发索引更新;- 调用对应搜索函数并把结果通过
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是索引同步的主体,流程为:
couch_db:count_changes_since(Db, CurSeq)统计自上次同步以来的变更数,加上待处理的 purge 变更数,注册search_indexer类型任务到任务状态(含database、design_document、index、progress、changes_done、total_changes),每 500ms 刷新进度;purge_index/3先处理被 purge 的文档:从 Clouseau 读取get_purge_seq,对已删除的文档调用clouseau_rpc:delete清理 Lucene 索引,并返回需排除的{Id, Rev}列表,避免更新阶段重复写入;- 获取查询服务器进程
get_os_process(Index#index.def_lang),通过proc_prompt(Proc, [<<"add_fun">>, Index#index.def])注入索引函数; couch_db:fold_changes(Db, CurSeq, EnumFun, Acc0, [])从CurSeq起增量遍历变更,对每个文档调用clouseau_rpc:update更新或clouseau_rpc:delete删除(README 中描述的逐文档调用);期间每满 60 秒强制clouseau_rpc:commit一次,防止长时间无提交;- 全部处理完后
clouseau_rpc:commit(IndexPid, NewCurSeq)提交并记录新序号,进程以exit({updated, NewCurSeq})结束,通知 dreyfus_index 唤醒等待者。
整个链路验证了 README 的关键论断:dreyfus 通过#index{current_seq}与 Clouseau 侧的 Lucene 提交序号保持一致,实现增量式同步;而 purge 场景的同步则另有 dreyfus_purge_test.erl 专项覆盖。
关键配置项与运维提示
从源码中可以确认以下直接影响 dreyfus 行为的配置:
| 配置 | 位置/默认值 | 作用 |
|---|---|---|
dreyfus.name | clouseau_rpc.erl,默认clouseau@127.0.0.1 | Clouseau 节点的 Erlang 节点名,dreyfus 通过该原子名发起 RPC;部署时须与实际 Clouseau 节点保持一致 |
couchdb.enable_database_recovery | dreyfus_index_manager.erl,默认false | 数据库被删除时,为true则对索引执行rename(配合恢复),为false则直接cleanup |
couchdb.maintenance_mode | dreyfus_rpc.erl,默认false | 为true时所有分片搜索直接回复maintenance_mode错误并退出 |
fabric.search/fabric.search_permsg | dreyfus_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
相关推荐
CouchDB 全文搜索索引实战指南:设计文档、Lucene 查询语法与 Clouseau 集成
CouchDB 全文搜索索引实战指南:设计文档、Lucene 查询语法与 Clouseau 集成 全文搜索(Search)是 CouchDB 在传统 MapRe
数据库文档数据库后端Obsidian Tracker终极指南:从笔记数据到可视化洞察的完整解决方案
Obsidian Tracker终极指南:从笔记数据到可视化洞察的完整解决方案 你是否曾经在Obsidian中积累了大量的笔记数据,却不知道如何从中提取有价值的
CouchDB Nouveau 全文索引实战指南:从索引定义、分析器到 Lucene 查询与分面检索
CouchDB Nouveau 全文索引实战指南:从索引定义、分析器到 Lucene 查询与分面检索 CouchDB 的 Nouveau 是一个实验性全文搜索索
数据库文档数据库后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考