likes
comments
collection
share

Android 数据库系列二:全文检索踩坑记录与相关思考

作者站长头像
站长
· 阅读数 4

一、背景

在我们的工程中存在一个全局搜索模块。其可以通过关键字搜索本地的消息记录。这部分的逻辑写于19年,直接通过SQL使用like进行字符串匹配。 这种实现方案具有以下几个问题:

1、搜索消息非常耗时

通过线上监控,某用户本地消息数量达到几百万条。在大搜中输入某个关键字,由于全局搜索主页默认会并发搜索服务消息与本地消息,一次本地消息搜索耗时竟在20秒+;

2、多次并发搜索导致占用连接池

用户反馈进入聊天页面偶现存在页面空白的情况,即本地的消息没有加载成功。由于是偶现,用户也没有总结出来规律,只是反馈频率颇高。我们拉取用户日志定位发现,每次用户反馈进入聊天页面空白,在此之前都进入了全局搜索页面。

即用户是通过全局搜索,输入某个关键字,搜索到某个联系人后,进入到与该联系人的聊天页面。而在全局搜索的业务中,用户输入关键字不只会搜索联系人,同时也会同时并发搜索本地消息。结合用户本地消息较多的情况,我们怀疑是消息搜索间接导致进入聊天页面出现空白的情况。

在数据库系列一中我们有提到,数据库存在连接池,通常由主连接+非主连接构成。所以我们怀疑是用户是输入关键字后,触发搜索本地消息逻辑,将连接池占满。此时进入会话页面,需要加载该会话所属消息,但由于连接池都被占用,获取消息的任务阻塞等待空闲连接池,所以聊天页面出现无法加载聊天记录的情况。针对这种情况,我们详细补充了部分日志后,证实了我们的猜想。

基于以上背景,我们决定添加全文检索能力,优化本地消息记录的搜索。

二、搜索库、搜索表

1、独立的库

为了将全文检索对业务逻辑的影响减低到最小,所以决定单独为全文检索创建一个搜索库。该数据库的目的就是向全业务提供全文检索数据。

数据库名称:SearchDatabase

2、消息表

由于搜索消息在业务上定制性太强,所以单独为消息创建了一张消息表。包含了搜索时需要的相关字段。

三、如何基于ROOM+WCDB实现全文检索

我们工程中本地数据库底层是基于WCDB实现的,数据库上层是使用JetPack ROOM组件封装。所以实现全文检索,必须要基于ROOM来实现。

1、ROOM对于全文检索的支持

Room 全文检索介绍 developer.android.com/training/da…

ROOM 目前支持FTS3、FTS4。可以直接通过@Fts3 @Fts4来实现;

FTS表示例代码:

@Fts4(contentEntity = SearchMessageEntity.class)
@Entity(tableName = "tb_search_message_fts")

public class SearchMessageEntityFTS {

    @ColumnInfo(name = "uuid")
    public String uuid;

    @ColumnInfo(name = "msg_body")
    public String msgBody;

}

新建的FTS表的名称为tb_search_message_fts,通过注解指定使用的是FTS4。

同时通过contentEntity字段,来关联FTS表对应的实体表是哪一个表。

在示例代码中,FTS表(tb_search_message_fts)关联的表是tb_search_message。

数据表示例代码:

@Entity(tableName = "tb_search_message", indices = {@Index(value = {"uuid"}, unique = true)})
public class SearchMessageEntity {

    @PrimaryKey(autoGenerate = true)
    @NonNull
    public long serial;
    /**
     * 消息UUID
     */
    @ColumnInfo(name = "uuid")
    public String uuid;
    /**
     * 会话ID
     */
    @ColumnInfo(name = "id")
    public String id;

    @ColumnInfo(name = "msg_type")
    public int msgType;

    @ColumnInfo(name = "msg_subType")
    public int msgSubType;

    /**
     * 发送消息的UID
     */
    @ColumnInfo(name = "msg_from_uid")
    public String msgFromUid;

    /**
     * 消息体
     */
    @ColumnInfo(name = "msg_body")
    public String msgBody;


    @ColumnInfo(name = "msg_time")
    public long msgTime;
}

2、FTS 表不可以使用索引

FTS表不能够使用索引,如果在FTS表中创建索引会编译不通过。

错误信息:

Indices not allowed in FTS Entity.

3、FTS 表主键必须是名字为 rowid 的字段(或者不指定)

FTS 表主键必须是名字为 rowid 的字段(或者不指定), 否则会编译不通过。

错误信息:

The single primary key field in an FTS entity must either be named 'rowid' or must be annotated with @ColumnInfo(name = "rowid")

4、FTS 表中的字段必须为实体表的子集

FTS 表中的字段必须为实体表的子集, 否则会编译不通过。

错误信息:

External Content FTS Entity 'com.xxx.entity.search.SearchMessageEntityFTS' has declared field with column name 'xxx' that was not found in the external content entity 'com.xxx.entity.search.SearchMessageEntity'.

5、分词器Tokenizer

分词器决定了文本数据如何被分解成词元。定义搜索时文本的处理方式,包括大小写转换、去除标点、支持多语言等。

ROOM框架中声明了以下几种分词器:

public static final String TOKENIZER_SIMPLE = "simple";

/**
 * The name of the tokenizer based on the Porter Stemming Algorithm.
 * @see Fts4#tokenizer()
 * @see Fts4#tokenizerArgs()
 */
public static final String TOKENIZER_PORTER = "porter";

/**
 * The name of a tokenizer implemented by the ICU library.
 * <p>
 * Not available in certain Android builds (e.g. vendor).
 *
 * @see Fts4#tokenizer()
 * @see Fts4#tokenizerArgs()
 */
public static final String TOKENIZER_ICU = "icu";


/**
 * The name of the tokenizer that extends the {@link #TOKENIZER_SIMPLE} tokenizer
 * according to rules in Unicode Version 6.1.
 *
 * @see Fts4#tokenizer()
 * @see Fts4#tokenizerArgs()
 */
@RequiresApi(21)
public static final String TOKENIZER_UNICODE61 = "unicode61";
  • simple 基本的分词器,按照空格将文本分割成词元,并且将所有词元转为小写
  • porter 基于Porter Stemming Algorithm,它除了执行simple分词器的操作外,还会对词元进行词干提取,移除常见的英文单词后缀,以提高搜索的灵活性和相关性
  • unicode61 基于Unicode版本6.1的规则来工作,支持多种语言,并提供了更加丰富的文本处理能力,例如大小写转换、去除标点、使用Unicode字符进行词元分割等
  • icu 使用ICU库提供的复杂的文本处理功能,支持大多数语言的词元化和大小写转化。

其中ICU支持中文分词,但尴尬的是 ROOM并不支持ICU分词器。设置ICU分词器后,直接抛异常。

@Fts4(tokenizer = FtsOptions.TOKENIZER_ICU)

通过tokenizer制定分词器为icu时,会报以下异常:

E/FATAL: [, , 0]:[149][V]unknown tokenizer: icu (code 1, errno 0): 
E/FATAL: [, , 0]:[149][V]com.tencent.wcdb.database.SQLiteConnection.nativeExecuteForChangedRowCount(SQLiteConnection.java:-2) 
E/FATAL: [, , 0]:[149][V]com.tencent.wcdb.database.SQLiteConnection.executeForChangedRowCount(SQLiteConnection.java:860) 
E/FATAL: [, , 0]:[149][V]com.tencent.wcdb.database.SQLiteSession.executeForChangedRowCount(SQLiteSession.java:711) 
E/FATAL: [, , 0]:[149][V]com.tencent.wcdb.database.SQLiteStatement.executeUpdateDelete(SQLiteStatement.java:91)
E/FATAL: [, , 0]:[149][V]com.tencent.wcdb.database.SQLiteDatabase.executeSql(SQLiteDatabase.java:1905) 
E/FATAL: [, , 0]:[149][V]com.tencent.wcdb.database.SQLiteDatabase.execSQL(SQLiteDatabase.java:1809) 
E/FATAL: [, , 0]:[149][V]com.tencent.wcdb.room.db.WCDBDatabase.execSQL(WCDBDatabase.java:283)

而其他分词器如simple,porter,unicode61 可以使用,但是不支持中文分词,检索效果不好。

6、如何使用WCDB的分词器MMICU

由于ROOM不支持ICU分词器,使用其他分词器,中文环境下搜索的效果非常差。之前可以通过like语句搜索到的内容,通过全文检索反而搜索不到了。

阅读WCDB文档时,发现WCDB自己实现了一个支持中文的分词器MMICU。所以我们决定引入MMICU作为分词器,那么Room框架如何添加MMICU分词器呢?

使用Room框架添加ICU分词时,代码是这样的: @Fts4(tokenizer = FtsOptions.TOKENIZER_ICU) 虽然无法使用ICU分词器,但是我们可以查看一下ROOM在编译期间为我们生成的数据库实现类 SearchDatabase_Impl:

可以看到,创建FTS的SQL语句为:

_db.execSQL("CREATE VIRTUAL TABLE IF NOT EXISTS `tb_search_message_fts` USING FTS4(`id` TEXT, `msg_body` TEXT, tokenize=icu, content=`tb_search_message`)");

而WCDB的分词名称为mmicu,所以猜测我们可以直接在注解中,指定分词器为mmicu。

@Fts4(contentEntity = SearchMessageEntity.class, tokenizer = "mmicu")

指定分词器为 mmicu 后重新编译,编译没有问题。但运行使用数据库时,依然会抛出异常,错误信息:

unknown tokenizer: mmicu (code 1, errno 0):

错误信息与使用icu分词器时居然相同...但我们知道WCDB官方明确提供了mmicu的分词器,依然抛出异常只能是我们使用方法不对了,需要继续排查了。

经过重新进行搜索,最终发现在 github.com/Tencent/wcd… 这个issue中有人提到了,使用MMICU 需要注册。

读了下分词器重构的代码,发现变成默认不加载了,重写onConfigure,增加MMFtsTokenizer的默认分词器就好了  
@override
public void onConfigure(SQLiteDatabase db) {  
    super.onConfigure(db);  
    db.addExtension(MMFtsTokenizer.EXTENSION);  
}

7、注册MMICU

依据搜索结果,注册 mmicu 需要在 SQLiteOpenHelper中onConfigure()方法中注册。

在创建数据库时,我们需要使用 WCDBOpenHelperFactory,指定数据库密钥,加密方式等。WCDBOpenHelperFactory 会在onCreate方法中,返回SQLiteOpenHelper对象。

所以就需要仿照WCDBOpenHelperFactory实现我们自己的Factory对象,同时在onCreate方法中返回我们仿照WCDBOpenHelper所写的对象,然后在其中注册。

具体的代码如下(非关键代码有删减)

WCDBSearchFactory

public class WCDBSearchFactory extends WCDBOpenHelperFactory {    
    ...
    @Override

    public SupportSQLiteOpenHelper create(SupportSQLiteOpenHelper.Configuration configuration) {
        WCDBSearchHelper result = new WCDBSearchHelper(configuration.context, configuration.name,
                mPassphrase, mCipherSpec, configuration.callback);
        result.setWriteAheadLoggingEnabled(mWALMode);
        result.setAsyncCheckpointEnabled(mAsyncCheckpoint);
        return result;
    }
}

WCDBSearchHelper


public class WCDBSearchHelper implements SupportSQLiteOpenHelper {
    private final WCDBSearchHelper.OpenHelper mDelegate;
    WCDBSearchHelper(Context context, String name, byte[] passphrase, SQLiteCipherSpec cipherSpec,
                     Callback callback) {
        mDelegate = createDelegate(context, name, passphrase, cipherSpec, callback);
    }

    private WCDBSearchHelper.OpenHelper createDelegate(Context context, String name, byte[] passphrase,
                                                       SQLiteCipherSpec cipherSpec, Callback callback) {
        final WCDBDatabase[] dbRef = new WCDBDatabase[1];
        return new WCDBSearchHelper.OpenHelper(context, name, dbRef, passphrase, cipherSpec, callback);
    }
    ...

    static class OpenHelper extends SQLiteOpenHelper {

        ...
        @Override
        public void onConfigure(SQLiteDatabase db) {
            //注册MMICU
            db.addExtension(MMFtsTokenizer.EXTENSION);
            db.setAsyncCheckpointEnabled(mAsyncCheckpoint);
            mCallback.onConfigure(getWrappedDb(db));
        }
        ...
    }
}

最后在构建 RoomDatabase 对象时,将WCDBSearchFactory对象传入就好了。

重新编译运行,发现没有问题。通过内部开发的开发者工具先实现一个向搜索表插入10w条数据的工具,再简单实现一个检索某中文关键字的工具。

插入10万条数据后,检索中文,测试可以搜索到准确的数据,耗时平均在1ms(具体的数据对比在文章后面给出),至此数据库层面对全文检索的支持处理完成。

8、FTS表是如何与实体表保持数据同步的

我们搜索是使用FTS表,而数据是存储在实体表中的,上层业务是向实体表中插入的数据,即tb_search_message表。那么tb_search_message的数据是如何同步到tb_search_message_fts表中的呢?

两个表的数据同步是数据库框架为我们处理的。

查看ROOM为我们生成的 SearchDatabase_Impl 实现类,可以看到其为我们创建了四个触发器。

源码实现:

_db.execSQL("CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_tb_search_message_fts_BEFORE_UPDATE BEFORE UPDATE ON `tb_search_message` BEGIN DELETE FROM `tb_search_message_fts` WHERE `docid`=OLD.`rowid`; END");
_db.execSQL("CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_tb_search_message_fts_BEFORE_DELETE BEFORE DELETE ON `tb_search_message` BEGIN DELETE FROM `tb_search_message_fts` WHERE `docid`=OLD.`rowid`; END");
_db.execSQL("CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_tb_search_message_fts_AFTER_UPDATE AFTER UPDATE ON `tb_search_message` BEGIN INSERT INTO `tb_search_message_fts`(`docid`, `uuid`, `msg_body`) VALUES (NEW.`rowid`, NEW.`uuid`, NEW.`msg_body`); END");
_db.execSQL("CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_tb_search_message_fts_AFTER_INSERT AFTER INSERT ON `tb_search_message` BEGIN INSERT INTO `tb_search_message_fts`(`docid`, `uuid`, `msg_body`) VALUES (NEW.`rowid`, NEW.`uuid`, NEW.`msg_body`); END");

源于触发器

触发器定义了一组SQL语句,这组语句会在特定的数据库事件(INSERT、UPDATE、DELETE)发生时自动执行。用于强制实施复杂的业务规则、维护数据一致性等。

以下是触发器基本组成部分

  1. 触发器名称:唯一标识触发器的名称。
  2. 触发事件:定义触发器响应的事件类型,如INSERT、UPDATE、DELETE。
  3. 触发时间:指定触发器是在给定事件之前(BEFORE)还是之后(AFTER)执行
  4. 触发操作:一组在触发事件发生时将要执行的SQL语句。

触发器语句示例:

CREATE TRIGGER  // 创建触发器 
 auto_insert    // 触发器名称,后期可以用来查询和移除触发器 
 AFTER          // 在事件之后触发,改为BEFORE就是之前触发   
 INSERT         // 在插入事件触发,还支持DELETE、UPDATE 
 ON tb_msg      // 操作哪个表    
 BEGIN          // 触发语句开始        
 // 触发语句,删除db_list_table表中和当前插入数据的user_id、item_id相同的数据        
 INSERT INTO db_search(busId, deleted, type, busAccount, busTimetag, busContent, busStatus, busExternParam) VALUES (NEW.uuid, 0, 1, NEW.id, NEW.msg_time, NEW.msg_body, 0, "");// 不要忘了分号        
 // 因为触发事件是INSERT,所以表单数据要用NEW.column-name引用;         
 END;          // 触发语句结束

通过示例代码比对框架为我们生成的代码,可以比较清楚的知道,SearchDatabase_Impl中创建的四个触发器的作用就是

  • 在删除数据库实体表之前,先删除FTS表中的数据
  • 在向实体表插入数据之后,也向FTS表中,插入FTS关心的数据(UUID,MsgBody)
  • 在向实体表更新数据之前,先删除FTS表中的数据
  • 在向实体表更新数据之后,向FTS表中插入FTS关心的数据

这样就完成了数据的同步,我们只需要操作实体表就可以了,FTS表中的数据由ORM框架为我们维护。

9、全文检索SQL语句

基于FPS的搜索语句如下:

SELECT * FROM table WHERE column MATCH 'keyword'

其中,table表示要进行检索的表名,column表示要进行检索的列名,keyword表示要检索的关键字。

在我们代码中:

@Query("SELECT * FROM tb_search_message WHERE id in (SELECT id FROM tb_search_message_fts WHERE tb_search_message_fts MATCH :keyword)") 
List<SearchMessageEntity> searchMessage(String keyword);

在我们写的示例代码中,我们没有指定要匹配的列,而是直接写了表名称,这样既会匹配uuid,也会匹配msg_body。

四、消息的同步、占用、自动清理等

1、消息实时同步

消息库中的消息与搜索库中的消息的同步,没有想到特别好的方法。目前采取的策略是在数据库操作的中间层中,在更新消息表的同时会去更新搜索库中的消息表。

目前主要关注消息的插入,删除,消息id的更改。这部分逻辑是比较稳定的,相关方法调整之后,后面基本不需要修改。

2、既有数据同步

已经存在用户消息表中的数据,本次升级上来无法将所有的用户数据都同步到消息表中。因为既有的数据量较大,现在采取的策略是,将用户最近30天的数据同步到搜索库中。后续新增的消息都会同步到该库中。

3、搜索库SD卡空间占用估算

通过开发者工具中实现的【一次向搜索表插入10w条数据的工具】,插入消息的消息体长度固定,每插入50万条数据,APP在SD卡上占用的控件就增加100M左右。

如果一个用户每天能够产生5万条消息,一年产生1800万条消息,那么APP数据会在SD卡上对占用3.5G+;

不过考虑到仅有一些运维等特殊职业才有可能有这样的消息频率。同时不是所有的消息都会同步到搜索库中,只有支持搜索的消息才需要同步到搜索库中,所以初步判定搜索库对SD卡的影响在短期内有限。

当然测试数据是固定长度的,在实际生产过程中,消息长度有大有小,其对存储空间的占用还需要持续监控。所以我们新增了相关的日志以及埋点,监控搜索库文件的大小,超过一定阈值上报。

4、自动清理

由于消息在本地存储了两份,一份在主库消息表中,一份在搜索库的消息表中。当搜索库中的消息数据量较多时,会占用用户过多的存储空间。

本地全文检索本身作为服务端消息搜索的一种补充手段,我们需要在体验与业务中稍作平衡。维持搜索库在一定的量级,超过一定的消息数量则进行自动清理逻辑。

不过在全文检索上线的版本中,我们没有着急开发自动清理逻辑,因为短期内对用户的影响是有限的,需要上线几个版本之后,观察线上的埋点数据才能做决定。

5、独立线程池

操作搜索库相关的SQL,设置在独立的线程池中,防止搜索库中insert,update,delete等操作耗时过久对主业务产生影响。

线程池中核心线程数量只有一个,阻塞队列为Int最大值。

五、耗时

消息数量30万100万150万200万
LIKE(五次取平均值)252ms621ms975ms1230ms
全文检索(五次取平均值)50ms77ms88ms70ms

可以看到随着数据量增加,使用LIKE语句的耗时增长是必然的。线上某用户本地800万条消息,每次搜索耗时非常久(实际上他就是找人,并不是想搜索消息,而我们大搜首页的业务逻辑会同时支持搜索本地消息),导致使用大搜后,进入会话页面消息加载就比较慢,出现空白几秒的情况,体验比较差。可以预见切换到全文检索后,整体耗时会相对可控,同时在一个独立的数据库中搜索,对主库将基本无影响。

备注: 通过开发者工具构造数据进行测试。