Describe the bug
同一事件循环内批量 dispatch 多条同频道消息时(任意适配器同 tick 批量推送均可触发),多个 session 并发执行 Session.getChannel() 的 check-then-act(先 SELECT、未命中才 INSERT),未命中的多个 createChannel 并发 INSERT 撞 channel 表复合主键 (id, platform),报 UNIQUE constraint failed: channel.id, channel.platform。
记录最终由首个 INSERT 创建成功、后续查询命中不复发,但会刷 [W] session 错误日志(实测一次批量 dispatch 4 条消息 → 1 成功 + 3 次冲突)。
源码定位:packages/core/src/session.ts getChannel()(约 L235):
async getChannel<K extends Channel.Field = never>(id = this.channelId, fields: K[] = []) {
const { app, platform, guildId } = this
if (!fields.length) return { platform, id, guildId } as Channel
const channel = await app.database.getChannel(platform, id, fields) // ① SELECT
if (channel) return channel
const assignee = this.resolve(app.koishi.config.autoAssign) ? this.selfId : ''
if (assignee) {
return app.database.createChannel(platform, id, { assignee, guildId, createdAt: new Date() }) // ② INSERT
}
...
}
createChannel(packages/core/src/database.ts)→ this.create('channel', { platform, id, ...data })——普通 INSERT,无 upsert / 无锁 / 无冲突重查。
Steps to reproduce
触发条件:首次进入某频道(channel 表无该记录)+ 同一 tick 内对该频道 dispatch ≥2 条消息。
- 新建 koishi 项目,接入任意适配器(或直接在插件中手动调用
bot.dispatch());
- 让适配器同一 tick 内对同一
channelId 连续 dispatch 多条 message 事件(如一次连接帧含多条消息、或插件里连续同步 bot.dispatch(session));
- 观察日志(sqlite 驱动):多条
UNIQUE constraint failed: channel.id, channel.platform。
最小复现:插件就绪后对同一 channel 连续同步 dispatch 2 条消息即可稳定复现。
Expected behavior
并发 get-or-create 不应抛唯一键冲突。创建路径应并发安全:createChannel 改为 upsert 语义(minato 已支持 ON CONFLICT)或冲突后重查返回既有记录;或在 getChannel 内对 (platform, id) 串行化。
Screenshots
真实日志(2026-08-09,sqlite 驱动;createdAt 同一毫秒连续 = 三次连续 INSERT):(群号已脱敏)
2026-08-09 13:41:55 [W] sqlite > INSERT INTO `channel` (`flag`, `assignee`, `guildId`, `locales`, `permissions`, `platform`, `id`, `createdAt`) VALUES (?, ?, ?, ?, ?, ?, ?, ?) [ 0, '3567100000', '978500000', '', '', 'napuketto', '978500000', 1786254115661 ]
2026-08-09 13:41:55 [W] sqlite > INSERT INTO `channel` ... [ 0, '3567100000', '978500000', '', '', 'napuketto', '978500000', 1786254115664 ]
2026-08-09 13:41:55 [W] sqlite > INSERT INTO `channel` ... [ 0, '3567100000', '978500000', '', '', 'napuketto', '978500000', 1786254115665 ]
2026-08-09 13:41:55 [W] session 暗区打不打
Error: UNIQUE constraint failed: channel.id, channel.platform
Versions
- OS: Windows 10/11
- Platform: napuketto(自定义 QQ 适配器,自建宿主 + IPC;与平台无关,任意批量 dispatch 平台均可触发)
- Node version: 22.x
- Koishi version: 4.18.11
- @koishijs/core: 4.18.11
- @satorijs/core: 4.6.0
- minato: 3.7.0
- @minatojs/driver-sqlite: 4.7.0
- @minatojs/sql.js: 3.1.0
- @cordisjs/core: 3.18.1
Additional context
根因分析:
-
同步 fire-and-forget 分发:@satorijs/core/src/bot.ts dispatch() 用 this.context.emit(...) 同步调用 listener、不 await;cordis emit(@cordisjs/core/src/events.ts)Array.from(this.dispatch('emit', args)) 同步耗尽 generator——async listener(_handleMessage)返回的 Promise 被丢弃。同 tick 多条消息的中间件链并发启动、经多个 await 让位交错执行。
-
每条消息必走 get-or-create:packages/core/src/middleware.ts 的 attach 以 prepend 注册(this.middleware(this.attach.bind(this), true)),群消息(!session.isDirect)必 await session.observeChannel(...)(fields 非空,必查 DB)。
-
check-then-act 无原子性:minato Executable.execute()(minato/src/selection.ts)每个 DB 操作前有 await prepared() 等微任务让位;sqlite 驱动(@minatojs/driver-sqlite/src/index.ts)create 是裸 INSERT INTO ... VALUES (...),_exec 捕获后 throw e 重新抛出——错误真实上报,被 _handleMessage 的 next() 捕获记 [W] session 日志(不崩溃、但刷屏)。
建议修复方向:
getChannel 创建路径改用 minato upsert(INSERT ... ON CONFLICT DO NOTHING,幂等、并发安全),或 INSERT 冲突后 catch 重查返回既有记录;
- 或 minato 驱动层写操作串行化(如
_ensureSession 实现 per-key 互斥),从框架层消除 check-then-act 窗口。
临时规避(插件侧):dispatch 前用 minato upsert 原子预热 channel(两段式:get 命中直返 / 未命中 upsert 幂等创建,per-channel 串行),让框架 SELECT 必命中——已实测消除该错误。
Describe the bug
同一事件循环内批量 dispatch 多条同频道消息时(任意适配器同 tick 批量推送均可触发),多个 session 并发执行
Session.getChannel()的 check-then-act(先 SELECT、未命中才 INSERT),未命中的多个createChannel并发 INSERT 撞 channel 表复合主键(id, platform),报UNIQUE constraint failed: channel.id, channel.platform。记录最终由首个 INSERT 创建成功、后续查询命中不复发,但会刷
[W] session错误日志(实测一次批量 dispatch 4 条消息 → 1 成功 + 3 次冲突)。源码定位:
packages/core/src/session.tsgetChannel()(约 L235):createChannel(packages/core/src/database.ts)→this.create('channel', { platform, id, ...data })——普通 INSERT,无 upsert / 无锁 / 无冲突重查。Steps to reproduce
触发条件:首次进入某频道(channel 表无该记录)+ 同一 tick 内对该频道 dispatch ≥2 条消息。
bot.dispatch());channelId连续 dispatch 多条message事件(如一次连接帧含多条消息、或插件里连续同步bot.dispatch(session));UNIQUE constraint failed: channel.id, channel.platform。最小复现:插件就绪后对同一 channel 连续同步 dispatch 2 条消息即可稳定复现。
Expected behavior
并发 get-or-create 不应抛唯一键冲突。创建路径应并发安全:
createChannel改为 upsert 语义(minato 已支持ON CONFLICT)或冲突后重查返回既有记录;或在getChannel内对(platform, id)串行化。Screenshots
真实日志(2026-08-09,sqlite 驱动;createdAt 同一毫秒连续 = 三次连续 INSERT):(群号已脱敏)
Versions
Additional context
根因分析:
同步 fire-and-forget 分发:
@satorijs/core/src/bot.tsdispatch()用this.context.emit(...)同步调用 listener、不 await;cordisemit(@cordisjs/core/src/events.ts)Array.from(this.dispatch('emit', args))同步耗尽 generator——async listener(_handleMessage)返回的 Promise 被丢弃。同 tick 多条消息的中间件链并发启动、经多个await让位交错执行。每条消息必走 get-or-create:
packages/core/src/middleware.ts的attach以prepend注册(this.middleware(this.attach.bind(this), true)),群消息(!session.isDirect)必await session.observeChannel(...)(fields 非空,必查 DB)。check-then-act 无原子性:minato
Executable.execute()(minato/src/selection.ts)每个 DB 操作前有await prepared()等微任务让位;sqlite 驱动(@minatojs/driver-sqlite/src/index.ts)create是裸INSERT INTO ... VALUES (...),_exec捕获后throw e重新抛出——错误真实上报,被_handleMessage的next()捕获记[W] session日志(不崩溃、但刷屏)。建议修复方向:
getChannel创建路径改用 minatoupsert(INSERT ... ON CONFLICT DO NOTHING,幂等、并发安全),或 INSERT 冲突后 catch 重查返回既有记录;_ensureSession实现 per-key 互斥),从框架层消除 check-then-act 窗口。临时规避(插件侧):dispatch 前用 minato
upsert原子预热 channel(两段式:get 命中直返 / 未命中 upsert 幂等创建,per-channel 串行),让框架 SELECT 必命中——已实测消除该错误。