Android 数据库系列一:ORM框架的引入与数据库表的设计思考

一、背景

由于我们现在维护的是一款IM类的应用,需要在本地存储消息、好友、群、群成员等数据,这就涉及到使用数据库。由于之前是以开发工具类应用为主,数据库方面使用的较少,自18年开始使用数据库以来也走了不少弯路。

所以接下来打算利用几篇文章介绍我们项目如何使用数据库,主要讲复杂业务中数据库的应用,以及过程中遇到了哪些问题和我们都是怎么解决的。这里尽可能从实际使用出发,解决实际生产过程中的问题。

当然这是站在一个Android APP 应用开发者的角度,描述在Android APP上使用数据遇到的问题,请路过的数据库大佬嘴下留情~

二、数据库ORM选型

在18年做项目之初,我们主要调研了两款ORM框架,分别是GreenDao以及Room。

GreenDao地址:github.com/greenrobot/...

Room介绍地址:developer.android.com/training/da...

最终我们确定使用ROOM,原因也很简单ROOM作为官方开发的数据库ORM框架虽然刚刚推出不久(2018年初调研的),但后面更新维护有保障。同时还可以与 LiveData 配合使用,用于观察某一张表的数据变化。所以果断决定使用了Room。

三、Room如何使用

1、Room的使用

Room 包含三个主要组件:

  • 数据库类
  • 数据实体
  • 数据访问对象 (DAO)

具体使用方法在官网有详细的介绍,这里我就不赘述了。

官网地址:developer.android.com/training/da...

官网中主要介绍的是Room框架的使用,这里我想重点聊的是,复杂业务中数据库要怎么设计。

2、分库

在我们的项目中,大致可以分为以下几个模块:IM模块、日历模块、音视频模块、小程序、其他。

其中IM是重度依赖数据库存储数据的,消息、联系人、群、群成员等各类信息都需要持久化到本地。日历模块涉及到日程信息、日历本的存储。 我们为什么要将数据库拆分呢?

起初时,由于经验不足我们是将以上所有信息都存储在同一库中的,也就是整个APP只有一个数据库。不过很快我们就发现数据库的性能比较差。

我们都知道Android SQLite是存在连接池概念的,连接池由一个主链接+多个非主链接构成。SQLite为了保证数据的一致性与完整性,设计为单写多读模式。它们会被序列化,即一个接一个地执行,以避免写冲突。因此,虽然存在多个读连接是可能的,但是对于写操作,就算在WAL模式下,也只能有一个主连接在任意时刻对数据库执行写操作。

当所有的数据存储在同一个库中,通过业务同时修改数据就需要阻塞等待了。我们的项目中作为核心的IM模块收发消息非常频繁,需要不断的操作数据库,这就影响了其他业务模块。

基于此我们将数据库重新划分为:

  • 消息库:用于存储IM模块消息
  • IM信息库:用于存储消息外其他配置信息,如联系人、群、群成员、各类配置
  • 日历库:用于存储日程等信息
  • 搜索库:用于存储需要全文检索的数据,减少对于业务库的影响

3、分表

当一个SQLite数据库表中的数据变得庞大时,它可能会导致性能问题,包括查询速度慢和数据插入效率低下。在我们的业务中大部分的数据表都是不需要考虑分表的,只有消息表需要考虑该问题。基于线上埋点统计,部分高频的用户,一天能够发送+接收消息数量超过5万条(主要是接收来自机器人的消息)。

常见的分表策略大致有:

水平分表

将一张表的数据按照某种规则分散到多张结构相同的表中。

  • 优势:分散IO压力,提高查询性能
  • 劣势:增加数据管理的复杂性,跨表操作更加复杂

垂直分表

将一张表按列分割成多张表,每张表存储部分列

  • 优势:减少单次查询的数据量,提高查询效率
  • 劣势:增加了JOIN操作的复杂性和成本,数据不在同一个表中,可能会影响数据一致性的维护

基于时间分表

根据时间将数据分配到不同的表中,例如按年、月、日来分表

  • 优势:可以根据数据的时效性采取不同的备份策略
  • 劣势:数据跨多个表,复杂查询可能需要跨表JOIN,复杂度增加

基于业务分表

根据业务特性将数据分配到不同的表中

  • 优势:不同的业务表可以独立优化和扩展;更好的符合业务逻辑
  • 劣势:跨表查询;分表策略可能会随着业务发展而变得不适用

我们业务中方式

在我们的业务中,针对消息表同时采取了水平分表以及垂直分表的方式。

水平分表

首先根据消息所在的会话类型进行区分。不同的会话类型所在的表不同。这里我们主要针对联系人、群进行分表。同时根据联系人ID以及群ID计算哈希值,基于哈希值分成各十张表。即存在10张联系人消息表,10张群消息表,这样水平方向就存在20张表。

不过这样拆分也带来了一个很大的问题,就是在Room中,每张表都要写对应的Dao类,那么就存在20个Dao类。在Dao中除了对应的表名称不同,其他都是相同的。在后续开发中,每次修改都需要同时修改20个类。初期我们都是硬着头皮来修改,后面针对此我们开发一个简单AS插件,每次仅需要操作其中一类,AS插件会自动匹配其他19个类动态修改。

同时以上所有计算哈希获取不同表Dao的方法都被我们封装在了底层,上层业务则不需要关心该获取那张表,只需要将ID传入给底层逻辑就可以了。

垂直分表

垂直方向上,我们将消息相关字段做了拆分。一部分作为基础字段放入基础表中,一部分作为扩展字段放入到可扩展的扩展表中。这样在垂直方向就将一张消息表拆分成了两张表。所以最终在消息库中共有40张表存储消息数据。

4、关于WAL

WAL 的全称是 Write Ahead Logging(预写日志),它是很多数据库中用于提高并发性能实现原子事务的一种机制。SQLite 在 3.7.0 版本引入该特性,在此之前 SQLite 实现原子提交和回滚的方法是 rollback journal(回滚日志)。

在WAL模式下,所有的写操作(如INSERT、UPDATE、DELETE)不是直接修改数据库文件,而是首先写入一个单独的日志文件,称为WAL文件(通常是数据库文件名后跟"-wal"的文件)。这些变更在事务提交时被记录,并最终在适当的时间被"检查点"(checkpoint)操作合并到主数据库文件。

在这种模式下,读取操作可以在写入操作进行时并行进行。因为读取操作读取的是主数据库文件,在写入期间,主数据库文件不会发生变化。这允许多个读取操作和一个写入操作同时发生,从而提高了并发性能。

关于WAL的介绍来自于:www.sqlite.org/wal.html

在工程中,我们要确保WAL处于开启状态,这样才可以将提高数据库的性能。

Room中判断是否开启WAL代码如下:

j 复制代码
JournalMode resolve(Context context) {

    if (this != AUTOMATIC) {
        return this;
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
        ActivityManager manager = (ActivityManager)
                context.getSystemService(Context.ACTIVITY_SERVICE);
        if (manager != null && !isLowRamDevice(manager)) {
            return WRITE_AHEAD_LOGGING;
        }
    }
    return TRUNCATE;
}
  

private static boolean isLowRamDevice(@NonNull ActivityManager activityManager) {
    if (Build.VERSION.SDK_INT >= 19) {
        return SupportSQLiteCompat.Api19Impl.isLowRamDevice(activityManager);
    }
    return false;
}

可以看到当API大于16(4.1)时 或API大于19(4.4)且非低RAM设备,就会开启WAL模式。也可以手动调用接口开启WAL模式。

四、切换到WCDB

在后面的调研中,我们了解到微信开源了他们的数据库:WCDB。同时WCDB提供了基于Room的封装,所以我们决定将数据库切换到WCDB。

WCDB是WeChat Database的缩写,顾名思义就是微信开源的数据库,GitHub的wiki如下: github.com/Tencent/wcd...

由于WCDB支持使用Room ORM 与数据绑定,所以对于我们工程而言切换非常简单。核心代码:

java 复制代码
SQLiteCipherSpec cipherSpec = new SQLiteCipherSpec()  // 指定加密方式,使用默认加密可以省略
        .setPageSize(4096)
        .setKDFIteration(64000);

WCDBOpenHelperFactory factory = new WCDBOpenHelperFactory()
        .passphrase("passphrase".getBytes())  // 指定加密DB密钥,非加密DB去掉此行
        .cipherSpec(cipherSpec)               // 指定加密方式,使用默认加密可以省略
        .writeAheadLoggingEnabled(true)       // 打开WAL以及读写并发,可以省略让Room决定是否要打开
        .asyncCheckpointEnabled(true);        // 打开异步Checkpoint优化,不需要可以省略

  
AppDatabase db = Room.databaseBuilder(this, AppDatabase.class, "app-db")
                //.allowMainThreadQueries()   // 允许主线程执行DB操作,一般不推荐
                .openHelperFactory(factory)   // 重要:使用WCDB打开Room
                .build();

代码来自:github.com/Tencent/wcd...

1、WCDB开启WAL

我们可以在构建WCDBOpenHelperFactory时,调用writeAheadLoggingEnabled()方法,开启WAL模式。

对应源码:

java 复制代码
public WCDBOpenHelperFactory writeAheadLoggingEnabled(boolean wal) {
    mWALMode = wal;
    return this;
}

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

2、遭遇大坑:参数超过999

就在我们感叹切换是如此简单时,现实就给了一个大闷棍,在灰度期间出现大量的崩溃,崩溃信息是:too many SQL variables。

经过排查,WCDB有针对SQL参数做限制,最大是999。而在我们的工程历史代码中有SQL语句使用【x IN list】的用法。这种写法导致SQL的参数具有不确定性,很可能会超过999。所以我们针对这种情况兵分两路,进行优化。

一路针对我们的工程进行排查,看看哪些业务逻辑存在这种写法。当list非常大时对查询性能、内存等都有影响。所以我们倾向于将这类SQL语句结合业务场景将其优化掉。如果不能改,那么就将List集合进行拆分,分批查询。封装分批操作底层工具类,提供给业务逻辑使用。

一路查看WCDB源码,从源码角度看看能否调整其参数限制。最终团队小伙伴通过翻WCDB代码找到了对应的位置:

c++ 复制代码
#ifndef SQLITE_MAX_VARIABLE_NUMBER
# define SQLITE_MAX_VARIABLE_NUMBER 999
#endif

assert( aHardLimit[SQLITE_LIMIT_VARIABLE_NUMBER]==SQLITE_MAX_VARIABLE_NUMBER);
  
if( x>db->aLimit[SQLITE_LIMIT_VARIABLE_NUMBER] ){
  sqlite3ErrorMsg(pParse, "too many SQL variables");
}

后面我们将999参数调整更大,然后重新编译了一个包集成到工程中。

五、预告

在后面的文章中,会介绍在我们的业务中,我们又遇到了哪些与数据库相关的问题,以及我们是怎么优化的。主要有以下几点:

1、WCDB全文检索的引入

2、SQL治理与数据库相关崩溃解决

3、连接池忙碌与WCDB源码

相关推荐
coooliang15 分钟前
【Android】ViewPager的使用
android
xvch2 小时前
Kotlin 2.1.0 入门教程(二十五)类型擦除
android·kotlin
夜泉_ly2 小时前
MySQL -安装与初识
数据库·mysql
qq_529835353 小时前
对计算机中缓存的理解和使用Redis作为缓存
数据库·redis·缓存
月光水岸New6 小时前
Ubuntu 中建的mysql数据库使用Navicat for MySQL连接不上
数据库·mysql·ubuntu
狄加山6756 小时前
数据库基础1
数据库
我爱松子鱼6 小时前
mysql之规则优化器RBO
数据库·mysql
chengooooooo6 小时前
苍穹外卖day8 地址上传 用户下单 订单支付
java·服务器·数据库
Rverdoser7 小时前
【SQL】多表查询案例
数据库·sql
Galeoto7 小时前
how to export a table in sqlite, and import into another
数据库·sqlite