数万条媒体库依然流畅:Remux 自托管媒体服务器的 SQLite 索引与性能优化全记录

原创2026-11-12 20:51:461,621 阅读

数万条媒体库依然流畅:Remux 自托管媒体服务器的 SQLite 索引与性能优化全记录

概要:Remux 是一个自托管媒体服务器(self-hosted media server),提供与 Jellyfin 兼容的 API。它的整个媒体库——剧集、电影、音乐、播放进度——都存放在单个 SQLite 数据库中。当库中条目达到数十万行时,查询会不会变卡?本文将带你完整回顾 Remux 在 SQLite 索引、WAL 配置、查询计划守护和百万行基准测试上做的全部性能优化工作。

为什么"一条 SQLite 库"容易变慢 🐢

Remux 把所有元数据放在一个 SQLite 文件里:媒体条目(media)、用户播放状态(user_media_state)、关联关系(media_relations)、热度统计(popularity_agg)……单库架构简单、便携、零运维,这是优点,但也是压力点:

  • 全表扫描:没有合适索引时,"最近更新"、"继续观看"这类首页高频查询要扫全表再排序;
  • 查询计划器统计过期:大批量写入(比如全库元数据刷新)之后,SQLite 的统计信息会"撒谎",导致读查询退回全表扫描;
  • 写阻塞读:默认的 journal 模式下,一次大事务会让所有读者排队。

Remux 的应对策略可以概括为四件事:连接层调优、索引精耕、统计保鲜、基准测试守门。下面逐一拆解。

Remux 自托管媒体服务器的高清 provider 徽标资源,用于媒体库与流媒体源展示

连接层调优:WAL 模式 + 缓存参数一步到位 ⚡

所有连接参数集中在 connect 函数 里一次性配置好:

参数 取值 作用
journal_mode WAL 写前日志模式,读写不再互相阻塞
synchronous NORMAL WAL 下的经典搭配,兼顾安全与速度
cache_size -16384(16 MB) 页缓存,减少磁盘回读
mmap_size 32 MB 内存映射,大表扫描更快
temp_store memory 临时表/中间结果放内存
busy_timeout 10 s 写锁竞争时最多等待 10 秒

另外还开启了一个慢查询日志:超过阈值(毫秒级可配置)的 SQL 会被记入日志,方便定位是哪条查询拖慢了媒体库——这是后文所有索引优化的"线索来源"。

WAL 文件本身也做了生命周期管理:每个后台任务结束后执行 PRAGMA wal_checkpoint(PASSIVE) 冲刷日志,避免写突发堆积成大 WAL 拖慢后续读查询(见 任务调度代码);服务启动时还会做一次 wal_checkpoint(TRUNCATE),把上次残留的 WAL 清零,保证重启后的第一批查询就是最快的(见 启动流程)。

索引精耕:覆盖索引让热门查询"不碰表" 🔍

SQLite 索引优化的精髓在 migrations 目录 里的一系列小文件,每一个都对应一条真实变慢的查询。

1. 把索引扩成"覆盖索引"

所谓覆盖索引,就是把查询需要的列全部塞进索引里,SQLite 直接走索引、连表都不用读。例如"继续观看 / 即将更新"页面的查询只需要 (media_id, last_played_at, played_at),于是索引被扩成:

CREATE INDEX idx_ums_user_play_state
    ON user_media_state(user_id, play_count, playback_position, media_id, last_played_at, played_at);

完整文件见 202606180001_perf_indexes.sql。同类操作还有"相似内容"查询用的 media_relations 覆盖索引(202607220001_media_relations_covering_idx.sql)和热门榜单查询用的 popularity_agg 覆盖索引(202607220002_pop_agg_covering_idx.sql)——后者让 ORDER BY avg DESC LIMIT N 直接沿索引倒序走、数满 N 条就停,不用物化全部候选。

2. 为 LIMIT 排序而生的表达式索引

首页默认按"创建时间"排序,且没有 WHERE 条件。没有索引时 SQLite 必须全表扫描 + 全量排序才能返回前 20 条。修复方式是建一个与查询表达式逐字相同的索引:

CREATE INDEX idx_media_created_at_sort ON media(datetime(created_at));

注意注释里的坑:表达式必须文本上完全一致,规划器才认(202609240001_media_created_at_sort_index.sql)。同样的思路还用于年份下拉框的 strftime('%Y', released_at) 表达式索引。

3. 部分索引:只索引"少数派"

收藏、片单(watchlist)、忽略标记每个用户只有寥寥几行。给全表建索引太浪费,Remux 用 partial index 只索引命中行:

CREATE INDEX idx_ums_user_watchlist
    ON user_media_state(user_id, media_id) WHERE watchlist = 1;

(202610240001_user_media_state_watchlist_ignored.sql)这样"我的片单"查询只扫几行索引,而不是几百万行表。子条目去重用的 (parent_id, kind, idx) WHERE parent_id IS NOT NULL 也是同款思路。

4. JSON 字段索引:一次"计划器不认账"的回滚式修复 📊

Remux 把 IMDb / TMDB / TVDB 等外部 ID 存在 external_ids JSON 列里。为按外部 ID 精确查找,它建了 json_extract 表达式索引,而且先修数据再建索引——真实环境中出现过畸形 JSON 行,直接建表达式索引会报错(202609030002_media_external_id_indexes.sql)。

更有意思的是后续故事:后来有人用复合唯一索引替换了单列查找索引来强制"每个 kind 只有一个外部 ID",结果 EXPLAIN QUERY PLAN 发现规划器根本无法用新索引服务原查询,只能退回 kind 分区全扫。最终方案是"双索引并存":唯一索引继续保证约束,单列查找索引恢复原样保性能(202609190001_restore_media_external_id_lookup_indexes.sql)。这个案例的教训很实在:约束索引 ≠ 查询索引,两者要分开看。

统计保鲜:任务跑完,规划器不能"说谎" ✅

前面提到,批量写入后 SQLite 的统计信息会过期。Remux 的做法是每个后台任务(全库刷新、目录导入、清理)结束时固定执行两条:

PRAGMA wal_checkpoint(PASSIVE);  -- 冲刷 WAL
PRAGMA optimize;                 -- 刷新查询规划器统计

代码见 任务调度。这保证了"刚跑完百万级写入"之后的第一次读查询,规划器看到的仍是新数据分布,不会误选全表扫描。

大批量删除场景(比如按类型清空整个媒体库)则有专门技巧:先关外键、BEGIN IMMEDIATE 单事务、把待删 ID 拷进带索引的临时表 _purge_batch 再逐表清理,把 O(N×M) 的子查询删除压成一次集合操作(purge_shared.rs)。

用百万行基准测试守住性能 🚀

索引不是拍脑袋加的,Remux 用 Criterion 构建了一套固定夹具的基准测试:5 万部剧 × 每季 24 集 ≈ 125 万行媒体数据,外加 1 万部电影、1 万条用户播放状态(见 benches/common.rs),然后压测最真实的首页接口:

更硬核的是查询计划回归测试:测试代码把一台真实 193 万行媒体库上 PRAGMA optimize 留下的 sqlite_stat1 统计原样复刻进内存库,再断言关键查询走的是预期索引而不是全表扫描(media.rs 中的计划测试)。这等于把"查询计划"也纳入了回归保障——索引改坏了,CI 直接红。

Remux 媒体库性能优化配套的 provider 高清徽标资源示例

普通用户能带走什么 💡

如果你只是想"用得上",记住这几点就够了:

  1. 保持版本更新:索引迁移是随版本自动执行的(migrations 目录 里的每个小文件都是一次针对性优化),旧版本的库升级后直接受益;
  2. 调慢查询阈值:slow_query_threshold_ms 越小,日志里能捕获的慢 SQL 越多,适合排查"某个页面怎么突然变卡了";
  3. 大批量操作交给内置任务:刷新、导入、清理都走任务系统,任务收尾自带 WAL 冲刷和统计刷新,比手动裸跑 SQL 稳得多;
  4. 单文件数据库 = 备份只需拷贝一个文件,这也是 Remux 选择 SQLite 的底气所在。

小结

Remux 的性能故事没有玄学:WAL + 缓存参数解决读写并发,覆盖/部分/表达式索引消灭全表扫描,PRAGMA optimize 保持统计新鲜,百万行基准测试和查询计划回归测试守住下限。数万乃至上百万行的媒体库,依然可以在普通硬件上保持秒开。感兴趣的话,可以从 db/mod.rs 的连接配置和 migrations 目录 的索引迁移文件开始阅读,那是全部优化的"现场"。

登录后查看全文
remux