数据库
签到插件要把积分落盘、封禁插件要记操作日志——只要你开始在插件里存数据,就会碰到建表、查询和事务。默认的 SQLite 零配置即可用;场景放大后再切 MySQL,上层接口不变。
ZCBOT 支持 SQLite(默认,零配置)与 MySQL,上层使用同一套接口, 插件基本不需要感知当前是哪种数据库。
选型边界:SQLite 仅适合小环境与开发环境(个人/小群、 单写多读、单文件存储);大环境不适合 SQLite——多群、高并发、多进程 部署、长连接保活、水平扩展等场景请使用 MySQL。方言适配只解决 SQL 能不能跑,解决不了 SQLite 的并发与扩展上限。
- 数据库封装:
framework/database/db.py的Database类; - SQL 方言翻译:
framework/database/dialect.py(纯函数); - 自动建表/迁移:
framework/database/schema.py+framework/database/init_db.py的auto_init_database(db); - 调试模式引擎:
framework/database/sql_sim.py的SqlSimEngine(无数据库时本地 SQL 模拟); - 配置见 配置系统。
存储降级兜底(数据库不可用)
数据库存储不再构成框架的硬依赖:初始化失败(缺 MySQL 驱动、连接失败、 SQLite 路径不可写等)时框架不会因此无法启动,而是自动降级为本地 SQL 模拟引擎 (SqlSimEngine,与 database.type: debug 同款实现,data/db/sql/ 下 JSON 行集落盘,查询有语义且重启不丢):
- 降级兜底仅供"数据库没了框架还能起来",数据以本地 JSON 行集存储,性能低、无真实事务; 正常部署请配置 SQLite 或 MySQL;
- 显式调试模式配置
database.type: debug走同一套SqlSimEngine(见下节), 适合无数据库的开发/联调环境; - 旧版
database.type: file(降级文件存储)已移除:该模式 SQL 调用一律返回空值、 无实际持久化能力,现已不可用;若配置中仍写type: file,框架启动将明确报错提示改用sqlite/mysql/debug; - 框架通过
framework.db_debug_mode(True 表示调试/降级兜底模式)与framework.storage_mode(sqlite/mysql/debug)感知当前后端;连接池状态可用ctx.db_pool_status查看。
调试模式(低性能模式 / 本地模拟 SQL)
在没有 SQLite/MySQL 依赖的开发、联调环境(如 CI 冒烟、最小复现、 无数据库沙箱),可通过抽象层开启调试模式:database: { type: debug }。
- 存储抽象层:
framework/database/storage.py的create_storage(config)按database.type选后端 ——sqlite/mysql → Database、debug → SqlSimEngine;上层访问接口完全同构, 插件与内核无需改动; - 模拟引擎:
framework/database/sql_sim.py的SqlSimEngine,在本机 用 JSON 行集文件"假扮"数据库(data/db/sql/<表>.json,原子写、重启不丢), 查询返回符合 SQL 语义的结果(非空值兜底):- SELECT:
*/ 列 /COUNT(*)/DISTINCT/DATE(col) AS d、 WHERE(=<>!=><>=<=ANDORINNOT INIS NULLLIKE,支持%s/?参数与字面量)、GROUP BY、ORDER BY、LIMIT/OFFSET; - INSERT [IGNORE]、UPDATE(
NOW()、col=col+N自增)、DELETE、CREATE TABLE; - 不支持的语法(JOIN/子查询/复杂表达式/ALTER)安全兜底:查询返回
[]、 写返回0,绝不抛异常,保证框架可启动;
- SELECT:
- 调试模式适合无数据库的开发/联调环境,可真实验证查询/写入逻辑; 生产或常规部署请使用 SQLite / MySQL(性能、事务、并发能力均更强)。 但它性能低、无真实事务与并发保证,仅限开发调试,生产必须用 sqlite/mysql;
- 行集内存缓冲:行集常驻内存,读写不再逐次全量解析/重写 JSON 文件; 写入先进缓冲,按
database.debug_flush_ms(毫秒,默认1000,0=逐次 同步落盘)由后台线程合并落盘,正常关停/进程退出兜底全量落盘(进程被强杀 最多丢最近一个刷盘间隔的写入);外部/手工改动 JSON 文件按 mtime 自动重载; - 框架感知:
framework.storage_mode(sqlite/mysql/debug)与framework.db_debug_mode(True 表示调试模式)暴露当前后端。
# config.yaml —— 调试模式(低性能模式:本地模拟 SQL)
database:
type: debug # 无需任何数据库依赖,行集落盘 data/db/sql/
debug_flush_ms: 1000 # 内存缓冲刷盘间隔(毫秒),0 = 每次写同步落盘自动初始化
框架启动时根据 config.yaml → database 建立连接并自动建表:
- SQLite:按
database.path(默认data/zcbot.db)创建文件库; - MySQL:自动探测版本与字符集,适配 DDL 后建库建表。
插件不要自己去改框架表结构;自己的业务表用 ctx.create_table() 创建。
建表之后的字段/索引演进走版本化迁移(账本表 schema_migrations,只执行未应用过的版本,失败会记账并在下次启动重试):
- 新增字段要三处同步:
sql/init.sql+sql/init_mysql55.sql+MIGRATIONS里追加一版; - 已发布的迁移不可改(账本有校验和,改了只会报漂移、不会重跑);
- 详见 数据库迁移。
插件建表:统一写 MySQL 方言
def register(ctx):
ctx.create_table("""
CREATE TABLE IF NOT EXISTS my_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
score INTEGER DEFAULT 0,
memo TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")框架自动完成方言适配,插件无需判断数据库类型:
- SQLite:
AUTO_INCREMENT → AUTOINCREMENT、ENUM → TEXT、移除不支持的内联 INDEX 等; - MySQL:
AUTOINCREMENT → AUTO_INCREMENT,对长文本列上的索引自动改成 前缀索引col(191),规避 MySQL 的 1170/1064 错误。
建议在 register(ctx) 里建表(幂等,IF NOT EXISTS),每次注册都会确保表存在。
查询接口
同步
rows = ctx.db_query("SELECT * FROM users WHERE group_id=%s", (group_id,)) # list[dict]
row = ctx.db_query_one("SELECT * FROM users WHERE user_id=%s", (user_id,)) # dict / None
n = ctx.db_execute("UPDATE users SET score=score+%s WHERE id=%s", (1, id_)) # 受影响行数
new_id = ctx.db_insert("INSERT INTO logs (msg) VALUES (%s)", ("hello",)) # 自增 ID
ctx.db_execute_many("INSERT INTO t (v) VALUES (%s)", [(1,), (2,)]) # 批量异步(async handler 推荐)
异步方法在数据库专用线程池执行,DB 繁忙也不会卡住消息事件循环:
rows = await ctx.db_query_async(sql, params)
row = await ctx.db_query_one_async(sql, params)
n = await ctx.db_execute_async(sql, params)
new_id = await ctx.db_insert_async(sql, params)
await ctx.db_execute_many_async(sql, params_list)查询结果统一为 dict(单条)或 list[dict](多条),列名即键。
占位符:统一用 %s
无论 SQLite 还是 MySQL,插件 SQL 一律写 %s 占位符,框架在 SQLite 下 自动转换成 ?。不要拼接字符串,避免 SQL 注入与方言问题:
# 正确:参数化
ctx.db_execute("UPDATE t SET v=%s WHERE id=%s", (v, id_))
# 错误:字符串拼接
ctx.db_execute(f"UPDATE t SET v={v} WHERE id={id_}")事务
多条必须一起成功/失败的写操作,用 db_connection() 取连接手动提交/回滚。 连接池模式下 close() 是归还连接,不是断开:
conn = ctx.db_connection()
try:
cur = conn.cursor()
cur.execute("UPDATE account SET balance=balance-%s WHERE id=%s", (100, from_id))
cur.execute("UPDATE account SET balance=balance+%s WHERE id=%s", (100, to_id))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cur.close()
conn.close()连接池状态
ctx.db_pool_status # dict:连接池占用/空闲等状态,便于排障切换到 MySQL 的完整写法
把 config.yaml 的 database 段改成下面这样(所有键与 type 平级):
database:
type: mysql
host: 127.0.0.1
port: 3306
user: zcbot
password: "your-password" # 数字密码请加引号
database: zcbot # 库名,需提前建好
charset: utf8mb4
pool_size: 10 # 连接池上限
pool_wait_timeout: 30 # 池满时最多等多久(秒)库和账号要先建好,框架只负责建表:
sqlCREATE DATABASE zcbot DEFAULT CHARSET utf8mb4; CREATE USER 'zcbot'@'127.0.0.1' IDENTIFIED BY 'your-password'; GRANT ALL PRIVILEGES ON zcbot.* TO 'zcbot'@'127.0.0.1'; FLUSH PRIVILEGES;
三种常见写错,症状都是启动卡住而不是报配置错:
# ✗ 错 1:整段写成单行 flow mapping,粘贴到 database: 下面就成了嵌套
database:
database: { type: mysql, host: 127.0.0.1, ... }
# ✗ 错 2:多嵌一层 mysql:
database:
type: mysql
mysql: { host: 127.0.0.1, ... }
# ✓ 对:平铺,与 type 同级
database:
type: mysql
host: 127.0.0.1写错时框架会直接指出具体键名(如「database.host 应为字符串,实际是 dict」), 不会再把一次参数错误重试成 30 秒假死。
权限说明:迁移前的备份会尝试读 binlog 位点,需要 REPLICATION CLIENT (或 MySQL 8 的 BACKUP_ADMIN)权限。普通业务账号没有该权限时会打印 v2 执行前备份未完成 — 无法读取 binlog 位点 并继续执行迁移——请自行确认 已有实例级备份,或给账号补上该权限。
SQLite vs MySQL 对比
| 特性 | SQLite | MySQL |
|---|---|---|
| 配置 | 零配置,单文件 | 需 host/port/user/password/database |
| 并发 | 单写多读,适合轻量场景 | 支持高并发 |
| 占位符 | 插件写 %s,运行时转 ? | 原生 %s |
| 自增主键 | INTEGER PRIMARY KEY AUTOINCREMENT | INT ... AUTO_INCREMENT PRIMARY KEY |
| 适合规模 | 仅小环境与开发环境(个人/小群、本地调试) | 大环境(多群、高并发、多进程部署) |
结论照抄即可:
- 本地开发、个人号、小群试点 →
database.type: sqlite(默认,零配置); - 生产上线、多群、消息量大、双进程/多 worker →
database.type: mysql, 不要用 SQLite 顶大环境(写锁、单文件、无网络拓扑,扩容到头就是换库)。 - 从 SQLite 迁到 MySQL:改
config.yaml后重启,框架会按sql/init.sql自动建表;业务数据需自行导出导入(结构为 MySQL 方言,双方言通用)。
字段元信息系统
框架为业务表维护字段描述等元信息,配套提供(通过 ctx/框架能力):
- 描述字段、更新字段描述、列出全表字段描述;
- 群级动态列等高级能力。
插件自定义表只要遵循统一建表入口,即可被这些能力识别。
实践建议
- 表名加插件前缀(如
sign_records),避免不同插件撞表; async defhandler 里一律用*_async接口;- 建表 DDL 保持幂等(
IF NOT EXISTS),在register(ctx)调用; - 高频写库考虑批量接口
db_execute_many; - 用户可改的配置走
_conf_schema.json+ctx.get_config,不要自己建配置表。