一、布景

在咱们的工程中存在一个大局查找模块。其能够经过要害字查找本地的音讯记录。这部分的逻辑写于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(五次取平均值) 252ms 621ms 975ms 1230ms
全文检索(五次取平均值) 50ms 77ms 88ms 70ms

能够看到跟着数据量增加,运用LIKE句子的耗时增加是必然的。线上某用户本地800万条音讯,每次查找耗时十分久(实际上他便是找人,并不是想查找音讯,而咱们大搜首页的事务逻辑会一起支撑查找本地音讯),导致运用大搜后,进入会话页面音讯加载就比较慢,呈现空白几秒的状况,体会比较差。能够预见切换到全文检索后,全体耗时会相对可控,一起在一个独立的数据库中查找,对主库将根本无影响。

补白:
经过开发者东西构造数据进行测验。