
1. 先说清楚Android数据库锁到底是在锁什么做Android开发这么久几乎没人敢说自己从没遇过database is locked这个报错。尤其当你的应用开始有大量读写操作、或者你引入后台任务频繁操作数据库之后这个报错出现的概率会直线上升。先别急着改代码先弄明白一个核心问题Android自带的数据库是SQLite它到底是怎么处理并发的跟MySQL、PostgreSQL这类服务型数据库不同SQLite是一个嵌入式数据库它没有独立的服务进程你的App进程就是数据库进程。这就意味着SQLite的并发控制策略完全靠文件锁机制来实现——锁的对象本质上是对数据库文件以及后续会提到的WAL模式下的索引文件的读写权限。当你打开一个SQLiteDatabase时实际上你打开的是底层单例的SQLiteConnection当你有多个线程同时读写时如果没有正确的并发控制策略极容易触发SQLiteDatabaseLockedException。这个异常往往表现为android.database.sqlite.SQLiteDatabaseLockedException: database is locked (code 5)这里有个极其容易踩的坑很多人在主线程直接调数据库操作只看到ANR或者卡顿没往锁的方向想。其实锁冲突未必一定报异常也有可能是你的UI线程在等待锁释放然后一直等到超时表现为界面无响应。所以理解Android数据库锁不只是为了处理报错信息更多时候是为了搞清楚“数据库操作为什么慢”“为什么并发高了性能就崩”这些更隐蔽的问题。2. 锁模型的底层原理别把SQLite当网络数据库用要彻底搞清楚Android的数据库锁必须知道SQLite的五种锁状态。别看网上一堆文章列那张状态表我建议你自己亲手跑一下日志看看过程才能真正记住。2.1 SQLite的五种锁状态SQLite的锁是围绕文件锁做的锁状态从弱到强分别是UNLOCKED无锁状态任何线程都可以读文件。SHARED共享锁允许并发读不允许写。可以多个线程同时持有SHARED锁。RESERVED预留锁。当某个连接准备写入但还没真正开始写时会先获得RESERVED锁。此时其他的读操作依然可以进行但新的写连接无法再获取RESERVED锁。PENDING挂起锁。连接准备真正写入时从RESERVED升级为PENDING。此时不再允许新的SHARED锁获取——目的就是等已有的读操作结束后把锁升级为EXCLUSIVE。EXCLUSIVE排它锁。写入进行中所有读都被禁止。从这套状态机里能看出几个关键点SQLite的写操作永远只有一个连接能进入EXCLUSIVE状态其他写操作必须等待。在一个连接持有RESERVED锁的时候另一个连接虽然也能读但不能写更不能从SHARED升级到RESERVED。读操作之间是并发的不会互相阻塞。所以你在做一个“读多写少”的场景时SQLite本身是能够胜任的。怕的是写多读也多又没做串行化控制锁竞争就会变得非常激烈。2.2 为什么明明没写操作也会锁很多人遇到过这种情况自己没写任何SQL只是连续执行了多个查询莫名其妙还是报database is locked。这里有个容易忽视的细节SQLite的锁不只是“写的时候”锁。当你执行一条事务时即使只执行了读操作事务也会把这个连接从UNLOCKED状态推进到SHARED状态。如果在同一时刻另一个连接正好在写持有PENDING或EXCLUSIVE那么你的读事务也需要等待。另外一个原因是SQLite的锁升级是需要时间的。在默认的journal modeDELETE模式下如果一个写事务已经进入了EXCLUSIVE状态此时所有其他连接的读操作都会被阻塞一直等到写事务完成并提交。我自己早期踩过一次非常不明显的坑有一个后台服务的定时任务每30秒执行一次DELETE INSERT来刷新缓存表。表面上看这个任务量不大但它把写锁持有时间拖得很长——因为那一次要删除上万条记录、再插入上万条记录。结果就是UI线程偶尔打开数据库查询时恰好撞上后台任务持锁直接报locked异常。所以大家排查问题时千万不要只盯着前台代码是不是写太多后台任务、定时器、推送回调里隐含着数据库写入这些才是隐形锁源。2.3 journal modeDELETE / TRUNCATE / PERSIST / WAL锁的行为跟事务日志模式强相关。SQLite支持好几种journal modeAndroid框架层默认的是DELETE模式。DELETE模式的特点是写事务开始时创建-journal文件写事务提交时将journal文件删除在删除journal文件之前其他连接无法获得写锁甚至部分场景下读也会阻塞。DELETE模式虽然兼容性最好但并发性能最差。所以在Android开发中如果你的App对数据库并发读写要求较高通常建议开启WAL模式db.enableWriteAheadLogging();WAL模式全称Write-Ahead Logging写操作顺序写入一个独立的-wal文件读操作直接读主数据库文件两者互不阻塞。因此WAL模式下写不阻塞读读不阻塞写但写与写之间依然是互斥的。很多人以为开了WAL就彻底解决并发问题了其实并没有。WAL解决的是读-写冲突解决不了写-写冲突。2.4 WAL模式下的锁细节WAL模式下也存在锁只是锁到了更细的粒度WAL读锁多个读连接可以同时持有WAL写锁同一时刻只能有一个写连接WAL检查点锁执行checkpoint时可能需要独占文件如果此时有写入操作检查点会被阻塞。在WAL模式下如果两个线程同时执行写操作依然会有一个线程收到database is locked。这跟DELETE模式唯一的区别是DELETE模式下读操作也会因为写锁而阻塞WAL模式读操作不会。所以理解到这层你应该明白我的态度不要以为换个模式就能解决所有并发问题真正解决问题的方向在于你如何控制并发入口。3. 并发场景拆解你的锁问题到底是哪一种数据库锁问题不会莫名其妙出现。它一定有前置条件。我简单列几种典型场景对照一下你的项目属于哪一种。3.1 多线程同时写库最常见的一种。有多个线程同时对同一个数据库执行INSERT/UPDATE/DELETE。SQLite从设计上就让同一时刻只能有一个写事务所以当多个写请求同时到达时后到的连接必须等待。等不到就报错。等得到也可能造成阻塞时间过长。我之前接手过一个社区项目的用户反馈打开应用非常卡后台日志全是locked异常。一查代码发现有四个不同的业务模块各自维护自己的线程池各自往同一张表里写日志数据。四个模块并发写每个写操作还是批量写结果就是锁竞争严重性能极度不稳定。解决方案很简单也很暴力把数据库写入收敛到一个单线程的Executor里所有写操作都提交到这个Executor中执行。虽然牺牲了一部分并发性但换来的是稳定和可预期。3.2 读写并发读操作遇到写锁DELETE模式下一个写事务在过程中其他连接进行读操作时可能因为无法获得SHARED锁而阻塞。如果写事务耗时太长读操作甚至会被阻塞很长时间造成ANR。这种场景不仅仅出现在“线程并发”层面还非常容易出现在前台UI线程查询 后台服务批量写入。我自己的经验是写操作一定要放在子线程里不仅是为了不阻塞UI更重要的是要控制写锁的持有时间。大数据量的批量写必须分批执行比如每500条一个事务提交。一次性写500条一个事务commit没问题一次性写50000条一个事务commit锁持有时间可能就是几百毫秒甚至几秒。在锁持有期间其他连接要么等待要么超时报错。所以我习惯把所有大数据量写入都做分批事务每批500到1000条之间。3.3 跨进程访问数据库这个场景相对少见但一旦出现就是大坑。Android中可以使用android:process:xxx给Service或者ContentProvider配置单独的进程。如果两个进程同时打开同一个数据库文件并执行操作那么锁的粒度就从线程间变成了进程间。这种情况下即便你使用了WAL模式进程间的锁协调还是会通过文件锁来实现。处理不好会疯狂触发SQLiteDatabaseLockedException。如果你确实需要多进程访问同一个数据库我的建议是不要直接操作数据库文件通过ContentProvider封装所有数据访问接口由ContentProvider所在进程统一处理数据库读写这样就能回到单进程并发控制的轨道上。3.4 事务嵌套与非正常退出这个坑非常隐蔽。你可能在业务层写了这样一段代码db.beginTransaction(); try { // 业务逻辑A db.insert(...); // 业务逻辑B可能抛出异常 throw new RuntimeException(oops); db.setTransactionSuccessful(); } finally { db.endTransaction(); }这种情况下事务没有正常提交也没有被正确处理回滚。如果数据库连接没有关闭那么底层持有的锁可能一直不会被释放。此时另外一个线程再来访问数据库就会一直被阻塞。更坑的是很多OOM、进程被杀的情况数据库事务没来得及回滚SQLite底层留下了残留的-journal文件导致下次启动时SQLite执行恢复逻辑整个数据库可能处于不可用状态。这也是为什么我强烈建议所有数据库操作都收口到统一的数据访问层不要让业务代码直接操作SQLiteDatabase事务的begin/commit/rollback必须在同一层管理避免业务层手抖留下打开未关闭的事务。3.5 SQLiteConnectionPool的默认超时机制Android从API 11开始引入SQLiteConnectionPool的概念默认每个数据库连接池里有多个可用的连接。连接池如果满了新的请求会进入等待队列。// Framework层默认等待时间是 1000ms // 超过这个时间还没获取到连接就抛出SQLiteDatabaseLockedException这个1000ms是可以修改的。在SQLiteOpenHelper里可以这样设置db.setLockingEnabled(true); db.setMaxSqlCacheSize(100);但设置这个等待时间有一个“副作用”如果大量的请求同时进入等待队列即便等待时间从1000ms改到5000ms也只是把异常延后而不是消除。治标不治本的方案我一般不建议用。4. 实操环节用两个土办法解决大多数锁问题接下来这部分是我最想跟你分享的。网上关于加锁、用单线程Executor、不开事务、用WAL这类的建议已经很多了我这边不再重复说一些自己实际项目中验证过的经验。4.1 用线程池串行化写操作如果你不想引入Room、GreenDAO这类重量级框架就想要一个轻量可靠的方案我建议直接做一个全局的单线程写库Executorpublic class DbExecutor { private static final ExecutorService WRITE_EXECUTOR Executors.newSingleThreadExecutor(); public static void submit(Runnable task) { WRITE_EXECUTOR.execute(task); } }所有涉及数据库写操作的代码统一通过DbExecutor.submit()提交。这样全局只有一个线程在执行写操作当然不可能产生database is locked。但这里有细节不要让Executors.newSingleThreadExecutor无界堆积任务。如果业务方疯狂提交写任务队列会无限增长内存会先崩。建议用有界队列加拒绝策略ThreadPoolExecutor executor new ThreadPoolExecutor( 1, 1, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue(1000), new ThreadPoolExecutor.DiscardOldestPolicy() );不要在写线程里再嵌套提交写任务否则容易造成任务阻塞等待。4.2 统一使用短事务杜绝大事务长时间持有写锁是造成锁冲突的头号原因。写事务做得越短、越快锁冲突出现的概率越低。我的习惯做法是把一次数据库写操作控制在50毫秒以内完成。判断方法很简单在本地数据库的写入方法里用Debug.startMethodTracing()跑一次看看单次insert/update的耗时。如果超过了50ms就说明要么SQL写得有问题没有索引要么数据量太大。此时优先优化SQL而不是盲目放开并发。批量操作一定要分批用一个简单示例public void batchInsert(ListEntity list) { SQLiteDatabase db helper.getWritableDatabase(); int batchSize 500; db.beginTransaction(); try { for (int i 0; i list.size(); i) { db.insert(table_name, null, buildValues(list.get(i))); if (i % batchSize 0 i 0) { db.setTransactionSuccessful(); db.endTransaction(); db.beginTransaction(); } } db.setTransactionSuccessful(); } finally { db.endTransaction(); } }这段代码的关键在于每500条就提交一次事务避免事务无限膨胀。别再问我为什么不直接用db.execSQL(BEGIN TRANSACTION)这种裸SQL——beginTransaction与endTransaction的成对使用能确保在出错时自动回滚裸SQL一点容错性都没有。4.3 优先使用WAL模式但注意checkpoint问题如果你的数据库还是默认的DELETE journal mode并且你遇到了上述提到的“读操作阻塞”问题那么打开WAL模式是目前成本最低、收益最大的一种优化。public class DbHelper extends SQLiteOpenHelper { Override public void onConfigure(SQLiteDatabase db) { super.onConfigure(db); db.enableWriteAheadLogging(); db.setForeignKeyConstraintsEnabled(true); } }注意几个点enableWriteAheadLogging()在SQLiteOpenHelper的onConfigure里调用最合适因为在getWritableDatabase()时就能生效。WAL模式下synchronous默认为FULL这是为了保证持久性不要轻易改成NORMAL或者OFF除非你完全能承受数据丢失风险。WAL文件无限增长也需要关注。当没有活动事务时checkpoint会自动执行把WAL文件内容合并回主数据库。但如果你有一个长连接长期不释放也可能导致WAL文件异常增大。此时可以定期手动执行db.query(PRAGMA wal_checkpoint(TRUNCATE), null);4.4 给读操作也设置合理的超时很多开发者只关心写操作会不会锁忽略了读操作同样可以因为获取不到锁而被阻塞。在WAL模式下读不阻塞但在其他模式下读操作也可能遇到锁等待。这里推荐一个实用技巧用SQLiteDatabase#queryWithFactory或者自己封装一层为读操作加超时控制。SQLiteDatabase db helper.getReadableDatabase(); db.setBusyTimeout(500); // 500ms Cursor cursor db.rawQuery(SELECT * FROM table_name, null);setBusyTimeout作用是当数据库被其他连接锁定时当前连接最多等待500ms。如果超过这个时间仍拿不到锁就会立刻抛出异常而不是无限期等待下去。这个API可以完美避免UI线程长时间被数据库锁卡住的情况。你可以在等待超时后提示用户稍后重试或者自动再发起一次查询而不是让用户看着白屏发呆。4.5 手动重试机制不要一报错就崩溃无论你怎么优化SQLiteDatabaseLockedException依然有可能在某些极端并发场景下出现。所以我最后一道防线是对瞬时锁冲突做重试。我的做法是写一个工具类封装一个带重试机制的数据库写操作public class DbRetryUtils { private static final int MAX_RETRY 3; private static final long DEFAULT_RETRY_DELAY_MS 50L; public static T T executeWithRetry(RetryTaskT task) { int retry 0; while (true) { try { return task.run(); } catch (SQLiteDatabaseLockedException | SQLiteBusyException e) { if (retry MAX_RETRY) { throw e; } retry; try { Thread.sleep(DEFAULT_RETRY_DELAY_MS * retry); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; } } } throw new RuntimeException(unreachable); } public interface RetryTaskT { T run(); } }这里我不用固定延迟而是采用递增退避策略——第一次重试等待50ms第二次100ms第三次150ms。原因很简单如果锁还没释放你立即重试大概率还是会失败稍微延迟一下给持锁方一点时间完成提交成功率会显著提升。另外要注意SQLiteDatabaseLockedException和SQLiteBusyException都代表锁冲突但前者通常是文件被锁后者是数据库忙。在业务上可以把它们都当作可重试异常处理。4.6 关闭WAL模式的方法如果你的项目有特殊需求必须关闭WAL比如你用的某些旧版SQLite版本对WAL支持不完善那么可以通过以下方式关闭db.disableWriteAheadLogging();但说实话在API 30以上的Android版本中我几乎没有遇到过必须关闭WAL的场景。WAL带来的并发性能提升远比它那点边缘问题更值得。5. 工具选型对比Room / GreenDAO / 原生SQLite还有Framework层的问题处理数据库锁问题工具选的不好也会埋雷。这套选型思路不仅是针对锁问题也会决定后续的数据层可维护性。5.1 Room框架中的线程安全管理如果你已经在用Room那么好消息是Room在框架层帮你做了很多事它默认不允许在主线程访问数据库也提供了可观察的查询、支持协程与Flow。但这不代表Room完全免疫锁问题。Room底层依然依赖SQLite所以多线程并发写同样可能出现database is locked。不过Room官方推荐你用suspend函数 withContext(Dispatchers.IO)来访问数据库这天然将大量数据库操作放在了单一线程模型之外配合协程的并发特性锁的概率能大幅降低。如果你用了Room还频繁遇到锁问题第一件事就是检查是否所有数据库操作都用了Room的RoomDatabase.Callback有没有绕开Room直接使用底层的SQLiteDatabase5.2 尽量避免创建多个SQLiteOpenHelper实例很多人的项目里不同模块各自new了一个自己的SQLiteOpenHelper看起来好像互不干扰实际上它们指向的是同一个数据库文件。如果这些helper同时执行写操作同样会产生锁冲突。我见过最夸张的一个项目全App有7个SQLiteOpenHelper实例全部指向同一个数据库名。你想想7个连接池在各自的线程里并发写同一个文件锁冲突概率能不高吗正确做法全局唯一化SQLiteOpenHelper实例可以在Application里初始化然后用依赖注入框架管理。5.3 使用ContentProvider还是回归单线程有些全局数据库访问场景下项目采用ContentProvider来统一管理多进程访问。这个思路在系统层面是标准方案但引入ContentProvider也有一定代价跨进程IPC有性能损耗ContentProvider的query/insert/update/delete方法默认在主线程执行除非你显式放到子线程不处理好同样会卡UI。如果锁问题是主进程单进程内的问题不要盲目引入ContentProvider。先把写操作串行化、缩短事务时间大部分问题就已经解决了。6. 锁问题排查实操记录一个真实案例我挑一个之前自己项目中出现的真实问题完整复盘一下排查过程和最终解决方案希望对你后续排查同类问题有参考价值。6.1 问题现场App突然收到大量用户反馈具体现象是应用打开后首页数据加载很慢部分用户直接闪退后台报错日志里大量出现SQLiteDatabaseLockedException。从报错栈来看异常都发生在同一个数据访问类里——用户表更新消息表插入这两个操作上。6.2 排查步骤第一步先看崩溃日志。发现异常都是集中在同一时间段。继续深挖发现那段时间有一个后台任务在批量同步数据大批量写用户表和消息表。第二步看数据库操作方式。发现这个后台同步任务用的是一个独立的线程直接调用业务层封装的Dao方法但Dao方法内部并没有开启事务而是每插入一条数据就自动commit一次。于是乎一个同步任务执行下来会高频产生几千次写事务。第三步模拟复现。用一台测试机把同步任务的批量数据调大然后同时手动操作界面让UI线程触发几个读操作很快就能稳定复现database is locked。6.3 原因定位本质上就是一个高频写事务 同时读的场景。后台任务每插入一条数据就自动提交一次导致写锁释放和获取的频率极高加上UI线程可能也在尝试读数据库锁竞争变得异常激烈。6.4 解决方案我给出的修复方案包括三条数据同步任务内部改成手动批量事务每500条提交一次。这样写锁从每秒获取释放几百次降到每秒一次锁竞争压力显著下降。对UI线程的数据库读操作增加setBusyTimeout(200)避免极端情况下UI线程被长时间阻塞。全局数据库开启WAL模式让读操作不阻塞写操作。改完之后用同一台测试机、同样的数据量压测锁异常日志完全消失首页加载时间恢复到了正常水平。6.5 排查总结这类问题最大的难点不是解决而是定位。很多锁问题表面上看起来没有规律今天崩一次后天崩一次实际大概率就是后台任务和前台访问的偶发碰撞。建议如果你近期也被这个报错困扰先按这个顺序排查查所有写操作是否都在统一线程查是否有大事务或过多高频小事务查是否开启WAL查UI线程是否直接访问数据库查是否有人为了图方便绕过了框架层接口直接操作数据库文件。7. 一个容易被忽略的隐藏点ContentProvider与FileProvider看热搜词里有不少content://开头的路径比如content://com.tencent.wework.fileprovider/external_path/...这类明显是FileProvider的文件路径其实跟数据库锁没有直接关系。但这里有个容易混淆的点ContentProvider和FileProvider的名字太像了有些开发者会误以为FileProvider也能访问数据库。实际上FileProvider是专门用来向其他App分享文件的它处理的是文件URI权限不是数据库访问的封装。如果你的业务逻辑里既用了FileProvider又用了ContentProvider别把两者混在一起排查。数据库锁问题只跟数据库访问路径有关要么通过SQLiteOpenHelper持有SQLiteDatabase要么通过ContentProvider封装CRUD接口。这条路走通了锁问题的排查范围会更清晰。8. 多进程与多用户场景下的锁扩展如果你的App需要支持多用户切换比如一台设备上多个账号登录或者你使用了多进程架构数据库锁问题的复杂度会再上一个台阶。在多用户场景下每个用户的数据最好独立存储在不同的数据库文件中比如文件名带上userId后缀而不是所有用户都写同一个库。否则切换账号、数据清理、数据迁移的过程会带来极其复杂的并发控制。在多进程场景下我前面已经建议用ContentProvider统一管理。这里再补充一点如果ContentProvider本身处理不好并发所有进程的数据访问都会卡在它身上所以ContentProvider内部依然需要遵循写操作串行化、事务短小精悍、开启WAL这些原则。9. 顺手把SQLite的PRAGMA配置聊透很多锁问题其实可以通过调整PRAGMA参数来缓解。我会在项目初始化的时候对数据库做一个整体配置db.execSQL(PRAGMA journal_modeWAL); db.execSQL(PRAGMA synchronousNORMAL); db.execSQL(PRAGMA busy_timeout500); db.execSQL(PRAGMA cache_size2000);这里重点说两个journal_modeWAL前文已经反复强调建议默认开启。busy_timeout500设置获取锁的等待时间上限单位是毫秒。500毫秒是个比较合理的折中值等待太短容易误报等待太长容易卡线程。需要特别提醒的是PRAGMA设置不要在每次创建数据库时执行而是在onConfigure里执行然后通过SQLiteOpenHelper.getWritableDatabase()触发。不过PRAGMA journal_modeWAL这个命令返回的是一个结果集不能用execSQL来执行要用rawQuerydb.rawQuery(PRAGMA journal_modeWAL, null).close();真机上这样执行是没有报错的但如果你的项目存在兼容性要求我还是更推荐用enableWriteAheadLogging()这个封装好的API因为它内部会处理不同Android版本的差异。10. 我的最终建议先做代码审查再加锁策略写了这么多最后再说点实在的。数据库锁问题本质上是“并发访问模型没设计好”的外在表现。所有“加锁”“超时”“重试”都是补救措施最健康的状态是你的数据访问路径在设计上就避免不可控的并发写。我做过的项目里凡是数据库锁问题级联出现且反复修改的大概率都有以下共性数据库操作分散在各个业务类里有些放在匿名线程有些放在IntentService里有些甚至直接在UI线程批量数据操作没有统一的分批处理逻辑数据库的journal mode从未调整过用的是默认DELETE模式事务控制混乱beginTransaction散布在业务代码里。如果你把这四个问题都解决了我可以很负责任地说99%的SQLite锁问题都不会出现在你的项目里。剩下的1%是极其极端的场景交给超时和重试机制兜底就够了。最后再分享一个小技巧上线前的压测一定要带着数据量跑别只测功能。用200万条以上数据做一次全量同步并发测试所有锁问题都会现出原形。那时候再优化比你上线后连夜修复要从容得多。