SQLite 基础与事务:从订单扣减看 Android 本地一致性-《Android深水区(十五)》

4 阅读10分钟

SQLite 基础与事务:从订单扣减看 Android 本地一致性

前言

本地数据库最危险的 bug 往往不是 SQL 写错,而是把几条“各自正确”的语句拆开执行:订单已写入,库存却没扣;升级脚本把旧用户的数据变成了空表;主线程一次查询在低端机上拖慢首帧。

SQLite 解决的是结构化、可查询、可索引的数据持久化,事务解决的是“一组相关变更要么一起成功,要么一起不生效”。它不是网络分布式事务,也不能让 UI 线程免于 I/O。本文以 Android SDK API 35 和 2026-08-23 复查的 AOSP main 为范围,先讲原生 SQLite 的边界,再为下一篇 Room 的抽象铺路。

先给工程结论:新业务通常优先选 Room;理解 SQLite 仍然必要,因为 Room 的表、索引、SQL、迁移和事务语义都以它为基础。只有遗留模块、非常小的底层封装或需要直接控制 SQLite API 时,才直接使用 SQLiteOpenHelper / SQLiteDatabase

前置知识

  • 知道关系型数据库的表、行、主键、索引、WHEREJOIN
  • 了解应用私有目录中的持久化数据;能区分键值状态文件与可查询数据集。
  • 理解协程不等于自动切换线程:数据库 I/O 仍应离开主线程。

核心概念

何时该用 SQLite

数据形状更合适的方案原因
主题、开关、少量整体设置DataStore不需要 SQL 查询或索引
待办、缓存、搜索、订单、离线列表SQLite / Room需要筛选、排序、分页、关联与局部更新
文件、图片、音视频大对象文件系统 + 数据库元数据不应把大二进制内容当作常规表字段
跨进程或云端一致性专门的 IPC / 服务端协议单个 app 数据库的事务范围不覆盖它们

SQLite 的 schema 是数据契约:表名、列名、类型、约束和索引共同定义“哪些数据可以存在、怎样被检索”。Android 官方文档也建议将表和列名集中在 contract 类或同等位置,避免 SQL 字符串散落后无法同步修改。

事务的最小模型是:beginTransaction() 开始,全部语句成功后调用 setTransactionSuccessful(),无论结果如何都在 finallyendTransaction()。只有外层事务结束且所有层级都标记成功时,改动才提交;否则回滚。SQLiteDatabase API

整体架构

flowchart LR
  UI[UI / ViewModel] --> Repo[Repository]
  Repo --> Helper[SQLiteOpenHelper]
  Helper --> DB[SQLiteDatabase]
  DB --> Session[Thread-local SQLiteSession]
  Session --> Pool[SQLiteConnectionPool]
  Pool --> Files[db / journal or WAL files]
  Repo -->|query result| UI

Repository 定义业务动作,例如“创建订单并扣减库存”;Helper 只负责打开、建表和升级;SQLiteDatabase 是 app API 外观;AOSP 再把当前线程的事务交给 SQLiteSession 与连接池。把 SQL 留在数据层,能防止 Activity/Composable 自己拼表名、持有 Cursor 或泄漏数据库连接。

工作流程

1. 建库和升级是两个不同的时刻

SQLiteOpenHelper 首次创建数据库时调用 onCreate();已有数据库的版本低于构造器传入版本时调用 onUpgrade()。因此建表和升级脚本都必须可审查:新装用户只走 onCreate(),老用户只走对应的升级路径。不要通过“先删表再建表”处理正式数据升级,除非产品明确允许丢失全部本地数据。

2. 事务收拢业务不变量

以下订单动作的业务不变量是:成功时“订单存在且库存减少”,失败时“二者都保持原状”。把查询、条件判断、更新和插入放在同一个事务里,才能将这个不变量交给数据库维护。

sequenceDiagram
  participant R as Repository
  participant DB as SQLiteDatabase
  participant S as SQLiteSession
  R->>DB: beginTransactionNonExclusive()
  DB->>S: beginTransaction(IMMEDIATE)
  R->>DB: UPDATE products ... stock > 0
  R->>DB: INSERT INTO orders ...
  R->>DB: setTransactionSuccessful()
  R->>DB: endTransaction()
  DB->>S: commit; otherwise rollback

setTransactionSuccessful() 不是“提交”本身;它只是给当前事务标记成功,实际提交或回滚发生在 endTransaction()。因此 finally 不能省略。嵌套调用也不是让内部事务独立提交:以最外层结束时的状态为准。

API 使用

1. 以 Helper 管理 schema

示例为便于理解直接使用原生 API;生产新模块应在下一篇介绍的 Room 中表达同样的表和事务。

private const val DB_NAME = "shop.db"
private const val DB_VERSION = 2

class ShopDbHelper(context: Context) : SQLiteOpenHelper(
    context.applicationContext,
    DB_NAME,
    null,
    DB_VERSION,
) {
    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL(
            """
            CREATE TABLE products (
                id INTEGER PRIMARY KEY,
                name TEXT NOT NULL,
                stock INTEGER NOT NULL CHECK(stock >= 0)
            )
            """.trimIndent(),
        )
        db.execSQL(
            """
            CREATE TABLE orders (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                product_id INTEGER NOT NULL,
                created_at_ms INTEGER NOT NULL,
                FOREIGN KEY(product_id) REFERENCES products(id)
            )
            """.trimIndent(),
        )
        db.execSQL("CREATE INDEX index_orders_product_id ON orders(product_id)")
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        if (oldVersion < 2) {
            db.execSQL("CREATE INDEX index_orders_product_id ON orders(product_id)")
        }
    }
}

建表常量不应直接来自用户输入。值使用 ContentValues? 占位符或带参数的查询 API;表名、列名和 ORDER BY 片段则应来自受控常量/白名单。占位符能绑定值,但不能替你校验任意 SQL 结构。

2. 用一次原子更新完成下单

库存扣减直接写成带条件的 UPDATE,并检查受影响行数;这样避免“先查库存再扣库存”之间被另一任务抢先修改。insertOrThrow() 失败时抛异常,未标记成功的事务会由 endTransaction() 回滚。ContentValues 用于绑定常量值;列计算 stock = stock - 1 则需要参数化 SQL 或更高层的 Room DAO。

完整实现如下:

suspend fun placeOrder(productId: Long) = withContext(Dispatchers.IO) {
    helper.writableDatabase.useTransaction { db ->
        db.compileStatement(
            "UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0",
        ).use { statement ->
            statement.bindLong(1, productId)
            check(statement.executeUpdateDelete() == 1) { "库存不足或商品不存在" }
        }
        db.insertOrThrow(
            "orders", null,
            ContentValues().apply {
                put("product_id", productId)
                put("created_at_ms", System.currentTimeMillis())
            },
        )
    }
}

private inline fun <T> SQLiteDatabase.useTransaction(block: (SQLiteDatabase) -> T): T {
    beginTransactionNonExclusive()
    return try {
        block(this).also { setTransactionSuccessful() }
    } finally {
        endTransaction()
    }
}

扣减条件、订单写入和成功标记处于同一个事务。compileStatement() 用于复用预编译的、无结果集的语句;不要跨线程共享同一个 SQLiteStatementAPI reference

3. 查询后及时关闭 Cursor

fun readOrderCount(productId: Long): Long = helper.readableDatabase.rawQuery(
    "SELECT COUNT(*) FROM orders WHERE product_id = ?",
    arrayOf(productId.toString()),
).use { cursor ->
    check(cursor.moveToFirst())
    cursor.getLong(0)
}

use 将 Cursor 的关闭路径与读取位置放在一起。列表查询还应限制投影列、分页并为过滤/排序列建索引;不要因为 Cursor 能懒读取,就在主线程遍历一个大结果集。

源码分析

AOSP 调用链:事务绑定到当前线程的 Session

以 AOSP main 的 frameworks/base 为例,公开 API 并不直接在 SQLiteDatabase 中拼接 BEGIN/COMMIT 字符串:

层级路径与方法职责
App API.../SQLiteDatabase.java beginTransactionNonExclusive()选择非独占入口,内部转换为 TRANSACTION_MODE_IMMEDIATE
App APISQLiteDatabase.beginTransaction(listener, mode)acquireReference() 后经 getThreadSession() 调用 Session
线程事务.../SQLiteSession.java beginTransaction()校验当前事务状态,取得连接并维护事务栈
线程事务SQLiteSession.setTransactionSuccessful() / endTransaction()标记成功;在结束时根据栈状态提交或回滚
连接协调.../SQLiteConnectionPool.java协调连接获取;WAL 开启时可支持不同连接上的并发查询

因此同一 SQLiteDatabase 对象不等于“所有语句天然处在同一事务”。事务状态由当前线程的 SQLiteSession 持有;在事务中提交给数据库的查询会使用同一数据库句柄。不要开始事务后切换到另一个线程继续发 SQL,也不要把一个 SQLiteStatement 同时给多个线程使用。

beginTransaction() 对应 EXCLUSIVE,beginTransactionNonExclusive() 对应 IMMEDIATE;API 35 还提供只读的 DEFERRED 事务入口。选型时先表达业务需求,不要把“NonExclusive”误解为多写者可同时提交——SQLite 仍需要协调写入,只是 WAL 等模式可以让读与写的并发关系更友好。AOSP SQLiteDatabase.javaSQLiteSession.java

实战案例

给遗留数据库加“安全下单”回归测试

测试重点不是断言某条 SQL 被调用,而是验证失败后没有半成品。可用仪器测试在真实 SQLite 上准备库存为 1 的商品:

  1. 第一次 placeOrder() 成功后,断言库存为 0、订单数为 1。
  2. 第二次调用抛出“库存不足”,断言库存仍为 0、订单数仍为 1。
  3. 临时让订单插入违反约束,断言库存不会被永久扣减。
  4. 用 Android Studio Database Inspector 检查表、索引和测试数据;其可查看原生 SQLite 与 Room 数据库(通常要求 API 26+ 的设备/模拟器)。

这四个断言把事务从“看起来包住了代码”变成可回归验证的业务契约。正式项目还应在 Repository 边界记录失败原因,而不是把 SQLiteException 直接显示给用户。

性能优化

  • 先测再改。 用 Database Inspector、trace 和真实数据量确认慢点;不要只因“听说 WAL 更快”就改全局配置。
  • 索引服务于查询。 为稳定的 WHEREJOINORDER BY 组合建索引;索引会增加写入和磁盘成本,重复索引反而有害。
  • 缩短事务。 事务内只放必须一起成功的 SQL;网络、解码、图片处理和用户交互放在事务外,避免长时间占用连接。
  • 选择 WAL 前检查限制。 Android API 文档说明 WAL 可让不同线程的读在写入期间继续看到写前快照,并减少部分同步等待;但附加数据库、只读库和内存库等场景不支持它。它会增加内存、日志和 checkpoint 成本,必须按实际设备与负载验证。WAL API
  • 批量写入合并为一次事务。 多条独立提交会反复产生事务开销;在内存中准备好一批记录后再入库,但控制单次批量大小,避免长事务卡住其他工作。

常见问题

为什么不用 execSQL("BEGIN") 自己管理事务?

应优先使用 beginTransaction* / setTransactionSuccessful / endTransaction。框架 API 会将事务纳入当前线程的 Session 和连接协调,finally 结构也更不容易遗漏回滚路径。

beginTransactionNonExclusive() 是否意味着不会锁库?

不是。它使用 IMMEDIATE 语义;写入仍需要串行协调。它的价值是配合 WAL 改善读写并发,而不是让多个写事务同时修改同一份数据。

能否在主线程执行很小的查询?

不要把“今天很小”当成线程策略。数据增长、慢闪存和锁竞争都会改变耗时;统一经 Repository 在 Dispatchers.IO 或 Room 的 suspend DAO 执行,更容易审计。

新项目还要直接使用 SQLiteOpenHelper 吗?

通常不需要。官方推荐 Room 作为 SQLite 抽象层,因为它能提供 SQL 编译期校验、减少对象映射样板并简化迁移。直接 SQLite 的知识仍用于设计表、诊断查询和维护遗留代码。Android Developers: Save data using SQLite

面试考点

  1. 为什么 setTransactionSuccessful() 后仍必须在 finally 调用 endTransaction()
  2. “查询库存再更新”和“带 stock > 0 条件的更新”在并发下有什么区别?
  3. beginTransaction()beginTransactionNonExclusive() 的事务模式分别是什么?
  4. WAL 如何改变读写并发可见性?它有哪些限制和代价?
  5. SQLiteOpenHelper.onCreate()onUpgrade() 分别何时调用?为什么不能用删表重建代替所有迁移?
  6. Room 相比直接 SQLite 解决了哪些问题,又没有替你解决哪些数据库设计问题?

总结

SQLite 的重点不是记住所有 SQL,而是把数据形状、schema、线程和事务边界对齐。用 SQLiteOpenHelper 管理版本,用参数绑定处理值,用 try/finally 保证结束事务,用条件更新保护并发不变量,用索引和 WAL 的实测结果指导优化。

下一篇 会将这些基础映射到 Room 的 Entity、DAO 和 Migration:Room 减少样板与 SQL 风险,但表设计、事务范围和升级验证仍需要由工程师负责。

扩展阅读