SQLiteDatabaseLockedException code 5?用 TaoToken 接入的 Codex 对照 PRAGMA journal_mode 排查
code 5和database is locked这对老搭档又出现了。TaoToken 在这篇里只负责发一把 Key地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 剩下的活交给 Codex 去读你的 SQLite 代码。真正难缠的是后半句while compiling: PRAGMA journal_mode。它说明报错不是在业务 SQL 执行到一半炸的而是在数据库连接准备、journal 模式还没设好的阶段就被别的线程摁住了。很多人看到 locked 就去查谁在写其实写的人可能早就写完了只是连接没还回来。这篇按排障顺序走先在本地把 SettingsProvider 那条堆栈抓全再从 DBOpenHelper、SQLiteDataProxy、mOpenCounter、Semaphore、ThreadLocal isQuery 这几段代码里找出重复打开、事务未收尾、close 与游标不配对的位置最后在本地跑改过的 execSQL/execSQLList 看还会不会撞锁。全程 Codex 只做读代码 标位置SQLite 自身行为不会被它改掉Key 和统一的 Base URL 由 TaoToken 这边提供Base URL 填https://taotoken.net/api末尾不要带/v1。1. 先把 code 5 的堆栈抓全别一上来就清数据SQLiteDatabaseLockedException属于看堆栈比看代码快的那类问题。日志里第一行通常长这样android.database.sqlite.SQLiteDatabaseLockedException: database is locked (code 5): , while compiling: PRAGMA journal_mode它给的信息量其实不小出错的语句是PRAGMA journal_mode不是你的INSERT也不是SELECT。抓完整堆栈比反复改代码省事得多adb logcat -v threadtime -b main | grep -i database is locked adb logcat -v threadtime -b main /tmp/sqlite-locked.log第二条命令把全量日志落盘后面用编辑器搜code 5就行。注意抓的是真机或模拟器上你复现那一刻的日志不是线上崩溃平台的摘要——摘要常常只有一行异常名没有帧。1.1while compiling: PRAGMA journal_mode说明锁发生在哪一层PRAGMA journal_mode是连接打开、或者设置 journal 模式时才执行的语句Android 的SQLiteOpenHelper.onConfigure、手动setJournalMode、以及部分框架初始化路径里都会碰到它。它被锁住意味着此时已经有另一个连接拿着这个数据库文件的锁不放或者写事务还没结束导致新连接连问一句现在是什么模式都排不上队。换句话说问题不在 journal_mode 这一句本身而在上一个持有者没把资源还回来。这也解释了为什么删库重装往往能好一阵子——数据文件换了一个旧连接的锁自然没了但代码里的计数错位、事务漏收尾、游标没关一个都没修跑几天又回来。真正的排查方向只有三个多线程同时写、beginTransaction漏endTransaction、getWritableDatabase与close的计数对不上。1.2 从堆栈里认出 DBOpenHelper、SQLiteDataProxy、mOpenCounter抓到的堆栈一般会经过几个你熟悉的类。典型形态是从SettingsProvider相关的调用进来穿过一层封装SQLiteDataProxy最后落到DBOpenHelper.getWritableDatabase。这三层各有各的嫌疑SettingsProvider这类系统级或高频读取的 Provider经常在 App 启动阶段被多个线程、多个组件同时触发query是最容易暴露并发问题的入口SQLiteDataProxy通常是团队自己写的薄封装负责打开库、关库、批量执行。计数逻辑多半就藏在这层DBOpenHelper是单例入口getWritableDatabase()里如果每次都直接返回新连接而没有引用计数问题会从这里开始累积。把这三层对应的源文件路径记下来下一步让 Codex 读的就是这几个文件而不是整个工程。堆栈里还会出现android.database.sqlite.SQLiteConnection.nativeExecuteForString、SQLiteConnectionPool.waitForConnection这类系统帧它们只是在等锁不用管。2. 三个高频错位写并发、事务收尾、游标配对锁的持有者无非两类还在跑的写事务和没被释放的连接。日常踩到的原因基本落在下面三处而且经常是叠加出现——一个连接没关再赶上多线程写就必然复现。2.1 mOpenCounter 只加不减getWritableDatabase 与 close 的账对不上最常见的写法是给数据库加一个打开计数计数到 0 才真正close。这个思路没问题问题出在实现上经常漏掉减一或者close被不同线程调到不同的实例上// 反例打开计数只加不减close 永远不生效 public synchronized SQLiteDatabase getWritableDatabase() { mOpenCounter; return mDatabaseHelper.getWritableDatabase(); } public synchronized void closeDatabase() { // 少了一行 mOpenCounter--也没做真正的 close }正确版本要保证加和减严格成对并且只有归零时才真的关private int mOpenCounter 0; private SQLiteDatabase mDatabase; public synchronized SQLiteDatabase openDatabase() { if (mOpenCounter 0) { mDatabase mHelper.getWritableDatabase(); } mOpenCounter; return mDatabase; } public synchronized void closeDatabase() { if (mOpenCounter 0) { mOpenCounter--; } if (mOpenCounter 0 mDatabase ! null mDatabase.isOpen()) { mDatabase.close(); mDatabase null; } }有个容易混淆的点SQLiteDatabase自己内部也有引用计数getWritableDatabase()反复调用并不会每次都新建底层连接。你的mOpenCounter是业务层的账用来决定什么时候该彻底关库关早了正在跑的写事务会被打断关晚了连接一直挂着。两边混在一起算账一定会乱。2.2 Semaphore 与 ThreadLocal isQuery 在 SQLiteDataProxy 里的角色很多封装层会加一个信号量来保证写串行再加一个ThreadLocalBoolean isQuery判断当前线程是在查询还是写private final Semaphore mWriteLock new Semaphore(1); private final ThreadLocalBoolean isQuery new ThreadLocalBoolean() { Override protected Boolean initialValue() { return Boolean.FALSE; } };Semaphore的坑是借了不还一旦acquire()之后中途抛异常没有在finally里release()写锁就永久少一个许可后续所有写请求都会排队直到超时表现出来就是 code 5。ThreadLocal的坑更隐蔽——线程池会复用线程如果isQuery用完不remove()下一个复用这个线程的任务会带着我是查询的旧标记于是本该endTransaction的路径被跳过事务留在那里没人收尾。这两个变量本质上是给并发行为打标签。标签一旦串了代码逻辑看起来是对的实际执行路径完全不是你以为的那条。2.3 游标没关query 让读锁一直挂着query返回的Cursor不关游标背后的窗口就不释放。Android 对未关闭的游标有严格的日志提示但如果被大量日志刷掉了很容易忽略。典型反例// 反例游标没有关闭异常路径更是直接漏掉 Cursor c db.query(t_settings, null, key ?, new String[]{key}, null, null, null); if (c.moveToFirst()) { value c.getString(1); }改成 try-with-resources异常路径也能收尾try (Cursor c db.query(t_settings, null, key ?, new String[]{key}, null, null, null)) { if (c.moveToFirst()) { value c.getString(1); } }在SQLiteDataProxy里如果有一个query总入口把游标关闭集中在这一层处理最省心别让上层调用方记得关。3. 让 Codex 对着 PRAGMA 报错逐个标位置TaoToken 建 Key 与 config.toml代码看完了接下来是最花时间的活在这几个类里逐条找出哪个方法打开没关、哪个事务没结尾、哪个游标没配对。这部分交给 Codex 读文件、做交叉比对挺合适——它读的是本地源码不动数据库。Key 从 TaoToken 建脚本里用占位符YOUR_API_KEY表示别把真 Key 贴进 issue 或截图里。3.1 创建 Key 与记下模型 ID打开 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 注册登录进控制台创建一把 Key复制出来存到本地密码管理器。同一页面里能进模型广场那里列着当前可以调用的模型 ID。模型 ID 以模型广场当时列表为准不要照着网上某篇旧文章里的名字填也不要自己加日期后缀——填了不存在的 ID第一个对话就会报模型不存在白折腾一轮。Key 建议给这类排查工作单独建一把用完能在控制台单独看用量也方便排查时随时吊销不影响其他项目在用的 Key。3.2 ~/.codex/config.toml 里把 base_url 指向 TaoTokenCodex 的配置写在~/.codex/config.toml关键是model_provider和base_url两项。填进工具的地址是接口地址https://taotoken.net/api末尾不要加/v1也不要填官网落地页地址两者是两回事model YOUR_MODEL_ID model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api wire_api chat env_key TAOTOKEN_API_KEY保存后把 Key 通过环境变量传进去避免写进配置文件export TAOTOKEN_API_KEYYOUR_API_KEY这里env_key里的名字要和实际导出的变量名完全一致大小写也别差。改完新开一个终端窗口再启动 Codex老的 shell 不会自动带上新变量。3.3 提问模板只要定位清单不要改数据库行为在工程根目录启动 Codex 之后把范围圈死让它只做标注。下面这段可以直接用把类名换成你自己的读这三个文件DBOpenHelper.java、SQLiteDataProxy.java、SettingsProvider 里 实际发起 Settings 查询的那段代码。 现象SQLiteDatabaseLockedException: database is locked (code 5), while compiling: PRAGMA journal_mode。 请只做三件事 1. 标出所有调用 getWritableDatabase 的位置说明有没有配对的 close 2. 标出每个 beginTransaction指出对应 endTransaction 是否在所有分支含异常里执行 3. 标出每个 query 返回的 Cursor说明是否有明确的关闭路径。 只输出文件、行号、问题类型三列不要改代码不要执行任何数据库操作。第三条尤其重要它只输出定位结果真正的修改由你决定。Codex 读的是源码跑不了你的 Android 设备也连不上你的数据库PRAGMA journal_mode的返回值只能由你在本地或设备上执行后贴回来。3.4 第一次对话常见两个报错现象常见原因处理方式401 未授权环境变量名与env_key不一致或 Key 复制时带了空格检查变量名重新export后新开终端404 或路径异常base_url写成了https://taotoken.net/api/v1去掉/v1保持https://taotoken.net/api模型不存在model填了未经确认的 ID回到模型广场按当时列表重填排查这类问题时先确认是通道问题还是代码问题随便发一句无关的问候能正常回就说明 Key 和 Base URL 没问题剩下的是代码逻辑连问候都发不出去别急着改 Java先看配置。4. execSQL 与 execSQLList 的收尾重写定位清单拿到手之后改动集中在三处事务成对、批量写合并、游标成对。改的时候别一次性重写整个封装层按下面顺序逐块改每改一块跑一次复现用例。4.1 beginTransaction 一定要包在 try/finally 里事务漏endTransaction有两种形态正常路径忘了写或者异常路径直接跳到 catch 把事务落下了。后者更常见// 反例异常时事务留在原地写锁不释放 db.beginTransaction(); db.execSQL(sql, args); db.setTransactionSuccessful(); db.endTransaction();改成这样无论中间发生什么事务都会收尾db.beginTransaction(); try { db.execSQL(sql, args); db.setTransactionSuccessful(); } finally { db.endTransaction(); }setTransactionSuccessful()决定提交还是回滚endTransaction()负责关掉事务本身两件事不能省其中之一。4.2 execSQLList 批量写要落在同一个事务里批量写在SQLiteDataProxy里通常叫execSQLList。逐条execSQL会各自开事务中间被别的线程插进来写锁窗口就变长了。把它合成一个事务public void execSQLList(ListSqlArgs list) { SQLiteDatabase db openDatabase(); db.beginTransaction(); try { for (SqlArgs item : list) { db.execSQL(item.getSql(), item.getArgs()); } db.setTransactionSuccessful(); } finally { db.endTransaction(); closeDatabase(); } }注意closeDatabase()放在finally里并且和前面的openDatabase()严格配对。如果closeDatabase内部依赖ThreadLocal isQuery判断是否关闭排查阶段建议先把这个判断去掉改成由调用方显式决定——标签串了关不关库就成了随机事件。4.3 写锁和游标要各还各的Semaphore的release()和游标的close()都属于借了必须还写法和前面的事务一样靠finally兜底mWriteLock.acquire(); try { // 只在这里做一次性写操作不要塞长耗时任务 } finally { mWriteLock.release(); }线程池里用ThreadLocal打标记的地方任务结束时补一句isQuery.remove()避免线程复用时带着上一次的标记跑。5. 本地压一把多线程写还撞不撞 code 5改完只看一遍代码不算验证。锁竞争是概率事件必须在本地把并发压出来才知道改动是不是真的收住了。5.1 用线程池本地跑一遍复现脚本写一个只跑测试数据的小用例模拟 Settings 被多个线程同时写。这个脚本在你自己机器或测试设备上跑不是让 Codex 去连设备ExecutorService pool Executors.newFixedThreadPool(8); final CountDownLatch start new CountDownLatch(1); for (int i 0; i 8; i) { final int id i; pool.submit(() - { try { start.await(); for (int n 0; n 200; n) { mProxy.execSQL(INSERT INTO t_settings(key, value) VALUES(?, ?), new Object[]{k id _ n, v n}); } } catch (Throwable t) { Log.e(SQLiteLockTest, worker id failed, t); } }); } start.countDown(); pool.shutdown(); pool.awaitTermination(60, TimeUnit.SECONDS);跑之前把异常日志级别打开跑完搜SQLiteLockTest和code 5。改之前这段必然报错改之后如果还是偶发一两次说明还有一条路径没收到finally里回到清单按行号再对一遍。5.2 对着 journal_mode 的取值看锁窗口想确认 journal 模式这条线没问题在本地或设备上用调试方式执行一次查询PRAGMA journal_mode;返回值可能是delete、truncate、persist或wal。开了 WAL 之后读写可以并发但写者仍然只有一个所以 WAL 下依然可能撞 code 5——只是触发点从读被写挡住变成多个写者争一个写锁。如果每次启动都在onConfigure里反复设置 journal 模式等于每次打开都在跑PRAGMA journal_mode那条报错堆栈出现的概率反而更高。设置一次、记住结果别在热路径里反复设。这一步的返回值由你在本地执行后贴回对话Codex 只负责根据结果帮你判断哪条路径还有问题它不执行数据库语句。5.3 压测通过后再回控制台对一下用量验证跑完顺手回到 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 看一眼这把 Key 这次排查消耗了多少。排查类任务的特点是对话长、附件多——一次让 Codex 读三个文件上下文就上去了。如果发现自己每周都有这类读代码找坑的需求可以顺手看看套餐够不够用别等到中途额度见底打断思路。6. 收尾验证完这几步别漏到这里报错本身基本收住了堆栈抓到了、三个错位定位到了、配置写对了、本地压测也过了。剩下三件事花了不到两分钟但漏了容易白折腾。6.1 用同一把 Key 回读一次确认配置没写歪配置改完最稳妥的自检是用同一把 Key 在 TaoToken 模型对话 里发一条测试消息。这一步验证的是通道本身Key 有没有多余空格、模型 ID 是不是模型广场里真实存在的那个、Base URL 有没有被手滑加上/v1。一旦这里通了再回 Codex 里读代码问题就只剩 Java 逻辑边界很清楚。6.2 长期写代码看 Coding PlanKey 在控制台管如果这类排查会长期做下去可以打开 Coding Plan 看套餐是否匹配使用强度排查用的 Key 统一在 控制台 API Keys 创建和吊销给读代码排查和日常编码各建一把出问题时能快速定位是哪一把的用量异常。想再核对一次 Codex 侧的环境变量写法也可以对照页面上的接入说明把base_url固定成https://taotoken.net/api末尾不加/v1剩下的交给已经配好的工具。最后提醒一句PRAGMA journal_mode报 code 5 从来不靠多试几次解决它一定会复现只是时间点随机。把beginTransaction和endTransaction、getWritableDatabase和close、query和游标关闭这三对关系在代码里逐对核一遍比反复清数据有效得多。