Android 数据库系列二:全文检索踩坑记录与相关思考
一、背景
在我们的工程中存在一个全局搜索模块。其可以通过关键字搜索本地的消息记录。这部分的逻辑写于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)发生时自动执行。用于强制实施复杂的业务规则、维护数据一致性等。
以下是触发器基本组成部分
- 触发器名称:唯一标识触发器的名称。
- 触发事件:定义触发器响应的事件类型,如INSERT、UPDATE、DELETE。
- 触发时间:指定触发器是在给定事件之前(BEFORE)还是之后(AFTER)执行
- 触发操作:一组在触发事件发生时将要执行的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(五次取平均值) | 252ms | 621ms | 975ms | 1230ms |
全文检索(五次取平均值) | 50ms | 77ms | 88ms | 70ms |
可以看到随着数据量增加,使用LIKE语句的耗时增长是必然的。线上某用户本地800万条消息,每次搜索耗时非常久(实际上他就是找人,并不是想搜索消息,而我们大搜首页的业务逻辑会同时支持搜索本地消息),导致使用大搜后,进入会话页面消息加载就比较慢,出现空白几秒的情况,体验比较差。可以预见切换到全文检索后,整体耗时会相对可控,同时在一个独立的数据库中搜索,对主库将基本无影响。
备注: 通过开发者工具构造数据进行测试。
转载自:https://juejin.cn/post/7330764235594694696