Skip to content

Bug: Session.getChannel并发get-or-create撞 (id, platform) 唯一键(UNIQUE constraint failed: channel.id, channel.platform) #1545

Description

@Oppenheymu

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
    }
    ...
}

createChannelpackages/core/src/database.ts)→ this.create('channel', { platform, id, ...data })——普通 INSERT,无 upsert / 无锁 / 无冲突重查。

Steps to reproduce

触发条件:首次进入某频道(channel 表无该记录)+ 同一 tick 内对该频道 dispatch ≥2 条消息。

  1. 新建 koishi 项目,接入任意适配器(或直接在插件中手动调用 bot.dispatch());
  2. 让适配器同一 tick 内对同一 channelId 连续 dispatch 多条 message 事件(如一次连接帧含多条消息、或插件里连续同步 bot.dispatch(session));
  3. 观察日志(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

根因分析

  1. 同步 fire-and-forget 分发@satorijs/core/src/bot.ts dispatch()this.context.emit(...) 同步调用 listener、不 await;cordis emit@cordisjs/core/src/events.tsArray.from(this.dispatch('emit', args)) 同步耗尽 generator——async listener(_handleMessage)返回的 Promise 被丢弃。同 tick 多条消息的中间件链并发启动、经多个 await 让位交错执行。

  2. 每条消息必走 get-or-createpackages/core/src/middleware.tsattachprepend 注册(this.middleware(this.attach.bind(this), true)),群消息(!session.isDirect)必 await session.observeChannel(...)(fields 非空,必查 DB)。

  3. check-then-act 无原子性:minato Executable.execute()minato/src/selection.ts)每个 DB 操作前有 await prepared() 等微任务让位;sqlite 驱动(@minatojs/driver-sqlite/src/index.tscreate 是裸 INSERT INTO ... VALUES (...)_exec 捕获后 throw e 重新抛出——错误真实上报,被 _handleMessagenext() 捕获记 [W] session 日志(不崩溃、但刷屏)。

建议修复方向

  • getChannel 创建路径改用 minato upsertINSERT ... ON CONFLICT DO NOTHING,幂等、并发安全),或 INSERT 冲突后 catch 重查返回既有记录;
  • 或 minato 驱动层写操作串行化(如 _ensureSession 实现 per-key 互斥),从框架层消除 check-then-act 窗口。

临时规避(插件侧):dispatch 前用 minato upsert 原子预热 channel(两段式:get 命中直返 / 未命中 upsert 幂等创建,per-channel 串行),让框架 SELECT 必命中——已实测消除该错误。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions