From 5b83f6419303ffdb31592492adbb2f2b2a3e635d Mon Sep 17 00:00:00 2001 From: chuan Date: Mon, 10 Aug 2026 02:16:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E7=99=BE=E4=B8=87?= =?UTF-8?q?=E7=BA=A7=E6=80=A7=E8=83=BD=E5=9F=BA=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + BENCHMARKS.md | 72 ++++ Cargo.lock | 1 + README.md | 2 + TODOS.md | 12 +- dht-search/Cargo.toml | 10 +- dht-search/README.md | 41 ++ dht-search/src/bin/dht-benchmark.rs | 616 ++++++++++++++++++++++++++++ dht-search/src/search/indexer.rs | 19 + 9 files changed, 768 insertions(+), 6 deletions(-) create mode 100644 BENCHMARKS.md create mode 100644 dht-search/src/bin/dht-benchmark.rs diff --git a/.gitignore b/.gitignore index 84fd725..12e6412 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /.remote-data/ /data/ /data-filter-test/ +/benchmark-data/ /dht-search.toml **/*.rs.bk diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..8b4f9d1 --- /dev/null +++ b/BENCHMARKS.md @@ -0,0 +1,72 @@ +# DHT Search 性能基线 + +本文档保存可重复的规模基准条件目标和已验证结果 + +完整运行方式和参数说明见 [`dht-search/README.md`](dht-search/README.md) + +## 基准条件 + +| 项目 | 值 | +|---|---| +| 日期 | 2026-08-10 | +| 平台 | Windows x86_64 | +| 逻辑处理器 | 32 | +| 构建模式 | release | +| 内容重复比例 | 每十条记录包含一条相同内容的不同 infohash | +| 索引批量 | 每次 1000 个内容文档 | +| 查询预热 | 每类 5 次 | +| 正式查询 | 10 万和 100 万规模每类 50 次 | +| 数据目录 | 独立生成并在报告完成后清理 | + +该基准使用确定性合成名称文件路径大小时间和内容变体 适合比较版本变化但不能替代真实 DHT 数据分布和长期运行测试 + +## 验收目标 + +| 指标 | 百万级目标 | +|---|---:| +| 普通搜索过滤排序 P95 | 不超过 50 ms | +| 精确 infohash P95 | 不超过 10 ms | +| 大命中集合正则 P95 | 不超过 1 s | +| 全量索引吞吐 | 不低于 2000 文档/秒 | +| 总磁盘占用 | 不超过 6 GiB/百万条 | +| 基准进程峰值内存 | 不超过 2 GiB | + +## 规模结果 + +| 记录数 | 内容文档 | RocksDB 写入 | Tantivy 索引 | 总磁盘 | 峰值内存 | +|---:|---:|---:|---:|---:|---:| +| 10,000 | 9,000 | 149,584 条/秒 | 3,794 文档/秒 | 52.42 MiB | 86.21 MiB | +| 100,000 | 90,000 | 118,833 条/秒 | 3,261 文档/秒 | 452.65 MiB | 360.04 MiB | +| 1,000,000 | 900,000 | 102,115 条/秒 | 2,520 文档/秒 | 4.36 GiB | 1.70 GiB | + +百万级 RocksDB 占用 442.38 MiB 平均每条 463.9 字节 + +百万级 Tantivy 占用 3.93 GiB 平均每个内容文档 4683.1 字节 + +## 百万级查询结果 + +| 查询类型 | 命中数 | P50 | P95 | P99 | +|---|---:|---:|---:|---:| +| 中文关键词 | 250,000 | 2.541 ms | 2.571 ms | 2.667 ms | +| 英文关键词 | 200,000 | 5.681 ms | 5.793 ms | 5.804 ms | +| 文件路径片段 | 10,000 | 0.107 ms | 0.110 ms | 0.113 ms | +| 精确 infohash | 1 | 0.007 ms | 0.008 ms | 0.008 ms | +| 有限状态正则 | 200,000 | 648.231 ms | 656.086 ms | 764.102 ms | +| 最近收录排序 | 900,000 | 3.322 ms | 3.346 ms | 3.358 ms | +| 大小扩展名过滤 | 794,074 | 6.391 ms | 6.451 ms | 6.526 ms | + +普通全文搜索过滤排序和精确 infohash 均明显低于目标 + +大命中集合正则达到一秒内目标但随文档数近似线性增长 是继续扩大数据规模前最值得优化的查询路径 + +## 基准中发现并修复的问题 + +- 修复四十位 infohash 被全文查询拆成二十字符窗口导致精确搜索结果为空的问题 +- 磁盘统计改为关闭 RocksDB 和 Tantivy 后执行避免漏掉尚未刷盘的数据 +- 基准索引增加 Windows 临时文件占用的指数退避并记录重试次数和累计等待时间 + +## 当前结论 + +百万级规模下 RocksDB 写入 Tantivy 索引普通查询磁盘和峰值内存均达到当前目标 + +下一阶段使用真实采集数据进行一小时和二十四小时持续运行 验证内存队列磁盘增长和网络稳定性 diff --git a/Cargo.lock b/Cargo.lock index d2ae8c0..4f4a416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -672,6 +672,7 @@ dependencies = [ "tracing", "tracing-subscriber", "unicode-normalization", + "windows-sys 0.61.2", ] [[package]] diff --git a/README.md b/README.md index 69d60a5..1d81b88 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ opencodes/ 不参与构建的参考项目 当前实施阶段和后续计划见 [`TODOS.md`](TODOS.md) +百万级性能基线和验收目标见 [`BENCHMARKS.md`](BENCHMARKS.md) + 应用配置模板见 [`dht-search.example.toml`](dht-search.example.toml) 应用构建运行和 API 文档见 [`dht-search/README.md`](dht-search/README.md) diff --git a/TODOS.md b/TODOS.md index b6f5b4c..7606486 100644 --- a/TODOS.md +++ b/TODOS.md @@ -150,7 +150,7 @@ - [x] 新写入记录在目标延迟内可搜索 - [x] 搜索索引删除后可以从 RocksDB 完整重建 - [x] 索引过程中异常退出不会永久丢失文档 -- [ ] 百万级测试数据常用查询延迟达到约定目标 +- [x] 百万级测试数据常用查询延迟达到 `BENCHMARKS.md` 约定目标 ## 阶段四 HTTP 搜索服务 @@ -260,8 +260,10 @@ - [x] 验证启用按需可用性功能后保守预算运行三分钟并安全停止 - [ ] 根据真实验证数据校准热度权重等级边界和失败退避时间 - [ ] 根据公网设备长期实测设计超时率自动降速 -- [ ] 建立采集存储索引和查询基准测试 -- [ ] 记录每条元数据和每个索引文档的平均磁盘占用 +- [x] 建立可重复的采集存储索引和查询规模基准工具 +- [x] 完成一万十万和一百万条 release 基线并记录查询 P50 P95 P99 +- [x] 记录每条元数据和每个索引文档的平均磁盘占用 +- [x] 记录百万级基准进程峰值内存和索引总吞吐 - [ ] 记录 RocksDB block cache memtable 和 compaction 指标 - [ ] 记录 Tantivy IndexWriter 内存和 commit 延迟 - [ ] 根据实测调整批量大小队列容量和并发 @@ -309,6 +311,6 @@ ## 当前下一步 -建立可重复的百万级数据基准并测量查询延迟索引速度磁盘占用和内存使用 +使用真实采集数据进行一小时持续运行并记录内存队列磁盘网络和验证指标 -随后进行更长时间的真实采集与资源稳定性测试 +随后扩展为二十四小时持续运行并根据数据决定资源参数和正则查询优化优先级 diff --git a/dht-search/Cargo.toml b/dht-search/Cargo.toml index 328c465..9da8c0d 100644 --- a/dht-search/Cargo.toml +++ b/dht-search/Cargo.toml @@ -22,6 +22,7 @@ hex = "0.4" rocksdb = { version = "0.24.0", default-features = false, features = ["bindgen-runtime", "lz4"], optional = true } rmp-serde = "1.3" serde.workspace = true +serde_json = "1.0" tantivy = "0.26.1" thiserror.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } @@ -33,6 +34,13 @@ tower-http = { version = "0.6", features = ["fs"] } unicode-normalization = "0.1" [dev-dependencies] -serde_json = "1.0" tempfile = "3.27" tower = { version = "0.5", features = ["util"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_System_ProcessStatus", "Win32_System_Threading"] } + +[[bin]] +name = "dht-benchmark" +path = "src/bin/dht-benchmark.rs" +required-features = ["rocksdb-storage"] diff --git a/dht-search/README.md b/dht-search/README.md index ae20c84..4420eb3 100644 --- a/dht-search/README.md +++ b/dht-search/README.md @@ -180,3 +180,44 @@ RocksDB 是权威数据源而 Tantivy 是可重建索引 项目当前处于开发阶段 持久化结构变化时直接清理测试数据重新采集 不维护旧测试数据库兼容层 正常退出会先停止 DHT 再排空持久化队列提交剩余索引最后关闭 HTTP 服务 + +## 规模基准 + +`dht-benchmark` 使用确定性数据调用真实 RocksDB 写入内容聚合待索引状态 Tantivy 索引和搜索接口 + +默认每十条记录生成一个相同内容的不同 infohash 用于同时覆盖精确去重和内容折叠场景 + +性能测量必须使用 release 构建并从小规模逐步增加 + +```powershell +$env:LIBCLANG_PATH = "$PWD\.tools\libclang\clang\native" + +cargo run --release -p dht-search --bin dht-benchmark -- ` + --records 10000 ` + --cleanup + +cargo run --release -p dht-search --bin dht-benchmark -- ` + --records 100000 ` + --query-iterations 100 ` + --cleanup + +cargo run --release -p dht-search --bin dht-benchmark -- ` + --records 1000000 ` + --query-iterations 100 +``` + +| 参数 | 默认值 | 作用 | +|---|---:|---| +| `--records` | `10000` | 生成记录数量 上限一千万 | +| `--generation-batch-size` | `1000` | 单批生成并暂存在内存的记录数量 | +| `--index-batch-size` | `1000` | 每次 Tantivy 提交的内容文档数量 | +| `--index-max-retries` | `20` | Windows 临时 IO 错误的最大连续重试次数 | +| `--query-iterations` | `50` | 每类查询正式采样次数 | +| `--query-warmup` | `5` | 每类查询预热次数 | +| `--duplicate-every` | `10` | 每多少条创建一个相同内容的不同 infohash 零表示禁用 | +| `--output-dir` | `benchmark-data` | 独立运行数据和 JSON 报告根目录 | +| `--cleanup` | 不启用 | 报告写入后删除本次 RocksDB 和 Tantivy 数据 | + +终端和 JSON 报告包含写入吞吐索引吞吐查询平均值与 P50/P95/P99 RocksDB 与 Tantivy 字节占用每条平均占用和进程峰值内存 + +`benchmark-data/runs` 保存每次未清理的数据库和索引 `benchmark-data/reports` 始终保留 JSON 报告 两者均不进入 Git diff --git a/dht-search/src/bin/dht-benchmark.rs b/dht-search/src/bin/dht-benchmark.rs new file mode 100644 index 0000000..21b4581 --- /dev/null +++ b/dht-search/src/bin/dht-benchmark.rs @@ -0,0 +1,616 @@ +// 负责生成可重复的大规模种子数据并测量持久化索引查询磁盘和内存表现 + +use std::{ + error::Error, + fs, + path::{Path, PathBuf}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use clap::Parser; +use dht_crawler::{FileInfo, TorrentInfo}; +use dht_search::{ + domain::{MetadataLimits, TorrentRecord}, + search::{SearchEngine, SearchOptions, SearchSort}, + storage::{RocksTorrentRepository, TorrentRepository}, +}; +use serde::Serialize; + +const MAX_RECORDS: usize = 10_000_000; +const BASE_TIMESTAMP: u64 = 1_700_000_000; + +#[derive(Debug, Parser)] +#[command(name = "storage-search", about = "RocksDB 和 Tantivy 端到端规模基准")] +struct Args { + #[arg(long, default_value_t = 10_000)] + records: usize, + #[arg(long, default_value_t = 1_000)] + generation_batch_size: usize, + #[arg(long, default_value_t = 1_000)] + index_batch_size: usize, + #[arg(long, default_value_t = 20)] + index_max_retries: usize, + #[arg(long, default_value_t = 50)] + query_iterations: usize, + #[arg(long, default_value_t = 5)] + query_warmup: usize, + #[arg(long, default_value_t = 10)] + duplicate_every: usize, + #[arg(long, default_value = "benchmark-data")] + output_dir: PathBuf, + #[arg(long)] + cleanup: bool, +} + +#[derive(Debug, Serialize)] +struct BenchmarkReport { + generated_at: u64, + build_profile: String, + target: String, + logical_cpus: usize, + records: usize, + indexed_documents: u64, + duplicate_every: usize, + generation_seconds: f64, + rocksdb_write_seconds: f64, + rocksdb_records_per_second: f64, + index_seconds: f64, + index_documents_per_second: f64, + index_transient_retries: usize, + index_retry_wait_seconds: f64, + rocksdb_bytes: u64, + tantivy_bytes: u64, + total_bytes: u64, + rocksdb_bytes_per_record: f64, + tantivy_bytes_per_document: f64, + peak_memory_bytes: Option, + queries: Vec, +} + +#[derive(Debug, Serialize)] +struct QueryReport { + name: String, + iterations: usize, + result_count: usize, + mean_micros: u64, + p50_micros: u64, + p95_micros: u64, + p99_micros: u64, +} + +struct QueryCase { + name: &'static str, + options: SearchOptions, +} + +fn main() -> Result<(), Box> { + let args = Args::parse(); + validate_args(&args)?; + let generated_at = unix_timestamp(); + let build_profile = if cfg!(debug_assertions) { + "debug" + } else { + "release" + }; + let run_name = format!("records-{}-{generated_at}", args.records); + let runs_dir = args.output_dir.join("runs"); + let reports_dir = args.output_dir.join("reports"); + let run_dir = runs_dir.join(&run_name); + if run_dir.exists() { + return Err(format!("基准目录已经存在 {}", run_dir.display()).into()); + } + fs::create_dir_all(&run_dir)?; + fs::create_dir_all(&reports_dir)?; + + println!("DHT Search 规模基准"); + println!("构建模式: {build_profile}"); + if cfg!(debug_assertions) { + println!("警告: debug 模式仅用于流程验证 性能结论必须使用 --release"); + } + println!("数据量: {}", format_integer(args.records as u64)); + println!("运行目录: {}", run_dir.display()); + + let rocksdb_dir = run_dir.join("rocksdb"); + let tantivy_dir = run_dir.join("tantivy"); + let repository = RocksTorrentRepository::open(&rocksdb_dir)?; + + let mut generated_duration = Duration::ZERO; + let mut write_duration = Duration::ZERO; + let progress_step = (args.records / 20).max(1); + for batch_start in (0..args.records).step_by(args.generation_batch_size) { + let batch_end = (batch_start + args.generation_batch_size).min(args.records); + let generation_started = Instant::now(); + let records: Vec<_> = (batch_start..batch_end) + .map(|index| generate_record(index, args.duplicate_every)) + .collect::>()?; + generated_duration += generation_started.elapsed(); + + let write_started = Instant::now(); + for record in records { + repository.upsert(record)?; + } + write_duration += write_started.elapsed(); + if batch_end == args.records || batch_end / progress_step != batch_start / progress_step { + eprintln!( + "RocksDB 写入进度: {:>3}% ({}/{})", + batch_end.saturating_mul(100) / args.records, + format_integer(batch_end as u64), + format_integer(args.records as u64) + ); + } + } + + let search = SearchEngine::open(&tantivy_dir)?; + let index_started = Instant::now(); + let mut indexed_documents = 0_usize; + let mut index_transient_retries = 0_usize; + let mut index_retry_wait = Duration::ZERO; + let expected_documents = expected_document_count(args.records, args.duplicate_every); + let index_progress_step = (expected_documents / 20).max(1); + let mut next_index_progress = index_progress_step; + loop { + let mut consecutive_retries = 0_usize; + let indexed = loop { + match search.index_pending( + &repository, + args.index_batch_size, + BASE_TIMESTAMP.saturating_add(args.records as u64), + ) { + Ok(indexed) => break indexed, + Err(error) if error.is_retryable_io() => { + consecutive_retries = consecutive_retries.saturating_add(1); + index_transient_retries = index_transient_retries.saturating_add(1); + if consecutive_retries > args.index_max_retries { + return Err(format!( + "Tantivy 临时 IO 错误连续重试超过 {} 次: {error}", + args.index_max_retries + ) + .into()); + } + let delay = index_retry_delay(consecutive_retries); + index_retry_wait += delay; + eprintln!( + "Tantivy 临时 IO 错误 第 {consecutive_retries} 次重试 等待 {} ms: {error}", + delay.as_millis() + ); + std::thread::sleep(delay); + } + Err(error) => return Err(Box::new(error)), + } + }; + if indexed == 0 { + break; + } + indexed_documents = indexed_documents.saturating_add(indexed); + if indexed_documents >= next_index_progress || indexed_documents >= expected_documents { + eprintln!( + "Tantivy 索引进度: {:>3}% ({} 个内容文档)", + indexed_documents.saturating_mul(100) / expected_documents.max(1), + format_integer(indexed_documents as u64) + ); + next_index_progress = next_index_progress.saturating_add(index_progress_step); + } + } + let index_duration = index_started.elapsed(); + + let query_cases = query_cases(args.records, args.duplicate_every)?; + let mut query_reports = Vec::with_capacity(query_cases.len()); + for case in query_cases { + let report = benchmark_query(&search, case, args.query_warmup, args.query_iterations)?; + print_query(&report); + query_reports.push(report); + } + + let indexed_documents = search.num_docs(); + let peak_memory_bytes = peak_memory_bytes(); + drop(search); + drop(repository); + let rocksdb_bytes = directory_size(&rocksdb_dir)?; + let tantivy_bytes = directory_size(&tantivy_dir)?; + let report = BenchmarkReport { + generated_at, + build_profile: build_profile.to_owned(), + target: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH), + logical_cpus: std::thread::available_parallelism().map_or(1, usize::from), + records: args.records, + indexed_documents, + duplicate_every: args.duplicate_every, + generation_seconds: generated_duration.as_secs_f64(), + rocksdb_write_seconds: write_duration.as_secs_f64(), + rocksdb_records_per_second: rate(args.records as u64, write_duration), + index_seconds: index_duration.as_secs_f64(), + index_documents_per_second: rate(indexed_documents, index_duration), + index_transient_retries, + index_retry_wait_seconds: index_retry_wait.as_secs_f64(), + rocksdb_bytes, + tantivy_bytes, + total_bytes: rocksdb_bytes.saturating_add(tantivy_bytes), + rocksdb_bytes_per_record: ratio(rocksdb_bytes, args.records as u64), + tantivy_bytes_per_document: ratio(tantivy_bytes, indexed_documents), + peak_memory_bytes, + queries: query_reports, + }; + + print_summary(&report); + let report_path = reports_dir.join(format!("{run_name}.json")); + fs::write(&report_path, serde_json::to_vec_pretty(&report)?)?; + println!("报告: {}", report_path.display()); + + if args.cleanup { + fs::remove_dir_all(&run_dir)?; + println!("已清理本次基准数据: {}", run_dir.display()); + } else { + println!("基准数据已保留 使用 --cleanup 可在完成后自动删除"); + } + Ok(()) +} + +fn validate_args(args: &Args) -> Result<(), Box> { + if !(100..=MAX_RECORDS).contains(&args.records) { + return Err(format!("records 必须在 100 到 {MAX_RECORDS} 之间").into()); + } + if args.generation_batch_size == 0 + || args.index_batch_size == 0 + || args.index_max_retries == 0 + || args.query_iterations == 0 + { + return Err("批量大小和查询次数必须大于零".into()); + } + if args.duplicate_every == 1 { + return Err("duplicate-every 必须是零或至少为二".into()); + } + Ok(()) +} + +fn generate_record(index: usize, duplicate_every: usize) -> Result> { + let content_id = content_id(index, duplicate_every); + let file_count = 1 + content_id % 4; + let mut files = Vec::with_capacity(file_count); + let main_size = 64 * 1024 * 1024 + (content_id as u64 % 8_192) * 1_048_576; + files.push(FileInfo { + path: format!("media/category_{}/item_{content_id}.mkv", content_id % 100), + size: main_size, + }); + for part in 1..file_count { + files.push(FileInfo { + path: format!("docs/item_{content_id}/part_{part}.txt"), + size: 1_024 + (content_id as u64 + part as u64) % 65_536, + }); + } + let total_size = files.iter().try_fold(0_u64, |total, file| { + total + .checked_add(file.size) + .ok_or("生成数据的文件总大小溢出") + })?; + let name = match content_id % 4 { + 0 => format!("流浪地球 第{content_id}集 1080p"), + 1 => format!("Ubuntu Linux Desktop Build {content_id}"), + 2 => format!("Nature Documentary 4K Episode {content_id}"), + _ => format!("Open Source Archive Collection {content_id}"), + }; + let digest = blake3::hash(&(index as u64).to_be_bytes()); + let info_hash = hex::encode(&digest.as_bytes()[..20]); + TorrentRecord::try_from_with_limits( + TorrentInfo { + info_hash, + magnet_link: String::new(), + name, + total_size, + files, + piece_length: 16_384, + peers: Vec::new(), + timestamp: BASE_TIMESTAMP.saturating_add(index as u64), + }, + MetadataLimits::default(), + ) + .map_err(Into::into) +} + +fn content_id(index: usize, duplicate_every: usize) -> usize { + if duplicate_every >= 2 && (index + 1).is_multiple_of(duplicate_every) { + index.saturating_sub(1) + } else { + index + } +} + +fn expected_document_count(records: usize, duplicate_every: usize) -> usize { + if duplicate_every >= 2 { + records.saturating_sub(records / duplicate_every) + } else { + records + } +} + +fn index_retry_delay(retry: usize) -> Duration { + let exponent = retry.saturating_sub(1).min(5) as u32; + Duration::from_millis(250_u64.saturating_mul(2_u64.pow(exponent))).min(Duration::from_secs(10)) +} + +fn query_cases(records: usize, duplicate_every: usize) -> Result, Box> { + let exact_index = records.saturating_sub(1).min(42); + let exact_hash = generate_record(exact_index, duplicate_every)? + .info_hash + .to_string(); + Ok(vec![ + QueryCase { + name: "中文关键词", + options: SearchOptions { + query: "流浪地球".into(), + limit: 20, + ..SearchOptions::default() + }, + }, + QueryCase { + name: "英文关键词", + options: SearchOptions { + query: "ubuntu desktop".into(), + limit: 20, + ..SearchOptions::default() + }, + }, + QueryCase { + name: "文件路径片段", + options: SearchOptions { + query: "item_42".into(), + limit: 20, + ..SearchOptions::default() + }, + }, + QueryCase { + name: "精确 infohash", + options: SearchOptions { + query: exact_hash, + limit: 20, + ..SearchOptions::default() + }, + }, + QueryCase { + name: "有限状态正则", + options: SearchOptions { + query: "ubuntu.*desktop".into(), + regex: true, + limit: 20, + ..SearchOptions::default() + }, + }, + QueryCase { + name: "最近收录排序", + options: SearchOptions { + limit: 20, + sort: Some(SearchSort::Latest), + ..SearchOptions::default() + }, + }, + QueryCase { + name: "大小扩展名过滤", + options: SearchOptions { + min_size: Some(1024 * 1024 * 1024), + extension: Some("mkv".into()), + limit: 20, + sort: Some(SearchSort::SizeDesc), + ..SearchOptions::default() + }, + }, + ]) +} + +fn benchmark_query( + search: &SearchEngine, + case: QueryCase, + warmup: usize, + iterations: usize, +) -> Result> { + for _ in 0..warmup { + search.search_with(case.options.clone())?; + } + let mut samples = Vec::with_capacity(iterations); + let mut result_count = 0; + for _ in 0..iterations { + let started = Instant::now(); + let page = search.search_with(case.options.clone())?; + samples.push(duration_micros(started.elapsed())); + result_count = page.total; + } + samples.sort_unstable(); + let mean = samples.iter().copied().sum::() / samples.len() as u64; + Ok(QueryReport { + name: case.name.to_owned(), + iterations, + result_count, + mean_micros: mean, + p50_micros: percentile(&samples, 50), + p95_micros: percentile(&samples, 95), + p99_micros: percentile(&samples, 99), + }) +} + +fn percentile(sorted: &[u64], percentile: usize) -> u64 { + let rank = sorted + .len() + .saturating_mul(percentile) + .div_ceil(100) + .saturating_sub(1) + .min(sorted.len().saturating_sub(1)); + sorted[rank] +} + +fn duration_micros(duration: Duration) -> u64 { + duration.as_micros().min(u128::from(u64::MAX)) as u64 +} + +fn rate(items: u64, duration: Duration) -> f64 { + if duration.is_zero() { + 0.0 + } else { + items as f64 / duration.as_secs_f64() + } +} + +fn ratio(bytes: u64, items: u64) -> f64 { + if items == 0 { + 0.0 + } else { + bytes as f64 / items as f64 + } +} + +fn directory_size(path: &Path) -> Result { + let mut total = 0_u64; + let mut pending = vec![path.to_path_buf()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory)? { + let entry = entry?; + let metadata = entry.metadata()?; + if metadata.is_dir() { + pending.push(entry.path()); + } else if metadata.is_file() { + total = total.saturating_add(metadata.len()); + } + } + } + Ok(total) +} + +fn print_query(report: &QueryReport) { + println!( + "查询 {:<16} 结果 {:>8} P50 {:>8} µs P95 {:>8} µs P99 {:>8} µs", + report.name, + format_integer(report.result_count as u64), + report.p50_micros, + report.p95_micros, + report.p99_micros + ); +} + +fn print_summary(report: &BenchmarkReport) { + println!(); + println!("基准汇总"); + println!("生成耗时: {:.3} 秒", report.generation_seconds); + println!( + "RocksDB 写入: {:.3} 秒 {:.0} 条/秒", + report.rocksdb_write_seconds, report.rocksdb_records_per_second + ); + println!( + "Tantivy 索引: {:.3} 秒 {:.0} 文档/秒", + report.index_seconds, report.index_documents_per_second + ); + println!( + "Tantivy 临时 IO 重试: {} 次 等待 {:.3} 秒", + report.index_transient_retries, report.index_retry_wait_seconds + ); + println!( + "RocksDB: {} 平均 {:.1} 字节/记录", + format_bytes(report.rocksdb_bytes), + report.rocksdb_bytes_per_record + ); + println!( + "Tantivy: {} 平均 {:.1} 字节/文档", + format_bytes(report.tantivy_bytes), + report.tantivy_bytes_per_document + ); + println!("合计磁盘: {}", format_bytes(report.total_bytes)); + if let Some(bytes) = report.peak_memory_bytes { + println!("进程峰值内存: {}", format_bytes(bytes)); + } else { + println!("进程峰值内存: 当前平台暂不支持读取"); + } +} + +fn format_integer(value: u64) -> String { + let digits = value.to_string(); + let mut formatted = String::with_capacity(digits.len() + digits.len() / 3); + for (index, character) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + formatted.push(','); + } + formatted.push(character); + } + formatted +} + +fn format_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit + 1 < UNITS.len() { + value /= 1024.0; + unit += 1; + } + format!("{value:.2} {}", UNITS[unit]) +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(windows)] +fn peak_memory_bytes() -> Option { + use std::mem::{size_of, zeroed}; + use windows_sys::Win32::System::{ + ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS}, + Threading::GetCurrentProcess, + }; + + let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { zeroed() }; + let result = unsafe { + GetProcessMemoryInfo( + GetCurrentProcess(), + &mut counters, + size_of::() as u32, + ) + }; + (result != 0).then_some(counters.PeakWorkingSetSize as u64) +} + +#[cfg(target_os = "linux")] +fn peak_memory_bytes() -> Option { + let status = fs::read_to_string("/proc/self/status").ok()?; + let line = status.lines().find(|line| line.starts_with("VmHWM:"))?; + let kibibytes = line.split_whitespace().nth(1)?.parse::().ok()?; + kibibytes.checked_mul(1024) +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn peak_memory_bytes() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generator_is_deterministic_and_creates_requested_duplicates() { + let first = generate_record(8, 10).unwrap(); + let duplicate = generate_record(9, 10).unwrap(); + assert_ne!(first.info_hash, duplicate.info_hash); + assert_eq!(first.content_key, duplicate.content_key); + assert_eq!(generate_record(8, 10).unwrap(), first); + assert_eq!(expected_document_count(100, 10), 90); + } + + #[test] + fn percentiles_use_nearest_rank() { + let samples: Vec<_> = (1..=100).collect(); + assert_eq!(percentile(&samples, 50), 50); + assert_eq!(percentile(&samples, 95), 95); + assert_eq!(percentile(&samples, 99), 99); + } + + #[test] + fn byte_and_integer_formatting_are_stable() { + assert_eq!(format_integer(1_234_567), "1,234,567"); + assert_eq!(format_bytes(1024), "1.00 KiB"); + } + + #[test] + fn index_retry_delay_is_bounded() { + assert_eq!(index_retry_delay(1), Duration::from_millis(250)); + assert_eq!(index_retry_delay(3), Duration::from_secs(1)); + assert!(index_retry_delay(100) <= Duration::from_secs(10)); + } +} diff --git a/dht-search/src/search/indexer.rs b/dht-search/src/search/indexer.rs index 2c45f4e..96e1199 100644 --- a/dht-search/src/search/indexer.rs +++ b/dht-search/src/search/indexer.rs @@ -422,6 +422,13 @@ fn regex_query(pattern: &str, fields: SearchFields) -> Result, Se } fn text_query(query: &str, fields: SearchFields) -> Box { + let normalized = normalize_text(query.trim()); + if normalized.len() == 40 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Box::new(TermQuery::new( + Term::from_field_text(fields.info_hash, &normalized), + IndexRecordOption::Basic, + )); + } let terms = query_terms(query); if terms.is_empty() { return Box::new(AllQuery); @@ -777,6 +784,18 @@ mod tests { assert_eq!(excluded.total, 0); } + #[test] + fn exact_infohash_is_searchable_without_ngram_splitting() { + let directory = TempDir::new().unwrap(); + let engine = SearchEngine::open(directory.path()).unwrap(); + let record = record(); + index_records(&engine, std::slice::from_ref(&record)); + + let page = engine.search(&record.info_hash.to_string(), 0, 10).unwrap(); + assert_eq!(page.total, 1); + assert_eq!(page.hits[0].info_hash, record.info_hash.to_string()); + } + #[test] fn filters_and_sorts_use_group_fast_fields() { let directory = TempDir::new().unwrap();