ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android SQLite数据库锁冲突实战:从崩溃到根治的完整排查指南

Android SQLite数据库锁冲突实战:从崩溃到根治的完整排查指南 一次线上发版后的崩溃报表让我印象极深某个版本上线第一天SQLiteDatabaseLockedException: database is locked的报错数量直接冲进 Top 3用户端的表现就是快速操作某个页面时偶发闪退、保存失败。这类android 数据库锁问题在开发和测试阶段很难稳定复现但只要用户量上来多线程并发访问数据库的场景一多锁冲突就会像潮水一样涌出来。这篇就围绕我在实际项目里排查和修复database is locked的完整过程展开从 SQLite 锁机制本身讲起到根因定位链路再到真正能落地的解决方案希望能帮你少踩几个坑。1. 先复现问题崩溃日志背后的真实场景1.1 崩溃堆栈与现场描述当时崩溃平台抓到的典型堆栈长这样android.database.sqlite.SQLiteDatabaseLockedException: database is locked at android.database.sqlite.SQLiteDatabase.throwException(SQLiteDatabase.java:1205) at android.database.sqlite.SQLiteDatabase.execSQL(SQLiteDatabase.java:1385) at com.example.app.data.local.NoteDao.insert(NoteDao.java:45) at com.example.app.viewmodel.EditorViewModel.saveNote(EditorViewModel.java:120)崩溃发生在execSQL的写入操作上。用户操作路径是在笔记编辑页快速打字、频繁点保存同时后台还有自动同步任务在跑。看起来就是多线程在同一个时间段内写数据库引发的冲突但问题的关键不在表面上而在于为什么 SQLite 明明有锁机制还会抛异常以及我们应该在哪里兜住这个异常。1.2 为什么锁反而成为崩溃源大多数人对数据库锁的理解是锁能保护数据一致性和并发安全但在 Android 的 SQLite 场景下锁本身并不能保证你的 App 不崩。SQLite 的锁是文件级锁多个数据库连接访问同一个数据库文件时靠锁协议协调读写当一个连接持有了写锁另一个连接尝试写入时默认行为是等待busy_timeoutAndroid 默认为 0也就是不等待立即返回失败或者直接抛SQLiteDatabaseLockedException。所以你会看到一种反差锁保证了数据不会写坏但并发的写入请求并不会自动排队成功而是直接以异常告终。这也是为什么很多项目在低并发下跑得好好的用户量和后台任务一多就崩。2. SQLite 锁模型拆解搞清楚它是怎么竞争和死锁的2.1 锁的五个状态与状态迁移SQLite 的锁机制在回滚日志模式默认 journal 模式下有五个状态UNLOCKED、SHARED、RESERVED、PENDING、EXCLUSIVE。我用自己的话翻译一下UNLOCKED无锁任何连接都可以尝试读或写。SHARED读锁。可以有多个连接同时持有SHARED锁大家都能读互不干扰。RESERVED一个连接准备写但还没真正开始写。拿到RESERVED锁后其他连接仍然可以读允许持SHARED锁但不能再拿到新的RESERVED锁也就是只允许一个写者排队进场。PENDING写者准备真正修改数据库了此时它要阻止其他连接再获取SHARED锁为升级到EXCLUSIVE做准备。EXCLUSIVE排他锁持有者可以写其他连接既不能读也不能写。当两个连接同时想要写A 拿到了RESERVEDB 也想拿RESERVED时B 会进入等待或失败状态。如果 A 在持有RESERVED时去读共享缓存shared cache而 B 持有了SHARED锁就可能出现互相等待的僵局。在 Android 的多线程数据库访问中最常见的就是一个写事务还没提交另一个连接又发起写请求后者超过超时时间后直接抛锁异常。2.2 Android 里每个连接到底是什么Android 中SQLiteDatabase的实例就是一条数据库连接。SQLiteOpenHelper.getWritableDatabase()底层会返回一个缓存的SQLiteDatabase实例但如果你在代码里 new 了多个SQLiteOpenHelper或者通过getReadableDatabase()和getWritableDatabase()分别拿到不同实例就可能产生多个连接同时操作同一个库文件。多连接并发访问同一个.db文件时锁的冲突概率成倍上升。// 错误示范同一个数据库被实例化了多个Helper连接 SQLiteOpenHelper helperA new NoteDbHelper(context); SQLiteOpenHelper helperB new NoteDbHelper(context); SQLiteDatabase dbA helperA.getWritableDatabase(); SQLiteDatabase dbB helperB.getWritableDatabase();上面的代码里dbA和dbB在极端情况下可以同时打开同一个数据库文件一个正在写、一个也抢着写锁冲突就来了。更隐蔽的是很多第三方库会自己实例化一个SQLiteOpenHelper你完全看不到它的生命周期于是业务代码里的普通 insert 和 SDK 内部的写操作就在文件层面互相打架。2.3 锁超时与 busy handler默认配置的坑Android 的SQLiteDatabase在创建时默认busy_timeout是 0关闭了等待逻辑。这意味着一旦锁冲突数据库立刻抛异常。虽然我们可以在遇到锁时捕获异常做重试但重试并不能解决多连接并发写的根源问题。更合理的做法是从架构上避免多连接同时写同一个库并把锁等待时间调到一个可控的范围内见后面的解决方案。3. 线上问题排查链路复盘我是怎么一步步找到根因的3.1 排查第一步确认写操作的调用线程与频率我先在崩溃发生前的日志里找到了 saveNote 的调用堆栈加上线上埋点信息观察到崩溃集中在两类线程主线程上的保存操作用户点保存按钮后台ScheduledExecutorService里的自动同步任务。也就是说同一时刻极有可能存在两个线程同时调用NoteDao.insert()。但仅凭这一点还不够因为如果它们使用同一个SQLiteDatabase实例Android 框架内部其实有ReentrantLock保证同一实例上的写操作是串行的。所以我需要验证是否出现了多个SQLiteDatabase实例。3.2 排查第二步确认数据库实例是否唯一我写了一个临时的 debug 工具在崩溃前记录当前数据库实例的内存地址和调用栈fun debugDbInstance(tag: String) { val db NoteDbHelper.instance.writableDatabase Log.d(DbDebug, tag$tag, db$db, hashCode${db.hashCode()}) Log.getStackTraceString(Throwable()) }实测发现笔记编辑页和后台同步任务拿到的SQLiteDatabase实例不一样。进一步查代码发现编辑页在自定义Application中初始化了一个NoteDbHelper而同步任务所在的SyncManager是自己又 new 了一个 helper于是形成了两个独立连接同时操作同一个notes.db文件。3.3 排查第三步检查是否存在长事务即使只有一条连接长事务也会把锁长时间攥在手里导致其他连接崩溃。继续追查 trace发现自动同步的逻辑里有一段不太起眼的代码db.beginTransaction(); try { // 网络请求、JSON解析、批量插入全在一个事务里 fetchFromServer(); parseAndInsert(); db.setTransactionSuccessful(); } finally { db.endTransaction(); }问题很明显fetchFromServer()是网络 I/O可能耗时几百毫秒甚至几秒而事务从beginTransaction()开始就持有了RESERVED写锁。网络慢的时候写锁被牢牢占住其他连接一旦写入必然锁冲突。这类事务嵌套、长事务问题在代码 review 时不容易发现但线上却会实打实地引燃崩溃。3.4 排查第四步区分真锁与伪锁还有一类database is locked其实是缓存未释放或数据库文件损坏的假象。我用以下方法区分使用PRAGMA integrity_check检查数据库完整性查看同一个库文件是否被多个进程打开此时锁范围扩大到进程级SQLiteDatabase的实例锁已经无法保护检查数据库文件所在目录是否被某些文件同步工具占用导致锁文件无法创建。在我们的场景里最核心的根因还是多连接并发写 长事务持有锁两个因素叠加崩溃率才会那么刺眼。4. 解决方案落地从代码修复到架构治理4.1 全局单例化数据库连接第一步是把SQLiteOpenHelper收敛为全局单例保证整个 App 进程内只有一条SQLiteDatabase写入连接。最简单的方式是在Application或依赖注入容器中提供单例public class App extends Application { private static NoteDbHelper noteDbHelper; public static NoteDbHelper getNoteDbHelper() { if (noteDbHelper null) { synchronized (App.class) { if (noteDbHelper null) { noteDbHelper new NoteDbHelper(getContext()); } } } return noteDbHelper; } }这一步解决的是连接泛滥问题。同一个SQLiteDatabase实例内部有ReentrantLockAndroid 框架会保证同实例上的写操作按顺序执行所以单例化之后大部分并发写冲突会自然消失。提示单例化只对同一进程内有效。如果你的 App 使用了多进程比如android:process:remote不同进程之间不能共享同一个SQLiteDatabase实例此时文件级锁依然存在只能靠 WAL 模式、事务拆分和串行队列来规避。4.2 事务必须短小精悍长事务里绝对不能夹带网络请求、文件读写、JSON 解析等耗时不定的操作。正确姿势是先准备好所有数据再开短事务做批量写入。// 正确示范网络请求在事务外完成 ListNote notes fetchFromServer(); // 网络耗时在事务外 JSONArray parsed parseNotes(notes); // 解析在事务外 db.beginTransaction(); try { for (Note item : parsed) { db.insert(notes, null, item.toContentValues()); } db.setTransactionSuccessful(); } finally { db.endTransaction(); }事务里只保留数据库操作这是降低锁持有时间的铁律。即使事务外准备数据耗时较长数据库也是安全的因为写锁还没开始持有。4.3 开启 WAL 模式读写并行互不阻塞WALWrite-Ahead Logging模式把写入操作先追加到-wal文件不会直接修改主数据库文件所以读写之间可以并行。这样读操作不会因为写锁而阻塞写操作也不会因为已有读操作而无法进行。Android 中开启方式很简单db.enableWriteAheadLogging();如果用 Room可以在构建数据库时配置Room.databaseBuilder(context, AppDatabase.class, notes.db) .setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) .build();实测下来这个改动对读写并发场景立竿见影。原本在编辑页快速滑动时后台在写偶尔会卡顿甚至崩开启 WAL 后用户基本无感知。需要注意的是WAL 模式下第一个连接的onConfigure里需要调用setWriteAheadLoggingEnabled(true)而不是在onOpen里调用相关写法参考官方SQLiteOpenHelper的注释即可。4.4 使用串行队列让所有数据库操作排队执行即使有了单例和 WAL我仍然建议在业务层把数据库操作统一放到一个串行线程池中让所有写操作绝对按顺序执行。这不是 SQLite 要求的但它能让你从意外锁冲突中彻底解放出来。我用一个单线程ExecutorService或HandlerThread就能实现private final ExecutorService dbExecutor Executors.newSingleThreadExecutor(); // 所有数据库操作提交到这个 executor public void executeDbTask(Runnable task) { dbExecutor.execute(task); }所有调用处的写法也统一public void saveNoteAsync(Note note) { dbExecutor.execute(() - { SQLiteDatabase db NoteDbHelper.getInstance().getWritableDatabase(); db.insert(notes, null, note.toContentValues()); }); }串行队列配合全局单例双保险。即使将来有新的同学不小心 new 出第二个 helper只要所有业务代码都通过同一个 executor 提交就不会出现同一时刻多个线程抢占同一把写锁的问题。5. 进阶场景与容易忽略的边界5.1 Room 也会报 database is locked 吗会。Room 默认使用FrameworkSQLiteOpenHelper管理数据库连接如果它在多线程环境下同时进行事务操作依然可能因为底层写锁冲突而抛异常。不过 Room 的Transaction注解方法会在内部保证同一事务中的操作顺序更多的时候问题还是出现在自定义SupportSQLiteDatabase直连或复杂事务嵌套里。5.2 多进程访问数据库锁冲突绕不过去如果你的 App 有多个进程比如播放器进程、推送进程各自持有一个数据库连接SQLite 文件锁会在进程级产生竞争。此时除了 WAL 模式还要谨慎使用MULTI_PROCESS标志默认不推荐因为它会导致每一轮操作都重新打开数据库性能很差。更稳妥的方案是单进程持有数据库其他进程通过 ContentProvider、AIDL 或 Broadcast 进行数据读写。5.3 数据库锁相关 ANR 的排查锁等待超过 5 秒还会导致 ANR。特别是主线程直接调用数据库写操作此时另一个线程持有了写锁主线程会卡住。所以永远不要在主线程做可能被锁阻塞的数据库写操作。即使不开 WAL数据库锁导致的 ANR 也很常见排查时可以抓取 ANR trace搜索SQLiteDatabase与lock关键字定位。5.4 SQLCipher 场景下的锁使用 SQLCipher 做加密数据库时锁机制和普通 SQLite 原理一致但加密解密的 CPU 开销会让事务执行时间变长从而放大锁冲突概率。这种情况下除了上面的方案还可以适当增大busy_timeout例如设为 3000ms把批量插入按 500 条一批切分避免在事务中使用db.query后再插入同一张表防止SHARED锁到RESERVED锁的升级尴尬。5.5 配置 busy_timeout虽然不建议把锁冲突完全寄托在超时重试上但调大busy_timeout仍然是值得做的一步。SQLite 的busy_timeout可以指定一个连接等待锁的毫秒数超过后才会抛错。在打开数据库时执行db.execSQL(PRAGMA busy_timeout 3000);这样遇到短暂锁竞争时会自动等待而不是立刻崩溃。这个配置适合绝大多数时候锁冲突都在毫秒级的业务但如果核心问题没有从连接管理和事务设计上解决单纯调大超时只能降低崩溃率无法根治。6. 测试与监控如何防止锁问题悄悄复发6.1 并发压力测试修复之后我在项目里加了一个简单的并发压测用例开 8 个线程同时向同一个库插入 5000 条数据并在不同线程里随机执行查询。压测代码长这样Test public void concurrentWriteTest() { final int threadCount 8; final CountDownLatch latch new CountDownLatch(threadCount); for (int i 0; i threadCount; i) { new Thread(() - { for (int j 0; j 5000; j) { db.insert(notes, null, createContentValues()); } latch.countDown(); }).start(); } latch.await(); }修复前这个测试几乎必现SQLiteDatabaseLockedException修复后稳定通过。如果你不想自己造轮子也可以直接用Room的测试库androidx.test:core配合JUnit4跑 JVM 测试但要注意纯 JVM 环境下的 SQLite 行为与真机略有差异最终还是要上模拟器或真机验证。6.2 线上监控与崩溃分诊线上建议对SQLiteDatabaseLockedException单独做崩溃分组并埋点记录数据库名称、写入线程名、当前事务嵌套层数、上次写操作持续时间。这样即使未来出现新的锁问题也能凭借元数据快速定位。我用的是自研日志上报把关键信息拼到一条 extraMessage 里崩溃平台能直接按 Tag 分组查看。6.3 代码 Review 的数据库红线最后把我们内部沉淀的几条约定分享出来每条都是从这次问题里总结出来的数据库连接必须全局单例禁止 new 多个SQLiteOpenHelper事务内禁止网络请求、文件读写、JSON 解析所有数据库写操作走同一个串行执行器默认开启 WAL 模式getWritableDatabase()和getReadableDatabase()不要在不同线程混用并做写入操作遇到非关键 insert 失败可以捕获SQLiteDatabaseLockedException做一次重试但重试上限为 1 次并打日志避免隐藏更大问题。7. 我最后的一点实际体会这次锁问题的修复过程让我重新理解了并发在移动端的微妙性。很多崩溃不是某个单点代码写错了而是多个看似正确的模块叠加后产生了资源竞争。数据库锁尤其典型每个连接在自己的逻辑里都是正确的但连接之间没有协调好就会在文件层面互相卡住。建议每个项目在启动之初就定好数据库访问的交通规则而不是等崩溃报表教育你。如果你们项目里也已经出现database is locked先别急着在代码里 catch 异常按照连接是否单例 - 事务是否短小 - 是否开启 WAL - 是否串行执行的顺序排查一遍大多数问题都能在半小时内定位。
返回列表