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源码

相关推荐
服装学院的IT男1 小时前
【Android 13源码分析】Activity生命周期之onCreate,onStart,onResume-2
android
Arms2061 小时前
android 全面屏最底部栏沉浸式
android
服装学院的IT男1 小时前
【Android 源码分析】Activity生命周期之onStop-1
android
ChinaDragonDreamer4 小时前
Kotlin:2.0.20 的新特性
android·开发语言·kotlin
vvvae12344 小时前
分布式数据库
数据库
雪域迷影5 小时前
PostgreSQL Docker Error – 5432: 地址已被占用
数据库·docker·postgresql
bug菌¹6 小时前
滚雪球学Oracle[4.2讲]:PL/SQL基础语法
数据库·oracle
逸巽散人6 小时前
SQL基础教程
数据库·sql·oracle
月空MoonSky6 小时前
Oracle中TRUNC()函数详解
数据库·sql·oracle
momo小菜pa6 小时前
【MySQL 06】表的增删查改
数据库·mysql