【转载】Android 是如何处理大型 SQLite 数据库查询的?

原文 Large Database Queries on Android by Chris Craik(机翻改)

  • 修复了失效的 sqlite wiki 链接,这些链接的域名前缀现改为www2。

SQLite 是在 Android 上保留数千项数据的好方法,但在 UI 中呈现这些庞大的数据集历来很困难,并且可能导致性能问题。在启动新的分页库之前,我们调查了平台中现有的分页方法,尤其是 SQLiteCursor 中的潜在缺陷。

在这篇博文中,我们将讨论它的问题,以及为什么这促使我们使用Android 架构组件中Room PersistencePaging库进行小型查询。

SQLiteCursor 和 CursorAdapter

SQLiteCursor是 Android SQLite 数据库查询的返回类型。它允许您以固定的初始加载成本查看大型查询结果。
第一次读取初始化一个CursorWindow,一个通常大小为 2MB 的行缓冲区,内容来自数据库。每次请求不存在的行时,SQLiteCursor 都会刷新此窗口。这样,SQLiteCursor 就实现了分页,页面大小固定。

CursorAdapter从 Android API 1 开始就已经存在,并提供了一种简单的方法来将来自 Cursor(通常是 SQLiteCursor)的数据绑定到 ListView 中的项目。虽然它可以很好地提供此功能,但它会需要新加载时直接在 UI 线程上查询数据库。对于现代且响应迅速的应用程序而言,这本身是不可接受的。所以有人可能会问:我们不能只有一个基于 Cursor 的适配器在后台线程上加载吗?毕竟,SQLiteCursor 内置了分页功能。

SQLiteCursor 中的分页问题

在 SQLiteCursor 中分页的大部分问题来自令人惊讶的行为,因为它使用其窗口来分页内容。以下是我们在试验分页库的 SQLiteCursor 内部分页时发现的挑战列表:

SQLiteCursor 没有打开任何数据库事务

当我开始研究分页时,我对 SQLite 尤其是 Android 上的 Cursors 缺乏经验。我只是假设 SQLiteCursor 会在加载一个窗口后暂停其查询,并在需要下一个窗口时恢复。这样,访问第 10 个窗口将与第一个窗口一样有效。这是不正确的。每次读取一个新窗口时,查询都会从位置 0 重新开始并跳过不需要填充窗口的行,一次一个。这是因为SQLiteCursor 无法恢复查询

这就像访问链表中的第 1000 到 1050 项——您必须跳过大量项才能访问要加载的下一页。加载时,每个后续窗口都必须跳过越来越多的查询,这会减慢速度。这等效于使用 SQLOFFSET关键字跳过内容,这不是页面内容的最有效方式,但在依赖 SQLiteCursor 中的分页时无法避免。您可以在此处查看 SQLiteCursor 如何在新窗口中分页。

SQLiteCursor.getCount() 是必需的,并扫描整个查询

在读取第一行之前,SQLiteCursor 调用getCount()边界检查。由于SQLite 必须扫描查询的整个结果集以对其进行计数(再次像链表一样),这会增加大量开销。如果您逐渐将大型查询分页到 UI,为了响应用户滚动,您可能不需要知道查询的整个大小,因此计数会增加不必要的前期工作。

SQLiteCursor.getCount() 总是加载行的第一个窗口

作为计算计数的一部分,在扫描结果集时,SQLiteCursor 主动从位置 0 填充其窗口,假设需要查询中的第一项。

它预加载这些项目,以便它可以提前知道大致有多少行适合一个窗口(更多内容见下文)。如果您从查询开始就显示数据,这种机会性加载是合理的,但从保存的实例状态恢复的位置可能会在列表的更下方启动索引,其中初始窗口不相关。如果您想显示来自第三个内容窗口的数据,您必须首先加载并丢弃 2MB 的数据。这种计数行为的代码在这里

SQLiteCursor 可能会加载您没有要求的数据

Cursor.moveToPosition() 保证请求的行在窗口中,但SQLiteCursor 不会从请求的行开始填充。因为 SQLiteCursor 不假设应用程序正在向前阅读,所以当它距离目标位置大约 1/3 个窗口时,它开始填充它的窗口。这意味着 CursorAdapter 在窗口加载后向后滚动几行不会触发另一个窗口加载。这也意味着在第一次加载 650KB 或更多您已经看到的数据之后,每加载 2MB 的数据。您可以在此处查看此行为的代码和说明。

SQLiteCursor 加载位置可能无法预测

当 SQLiteCursor 尝试加载目标位置时,它会尝试提前启动窗口的 ⅓。这意味着它必须猜测一个窗口中有多少行。为此,它使用在其第一个窗口加载中填充的行数。不幸的是,这意味着如果您的行具有不同的大小(例如,如果您嵌入了任意长度的用户评论字符串),那么它的猜测可能是错误的。该SQLiteCursor可以冲目标位置,填充内容的窗口-然后放弃这一切,重新开始填充。例如,如果您正在扫描一个长查询并到达需要重新加载窗口的行,则加载可能只会捕获少量的新行。清除窗口和重启代码在这里

游标需被关闭

必须使用该close()方法关闭游标,因此无论将它们存储在何处,都必须有代码在不再需要它们时将其清除。CursorAdapter 显然对此无济于事,将这一责任传递给应用程序开发人员。要存储和重用 Cursor,开发人员需要编写代码来处理活动停止等事件。

SQLiteCursor 不知道数据已更改

SQLiteCursor 不会跟踪数据库在第一个窗口读取(和第一次计数)后是否发生更改。这意味着如果添加或删除某些项目,SQLiteCursor 的缓存大小是不正确的——对于边界检查和您是否希望加载的数据看起来一致都是一个问题。在移动到不再存在的行时,这可能会导致异常,或者在某些情况下数据不一致。例如,如果您已经加载了第 N 行并且在位置 0 处插入了一个新项目,然后您尝试读取第 N+1 行,您最终将再次加载旧的第 N 行。

避免问题

上述问题告诉我们 SQLCursor 不能扩展到具有数千个结果的查询。幸运的是,这些问题都有一个简单的解决方法:小查询。适合单个 CursorWindow 的查询避免了所有问题,这就是为什么我们在 Paging 和 Room 中如此青睐它们的原因。将页面大小配置为仅 10 到 20 个项目,并且一次仅查询那么多项目是很常见的。

不过,选择页面大小有很多影响 - 较大的查询(一个窗口大小)通常会提高性能,而较小的查询会改善延迟和内存。对于 DB 不是性能瓶颈的长列表,10 项可能有意义,而如果您的列表项是小块,或者您的查询很昂贵,则 300 可能更好。

如果您依赖 SQLiteCursor 的内部分页来延迟加载更大的结果集,我们建议您切换到另一种方法。要么使用新的分页库及其与Room Persistence Library 的集成,要么使用您自己处理分页的自定义实现,并确保您的查询结果足够小以适合单个 CursorWindow。

要使用新的分页库在 Room 中使用小查询对大 SQL 查询结果进行分页,您可以将以下代码:

@Dao
interface UserDao {
    // regular list query — falls over with too much data
    @Query(“SELECT * FROM user ORDER BY age DESC”)
    LiveData<List<User>> loadUsersByAgeDesc();
}

更改为:

@Dao
interface UserDao {
    // paged query — handles arbitrarily large queries
    @Query(“SELECT * FROM user ORDER BY age DESC”)
    DataSource.Factory<Integer, User> loadUsersByAgeDesc();
}

然后您将其传递给一个 LivePagedListBuilder 以获得 LiveData<PagedList>,用来处理任意大小的结果集合:

LiveData<PagedList<User>> users = new LivePagedListBuilder<>(
        userDao.loadUsersByAgeDesc(), /*page size*/ 20).build();

在上面的代码中,我们获得了分页查询结果的LiveData版本,当数据库发生变化时,它也会更新任何订阅的观察者。要了解有关使用架构组件从 SQLite 进行分页的更多信息,请参阅分页介绍Github 上分页示例

Android 平台 — Android P 更新!

由于本文最初发布,我们在 Android P 开发者预览版中添加了新的 API 以改进上述行为。该平台现在允许应用程序禁用窗口启发式的 ⅓配置 CursorWindow 大小。小查询仍然是避免上述所有问题的好方法,但 P 更改为应用程序提供了更多控制权,我们很快将在 Room 中使用它们以保持查询尽可能高效。


拓展阅读

https://stackoverflow.com/questions/14468586/efficient-paging-in-sqlite-with-millions-of-records/14468878

http://blog.ssokolow.com/archives/2009/12/23/sql-pagination-without-offset/

https://www.techyourchance.com/data-paging-lists/ (包含一个有关原文的可运行benchmark表格程序)

androidx.Paging 使用了 kotlin 来写,这不是我想要的,好端端的java被弄成类似JavaScript的样子,但又根本不是JavaScript那样的动态语言,只得其形不得其神。

实际上可以自己实现类似的效果。官方的demo名为 PagingSample,展示的是一用个recycleview瀑布流显示数据库记录,十分流畅。

根据sqlite的文档,当存在索引时,利用索引排序进行逐量分页查询的方法,是非常快的(二分法,即使是亿级的体量,即使在低端存储设备,仍然很快)。

   CREATE INDEX example1 ON tracks(singer, title);
    SELECT title FROM tracks
     WHERE singer='Madonna' // 匹配条件
       AND title>:lasttitle  // 利用索引中的排序,实现逐量分页查询
     ORDER BY title
     LIMIT 5; // 一次只查一点点,所以速度很快

此文档还说,不要用 OFFSET 命令实现这样的效果,因为 OFFSET 没有用二分法,而是一步步地跳过,类似于链表。当匹配条件的数据量很大的时候,效率会变很低。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,287评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,346评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,277评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,132评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,147评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,106评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,019评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,862评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,301评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,521评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,682评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,405评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,996评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,651评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,803评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,674评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,563评论 2 352

推荐阅读更多精彩内容