轻量适用的数据库框架

Frank Xu
微信读书
Published in
5 min readDec 28, 2016

团队一步一步走过来,习惯使用纯粹的 SQL 来设计表和执行查询,为了满足非常丰富复杂的查询需求,我们的 SQL 可以写成几百行甚至上千行。

刚开始的时候,需求变化非常快,想法圈在整个产品发布前迭代了接近 30 个版本,几乎两周一次推倒重来,我们开始考虑如何在已有的基础上做一些抽象工作以减少劳动力。

外面很多的数据框架不能照搬过来用(很难满足如此快速复杂的变化,学习过程可能是指数增长),反思我们的初衷,是一个能够辅助目前工作的小工具,简单做这么几件事情就好:

  1. 设计一个定义数据的格式,并以此生成一个类,对应一个表
  2. 生成好创建,删除表等常见的 SQL
  3. 生成一个把 Cursor 转成对象的方法用于读取
  4. 生成一个把对象变成 ContentValue 的方法用于写入和更新

定义

2012 年写过一个 Protobuf 的解析器,格式定义上刚刚够用(表名、类型、字段名、顺序),两个地方可作为配置扩展,就直接拿过来使用。

message Book { // 表名
option database "WeRead"; // 所在的数据库
optional int32 id = 1 [hashFrom="bookId"]; // 类型、字段名、顺序
optional string bookId = 2;
optional string author = 3;
optional string title = 4 [default = "无标题"];
// ...
}

对应这样的一份定义,生成同名类文件,包含以上需要的功能,就已经满足我们的大部分需求了,这是第一个大版本。

关系

基于 SQL 设计业务逻辑,不可避免的需要管理 1:1、1:n、m:n 的关系。

1:1 关系

可以通过声明一个自定义类型的变量来定义:

optional User author = 1;

存储的时候,如果是已经存在的数据对象,取其主键保存。查询的时候,需要在 SQL 里手动 JOIN 进来。SQL 语句要区分表名,这样使得从 Cursor 到对象可以嵌套调用。

User user = new User();
user.convertFrom(cursor);
this.user = user;

如果是简单的不需要关系查询的对象,直接序列化保存:

this.style = parseObject(cursor.getString(i), Style.class);

1:n 和 m:n 关系

1:n 可以看作 m:n 的子集。如果是已经存在的数据对象,且需要被关联查询的,通过中间表来管理关系,取其主键放入中间表。中间表的操作逻辑可以通过定义来生成,关系的保证,在关联着的两个对象的写操作时同步进行,放到一个事务里。

db.beginTransactionNonExclusive();
try {
updateSelf();
updateRelation();

db.setTransactionSuccessful();
} finally {
db.endTransaction()
}

同样的如果是简单的结构,直接序列化为数组存储即可:

this.style = parseArray(cursor.getString(i), Style.class);

依旧需要手动在 SQL 语句里自行 JOIN 关系表来实现查询。这是第二个大版本。

缓存

对外透明的缓存是最好的,调用者无需考虑性能,但是同样对于复杂查询,缓存的设计就会变得很头疼。

由于大部分表设计已经有明确的主键,从主键做 key 来对整一行做缓存可能是最简便的方法,有几个好处:

  1. 缓存构造 SQL 到生成对象的过程可以框架来做,达到透明使用的目的
  2. 调用者通过主键读取内容,主键通过 Hash 生成,得到了 remoteId 可转为主键查询,可应付大部分单个对象的查询
  3. 对于列表查询,如果主键在缓存中,直接从缓存中读出,不再需要从 CursorWindow 复制出来
  4. 对于跨表查询通过主键引用的,也可先从缓存中获取
  5. 缓存策略根据不同的表自定义配置

这个缓存实现基于 Guava Cache,可定制的缓存策略很多,也方便跟踪命中率,查询性能等,可针对其统计数据制定不同的优化策略。

这几种使用场景可以覆盖大部分情况,由于缓存中的数据整一行所有数据都存在,部分字段查询的时候把缓存对象给出去也没问题。

缓存更新

这个模型的一个大前提是缓存里的数据不可更改,数据更新的时候通过 SQLite 的 UpdateHook 来清掉缓存数据下一次重新加载。

脏数据逃逸

对于已经逃逸出去的对象(被其他对象引用的旧数据),缓存本身已经追踪不到,如何快速的确定这个数据是否已被更新也是一个挺有意思的事情,可以有两个思路:

  • 每次查询出来的对象,把对象的放入另外一个池子里通过主键标记起来,更新的时候找出来标识为脏数据
  • 每次查询出来的对象,记录一个查询时间,数据更新的时候单独记录一个时间,需要检查数据是否脏了的时候比较这两个时间

这两个做法的出发点都是由使用者决定是否要检查数据,都是被动拉取更新的做法,比较节省资源。

升级

经常跟 SQLite 打交道,库升级是不可避免的,通过 pragma table_info 来获得原库的 schema,比较最新的结构,添加必要的列。每次定义一修改,版本号就加一,保证能触发库升级的 onUpgrade 回调。

SQLite 本就不支持修改和删除列,所以遇到没有的就添加,并适当回调出去用于升级的时候抄写数据等等。

最后

一步一步演变过来,这个框架有效地支持了两年来的大大小小近百个迭代,两年时间协议文档高达 400 次的提交,从最简单的代码生成工具,根据业务需要,演变为一个完全定制化可灵活扩展并适用于团队使用的工具。

--

--

Frank Xu
微信读书

WeRead at WeChat. Growth Analyst, Data & Infra Engineer.