一、布景
在咱们的工程中存在一个大局查找模块。其能够经过要害字查找本地的音讯记录。这部分的逻辑写于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万条音讯,每次查找耗时十分久(实际上他便是找人,并不是想查找音讯,而咱们大搜首页的事务逻辑会一起支撑查找本地音讯),导致运用大搜后,进入会话页面音讯加载就比较慢,呈现空白几秒的状况,体会比较差。能够预见切换到全文检索后,全体耗时会相对可控,一起在一个独立的数据库中查找,对主库将根本无影响。
补白:
经过开发者东西构造数据进行测验。