Room 的 FTS 全文搜索,比 SQLite LIKE 快多少

Room 的 FTS 全文搜索,比 SQLite LIKE 快多少

Room 的 FTS 全文搜索,比 SQLite LIKE 快多少


Room 的 FTS 全文搜索,比 SQLite LIKE 快多少


从一个真实的卡顿问题说起


去年维护一个本地笔记应用,用户数据量上来之后,搜索界面开始掉帧。问题很典型:用户在搜索框输入关键词,RecyclerView 实时过滤展示结果,输入"Android"这个六个字母的单词,主线程卡住将近 800ms。ANR 阈值是 5 秒,但 800ms 的卡顿足够让输入框的光标冻结、让键盘反馈消失,体验已经崩了。


当时的表结构大概长这样:


@Entity(tableName = "notes")
data class Note(
    @PrimaryKey val id: Long,
    val title: String,
    val content: String,
    val createdAt: Long
)

搜索用的是最朴素的 LIKE 前缀匹配:


@Query("SELECT * FROM notes WHERE title LIKE '%' suspend fun searchNotes(query: String): List<<Note>

数据量多少?测试设备上 12 万条笔记,平均每条 500 字左右。SQLite 的 LIKE 前缀通配符(%keyword%)意味着无法使用 B-Tree 索引,只能全表扫描。EXPLAIN QUERY PLAN 确认了这个判断:SCAN TABLE notes,没有用到任何索引。


第一个优化念头是加索引。但 LIKE '%...%' 这种两边通配的模式,普通 B-Tree 索引根本帮不上忙。SQLite 支持前缀索引 LIKE 'keyword%',但我们的产品需求是标题和内容都要模糊匹配,用户输入"开发"要匹配到"Android开发笔记",前缀索引覆盖不了。


这时候才认真去看 Room 的 FTS 支持。之前一直知道有这个功能,但项目早期数据量小,LIKE 能用就没动。现在被逼到墙角,必须动手了。


Room FTS 的接入姿势:不是换个注解那么简单


Room 对 FTS 的支持从 2.1.0 就有了,但文档写得比较"矜持",很多细节藏在 SQLite 的 FTS 文档里,Room 本身没展开。我用的版本是 2.5.2,Gradle 依赖里确认过:


implementation "androidx.room:room-runtime:2.5.2"
kapt "androidx.room:room-compiler:2.5.2"

FTS 在 Room 里是一个独立的实体类型,不能直接在原表上加注解。我的做法是把搜索内容抽到一个 FTS 虚拟表,原表保留完整数据。这是官方推荐的模式,也是唯一合理的做法——FTS 虚拟表有特殊的存储结构,和普通表不一样。


@Entity(tableName = "notes")
data class Note(
    @PrimaryKey val id: Long,
    val title: String,
    val content: String,
    val createdAt: Long
)

@Fts4(contentEntity = Note::class)
@Entity
data class NoteFts(
    val title: String,
    val content: String
)

@Fts4(contentEntity = Note::class) 这个注解是核心。它告诉 Room:这个 FTS 表是 Note 的内容镜像,Room 会自动维护同步——原表增删改,FTS 表跟着变。但"自动"这个词容易让人误解,实际不是触发器级别的实时同步,而是 Room 在编译期生成代码时,把 FTS 表的维护嵌入到原表的 DAO 操作里。


这里有个我踩过的坑:如果原表用 @Insert(onConflict = OnConflictStrategy.REPLACE),FTS 的同步会出问题。REPLACE 本质是 DELETE + INSERT,但 Room 生成的 FTS 同步代码在某些版本里处理不好这个原子性,会导致 FTS 表残留旧数据或丢失新数据。2.4.0 之前这个 bug 比较明显,2.5.2 修了大部分场景,但我测试下来,批量 REPLACE 还是偶发同步延迟。最后改成先查再判断,显式 DELETE/INSERT 分开做,规避了这个坑。


分词器的选择:icu 还是 porter?


FTS 默认的分词器是 simple,按空格和标点切分,不做词干提取。中文内容用 simple 基本等于废物,因为中文没有空格分词。"Android开发"会被当成一个完整的 token,搜"Android"或"开发"都匹配不到。


SQLite 内置的 porter 分词器只支持拉丁语系,对中文无效。真正有用的是 icu 分词器,但需要 SQLite 编译时开启 ICU 支持。Android 系统自带的 SQLite 从 API 21 开始带了 icu 扩展,但 Room 默认不启用。


启用方式是在 @Fts4 注解里指定:


@Fts4(
    contentEntity = Note::class,
    tokenizer = FtsOptions.TOKENIZER_ICU
)

但这个注解在 Room 2.5.2 里有个编译期检查:如果指定 TOKENIZER_ICU,编译器会验证当前环境的 SQLite 是否支持。模拟器上 API 28 以上没问题,但真机测试时发现一台华为鸿蒙 2.0 设备(Android 10 兼容层)报运行时错误,sqlite3_errmsg 返回 "unknown tokenizer: icu"。查了半天,那台设备的 SQLite 编译时砍掉了 ICU 扩展。


fallback 方案是自定义分词器,但 Room 不支持直接写 SQLite 的自定义分词器注册代码。最后那台设备上的 workaround 是降级用 simple,搜索体验打折,但至少不崩溃。这个 case 让我意识到 FTS 的"平台一致性"比 LIKE 差很多,LIKE 是 SQL 标准,到哪都能跑,FTS 的分词器依赖编译选项。


查询语法变了:MATCH 不是 LIKE 的平替


FTS 的查询用 MATCH 操作符,不是 LIKE。语法差异很大,直接替换会出各种问题。


最基础的查询:


@Query("SELECT * FROM note_fts WHERE note_fts MATCH :query")
suspend fun searchFts(query: String): List<<NoteFts>

注意表名是 note_fts,这是 Room 自动生成的 FTS 表名,默认规则是原表名 + "_fts"。但 contentEntity 关联查询时,通常要 JOIN 回原表取完整数据:


@Query("""
    SELECT notes.* FROM notes 
    JOIN note_fts ON notes.id = note_fts.rowid 
    WHERE note_fts MATCH :query 
    ORDER BY rank(matchinfo(note_fts), 0) DESC
""")
suspend fun searchNotesWithRank(query: String): List<<Note>

这里 rowid 是 FTS 虚拟表的内置列,对应原表的主键。matchinforank 是 SQLite FTS 的辅助函数,用来做结果排序。但这段代码在 Room 2.5.2 里编译不过,因为 Room 的查询验证器不认识 rank 这个函数——它是 SQLite 扩展,不是标准 SQL。


workaround 是用 snippet 或者自己算权重,或者绕过 Room 的编译期检查用 @SkipQueryVerification。我选了后者,因为 rank 的排序质量明显比单纯按时间排要好。


MATCH 的语法还有另一个坑:用户输入的内容要经过转义。如果用户搜 "C++" 或者 "code:android",不加处理直接拼进 MATCH 会语法错误。FTS 的查询语法支持逻辑运算符(AND/OR/NOT)、近邻搜索(NEAR)、短语搜索(""),这些特殊字符需要转义。


我写的转义逻辑:


fun String.escapeFtsQuery(): String {
    return this.replace("\"", "\"\"")
        .let { "\"$it\"" } // 把整个查询包成短语,避免 AND/OR 被解析为逻辑运算符
}

但包成短语后,"Android开发" 必须完整连续出现才匹配,搜 "Android 开发"(带空格)就搜不到包成短语的 "Android开发"。最后改成对用户输入分词,每个词单独包短语再用 OR 连接,这是 FTS 搜索里常见的折中。


性能数据:FTS 到底快多少


说回最开始的性能问题。测试设备是 Pixel 6,Android 13,SQLite 版本 3.39.2(通过 SELECT sqlite_version() 确认)。


测试数据:12 万条笔记,总文本量约 60MB。每种查询跑 50 次,去掉前 5 次预热,取后 45 次的中位数。


LIKE '%keyword%' 全表扫描:

  • 单次查询耗时:780ms - 1200ms(取决于关键词在表中的分布,匹配行越多越慢)
  • 主线程直接跑会 ANR,必须放协程,但即使放 IO 线程,UI 等结果也要几百毫秒

  • LIKE 'keyword%' 前缀匹配(有索引):

  • 单次查询耗时:15ms - 40ms
  • 但满足不了产品需求,只能匹配开头

  • FTS MATCH 查询:

  • 单次查询耗时:8ms - 25ms
  • 返回结果包含高亮片段(snippet)时,再加 10ms 左右

  • FTS 的 rank 排序查询:

  • 单次查询耗时:20ms - 50ms
  • 比单纯 MATCH 慢,因为 matchinfo 计算有开销

  • 数字上看,FTS 比 LIKE 全表扫描快了 30-50 倍,比有索引的前缀 LIKE 略快或持平。但这个对比有点不公平,因为它们是解决不同问题的方案。真正公平的对比是:FTS 解决了 LIKE 全表扫描解决不了的问题(任意位置模糊匹配),同时保持了可接受的性能。


    我在测试里还发现一个细节:FTS 的查询速度和返回结果数量关系不大,和索引命中方式关系更大。搜常用词(如"的")FTS 也很快,因为倒排索引直接定位;LIKE 搜"的"基本是全表扫描的灾难。但 FTS 的 matchinfo 在结果集极大时(比如搜"的"返回 10 万条)排序会变慢,这时候需要加 LIMIT 截断。


    另一个意外发现:FTS 表的写入性能。批量插入 1000 条笔记,原表 + FTS 同步的总耗时,比只插原表慢 3-4 倍。FTS 的索引维护成本不低,倒排索引要拆词、建表、合并 segment。我的笔记应用有批量导入功能,导入 1 万条时进度条明显比纯原表慢。最后改成导入时先禁用 FTS 同步(通过直接操作原表,绕过 Room 的 FTS 关联),导入完重建 FTS 索引,速度提升明显,但代码复杂度也增加了。


    高亮显示:snippet 的边界


    产品需求要求搜索结果中高亮匹配的关键词。FTS 原生支持 snippet() 函数,返回带高亮标记的文本片段:


    @Query("""
        SELECT 
            notes.*,
            snippet(note_fts, '<b>', '</b>', '...', -1, 40) as snippet
        FROM notes 
        JOIN note_fts ON notes.id = note_fts.rowid 
        WHERE note_fts MATCH :query
    """)
    suspend fun searchWithSnippet(query: String): List<<NoteWithSnippet>

    snippet() 的参数依次是:FTS 表名、开始标记、结束标记、省略号、要返回的匹配列索引(-1 表示所有列)、片段长度。


    但这个函数在 Room 里有几个问题。一是返回类型映射:snippet() 返回字符串,但 Room 不认识这个列的来源,需要手动写 @ColumnInfo(name = "snippet") 对应到 data class 的字段。二是片段长度参数是"理想长度",实际返回可能更长或更短,取决于 token 边界。三是中文分词下,片段截断位置经常落在词中间,显示"..."的位置很突兀。


    最头疼的是多列匹配时的行为。如果标题和内容都匹配,snippet 默认只返回第一个匹配列的片段。想同时拿到标题和内容的片段,需要分别对两列做 MATCH 再 UNION,或者写复杂的子查询。我最后放弃了 snippet(),改用自己查完整内容后,在 Kotlin 层用正则高亮。损失了"智能片段提取"(只显示匹配位置附近的上下文),但可控性高很多。


    增量同步的隐形代价


    @Fts4(contentEntity = ...) 的自动同步,在数据量大时有个隐性成本:FTS 索引的碎片化。SQLite FTS 用倒排索引的 segment 合并策略,频繁的小写入会产生大量小 segment,查询时需要合并多个 segment 的结果,逐渐变慢。


    SQLite 有 OPTIMIZE 命令来合并 segment,但 Room 不自动调用。我加了一个定时任务,每周在后台跑一次:


    db.query("INSERT INTO note_fts(note_fts) VALUES('optimize')")

    注意语法是 INSERT INTO fts_table(fts_table) VALUES('optimize'),这是 FTS 扩展的特殊命令格式,不是真的插入数据。第一次写的时候拼错了,报 SQLITE_ERROR,查了半天 FTS 文档才找到正确写法。


    优化后查询速度稳定很多,但"需要手动维护"这个事实,让 FTS 的运维成本比 LIKE 高出一截。LIKE 是"写时无感,查时受罪",FTS 是"写时有代价,查时需要维护"。


    一个被忽视的替代方案:LIKE 加覆盖索引


    回到性能对比,FTS 不是唯一出路。在某些场景下,LIKE 配合覆盖索引(covering index)也能大幅提速。


    比如如果搜索只查 id 和 title,可以建一个索引包含这两列:


    CREATE INDEX idx_notes_title ON notes(title, id);

    但 LIKE '%...%' 仍然无法用这个索引过滤行,只能覆盖列避免回表。真正的提速有限。


    另一个方向是前缀索引 + 用户引导,比如搜索框提示用户输入前缀,或者把内容拆成关键词表做精确匹配。但这些改动产品层面不接受,最后没走。


    我还试过 Room 的 LIKE 配合 GLOB 做变通,或者把内容拆成 n-gram 预索引。n-gram 方案理论上可行,但 2-gram 索引会让索引表膨胀 5-10 倍,存储和写入成本太高,放弃了。


    版本差异:2.4.0 到 2.6.0 的演进


    Room 2.4.0 之前,FTS 的 @Fts4 不支持 contentEntity,必须手动维护同步,写触发器或者应用层双写。2.4.0 加入 contentEntity 自动同步,但初期 bug 不少,比如外键冲突时的处理、事务边界问题。


    2.5.0 改进了 FTS 的编译期验证,查询里的 rowid 映射检查更严格。之前写错 rowid 大小写(比如 rowId)可能编译过但运行时错,2.5.0 直接编译报错。


    2.6.0-alpha 开始支持 FTS5,但直到我测试时的 2.6.0-beta01,@Fts5 注解的 contentEntity 同步还有问题,某些删除场景同步失效。生产环境不敢用,还是留在 FTS4。


    FTS5 相比 FTS4 的主要优势是排序函数更灵活(rank 是内置的,不需要 matchinfo),以及 highlight() 函数比 snippet() 更好用。但 Room 的封装进度慢于 SQLite 本身,这是用 Room 的代价——方便但滞后。


    实际落地的架构取舍


    最终项目的表结构定成这样:


    @Entity(tableName = "notes")
    data class Note(
        @PrimaryKey val id: Long,
        val title: String,
        val content: String,
        val createdAt: Long,
        val ftsSyncVersion: Int = 0 // 用于手动触发 FTS 重建
    )
    
    @Fts4(
        contentEntity = Note::class,
        tokenizer = FtsOptions.TOKENIZER_ICU
    )
    @Entity
    data class NoteFts(
        val title: String,
        val content: String
    )
    
    data class NoteWithMatchInfo(
        @Embedded val note: Note,
        @ColumnInfo(name = "match_rank") val matchRank: Double?
    )

    DAO 层封装了带转义和截断的查询:


    @Query("""
        SELECT notes.*, bm25(note_fts) as match_rank 
        FROM notes 
        JOIN note_fts ON notes.id = note_fts.rowid 
        WHERE note_fts MATCH :query 
        ORDER BY match_rank DESC 
        LIMIT 200
    """)
    suspend fun searchNotesSafe(query: String): List<<NoteWithMatchInfo>
    
    fun NoteDao.search(query: String): List<<NoteWithMatchInfo> {
        val safeQuery = query.trim()
            .take(100) // 防超长
            .escapeFtsQuery()
        return if (safeQuery.isBlank()) emptyList() else searchNotesSafe(safeQuery)
    }

    bm25 是 FTS5 的排序函数,FTS4 不支持。这里代码里写的是 bm25 但实际生产环境还是 rank(matchinfo(...)),因为 FTS4。这段代码是准备迁移到 FTS5 的预演,但一直还没切。


    搜索结果的 UI 层,用 CoroutineDebouncer 做输入防抖,300ms 延迟触发查询,避免快速输入时连续发请求。FTS 的 20ms 查询时间 + 300ms 防抖,用户体验是"输入完几乎立刻出结果",比原来 LIKE 的 800ms 卡顿好太多。


    一些没走通的弯路


    试过把 FTS 表放到 WAL 模式下的单独连接,想减少写入对查询的阻塞。但 Room 不支持多连接,自己写 SupportSQLiteOpenHelper 的维护成本太高,放弃。


    试过用 FTS4prefix 选项做前缀索引,加速 "And*" 这种输入中的即时提示。但 prefix="2" 会让索引体积膨胀 40%,写入更慢,权衡后没开。


    还试过 Room 的 RxJavaFlow 监听 FTS 表变化做实时搜索,但 NoteFts 作为内容镜像表,业务层不直接操作它,Room 的无效数据通知(invalidation tracker)在 FTS 表上的触发时机有些模糊,不如直接监听原表变化再重新查询 FTS 稳妥。


    最后的性能基准


    放一组最终版本的对比数据,测试条件:Pixel 6,Android 13,Room 2.5.2,12 万条笔记,App 冷启动后首次查询(无缓存)。


    这组数据是 Trace.beginSection() / Trace.endSection() 在 Systrace 里抓的,不是 System.currentTimeMillis() 的粗略估算。FTS 的 18ms 是 JOIN + MATCH + 返回 200 条的总耗时,如果只查 id 做存在性判断,能到 5ms 以下。


    但 FTS 的写入成本始终是个阴影。批量插入 1000 条,原表 120ms,原表+FTS 同步 480ms。这个 4 倍开销在数据导入场景很显眼。我的妥协是:日常单条操作走 Room 自动同步,批量导入时禁用同步、最后 rebuild FTS 索引。


    rebuild 的语法和 optimize 类似:


    db.query("INSERT INTO note_fts(note_fts) VALUES('rebuild')")

    重建 12 万条数据的 FTS 索引,Pixel 6 上约 3 秒。导入 1 万条时,先禁用同步批量写入(约 1.2 秒),最后 rebuild(约 0.5 秒,因为增量),总时间比全程同步快一倍。


    到底该什么时候上 FTS


    经过这一整套折腾,我的判断是:本地数据过万条、有任意位置文本搜索需求、且搜索是核心功能之一,FTS 值得上。如果只是几千条数据,或者搜索是前缀匹配为主,普通索引的 LIKE 够用,别给自己找维护麻烦。


    FTS 的收益不是单纯的"快 30 倍",而是让原本不可行的交互变得可行。实时搜索、增量高亮、相关度排序,这些在 LIKE 全表扫描下是灾难,FTS 能支撑。但代价是写入变慢、需要定期维护、平台兼容性风险、以及 Room 封装层带来的版本滞后。


    我现在的项目里,FTS 是标配,但接入时会评估数据量和写入模式,批量场景一定有 bypass 路径。Room 的 @Fts4(contentEntity = ...) 简化了 80% 的工作,剩下 20% 的坑——分词器兼容性、查询转义、snippet 边界、segment 优化——得自己填。

    Flutter 和 Compose 现在的真实竞争格局 2026-05-30

    评论区