本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/待办事宜/FlintHub_MySQL版本改造方案.md。
FlintHub MySQL 版本改造方案
编制日期:2026-10-11
编制依据:站点根
D:\phpstudy_pro\WWW全量代码通读(核心app/、cli/、plugins/、install.php、config.php)结论定位:这是一次数据访问层的整体替换,不是"改几处 SQL"。可做,但必须按阶段推进,且第一刀要落在"抽象层"上。
0. 结论先行(TL;DR)
| 项目 | 结论 |
|---|---|
| 可行性 | ✅ 可行。而且本项目原本就是 MySQL 架构(旧库 fastbb),1.0.x 才迁到 SQLite。cli/migrate.php 就是当年 MySQL→SQLite 的迁移器,反向改造有现成参照 |
| 本质 | 不是"改方言",是替换整个存储引擎(SplitDB) |
| 主要工作量 | ① 连接/门面层重写 ② 30 张表 DDL 重写 ③ 约 60 处 SQLite 专有语法改写 ④ 双引号标识符全局替换 ⑤ 后台数据库模块几乎全部重写(约 1400 行控制器 + 3 个视图) |
| 最大的架构级难点 | 分库分表(季度分区 + 32 哈希桶 + extern 正文外置 + 3 队列库 + 季度搜索库)在 MySQL 下没有对应物,需要做一次"降维"设计决策 |
| 最大的环境级难点 | 纯 Web 环境无法跑 mysqldump(站长不用 SSH,红线)→ 后台备份必须改为「纯 PHP 逻辑导出」 |
| 推荐路线 | 路线 B:抽象层 + 双引擎适配器(保留 SQLite 可用,新增 MySQL 驱动),而不是直接换掉 SQLite |
| 预估工作量 | 核心层 3~5 个工作日;后台模块 2~3 个工作日;迁移器 + 校验 2~3 个工作日;插件兼容审计 1~2 个工作日。合计约 10~15 个工作日(不含回归测试) |
1. 现状摸底(全部有据)
1.1 当前数据层架构全景
FlintHub 当前是自研的 SplitDB 纯 SQLite 分片引擎,不是单库。共 7 类物理库 + 1 类外置存储:
| 物理位置 | 内容 | 代码入口 |
|---|---|---|
data/meta/business.sqlite | 23 张业务表(用户/版块/设置/博客/私信/附件/标签/投票/积分/通知/审计…) | Schema::businessDb() Schema.php:265 |
data/meta/main_index.sqlite | topic_index / reply_index 帖子主索引 | Schema::mainIndexDb() Schema.php:271 |
data/meta/sessions.sqlite | 会话(与业务库物理隔离) | Schema::sessionDb() Schema.php:277 |
data/meta/global_id.sqlite | id_generator 全局取号 | Schema::globalIdDb() Schema.php:283 |
data/meta/task_queue/queue_0~2.sqlite | 3 个队列库(打散写锁竞争) | Queue::getQueueDb() Queue.php:110 |
data/meta/search/search_{YYYYQn}.sqlite | 自研倒排索引,按季度分文件 | SearchIndexStore::db() SearchIndexStore.php:105 |
| `data/bucket/active\ | archive/{YYYYQn}/{N}.sqlite` | 帖子/回复真相源,季度分区 + 32 哈希桶 |
| `data/extern/{年}/{季}/{桶}/{t\ | r}{id}.txt` | 帖子/回复正文外置为文件(另有 .bin/.idx 归档块) |
1.2 建表位置(改造清单的来源)
全部 DDL 集中在 1 个文件:app/SplitDB/Schema.php(1049 行)
ensureBusiness()Schema.php:294-634— 23 张业务表 + 全部索引ensureMainIndex()Schema.php:806-878—topic_index/reply_indexensureSessions()Schema.php:883-894—sessionsensureBucketTables()Schema.php:929-996— 桶内topic/replyensureGlobalId()Schema.php:1045-1048—id_generatorQueue::initTable()Queue.php:41-68—task_queueSearchIndexStore::createTable()SearchIndexStore.php:74-86—search_index
幂等补列机制:Schema::ensureColumnPatches() Schema.php:641-765(哈希 marker 门控)——MySQL 下需要整套重设计。
1.3 SQL 调用规模(改造面统计)
| 范围 | SQL 调用点数 | 涉及文件数 |
|---|---|---|
app/(核心) | ~590 | 60 |
plugins/(48 个插件) | ~1200 | ~120 |
| 合计 | ~1800 | ~180 |
⚠️ 但真正需要改的远少于此:约 95% 是标准参数化
SELECT/INSERT/UPDATE/DELETE,MySQL 直接兼容。真正要动的是下面 §3.2 的方言清单(约 60 处)+ 双引号标识符(约 40 处)。
1.4 关键证据:本项目原本就是 MySQL
cli/migrate.php:1-22 「旧 MySQL 数据迁移脚本(CLI)」——一次性历史工具
cli/migrate.php:28-34 $MIGRATE = ['host'=>'localhost','name'=>'fastbb','user'=>'fastbb',...]
cli/migrate.php:89-128 class MySqlMigrateSource —— PDO mysql 非缓冲查询
flinthub1.0.23/cli/migrate.php 同样存在(538 行)
app/init.php:28-37 「★ 原 MySQL 探测逻辑(恢复时参考):$db->fetchOne("SHOW TABLES LIKE 'sessions'")」
install.php:146 「不再支持"旧 MySQL 配置升级为 SplitDB"」⇒ cli/migrate.php 的 MigrateSourceInterface 抽象(migrate.php:72-84)就是为"源/目标可互换"设计的,反向迁移器可以直接复用它的骨架。
2. 路线选择
路线 A:直接替换 SQLite → MySQL(单引擎)
把 app/SplitDB/* 整体改写为 MySQL 实现,SQLite 代码删除。
- ✅ 代码最干净,无分支
- ❌ 不可回退——一旦上线,想回 SQLite 就得重写第二遍
- ❌ 与现有发布体系冲突(发布包、升级器、老站都要跟着改)
- ❌ 违反项目一贯的"最小变更 + 零依赖回滚"原则
路线 B:抽象层 + 双引擎适配器 ✅ 推荐
引入 app/Db/ 驱动抽象,Database 门面按 DB_DRIVER 常量分派到 SqliteDriver / MysqlDriver。
- ✅ SQLite 现有站点零影响(默认仍走 SQLite)
- ✅ MySQL 作为可选引擎,老站可平滑切换
- ✅ 回滚 = 改一个常量
- ✅ 与现有发布体系兼容(核心包里同时带两套驱动)
- ❌ 需要先做抽象层(额外 1~2 天)
- ❌ 双引擎 = 双份测试矩阵
路线 C:恢复历史 MySQL 版
从 flinthub1.0.23/ 或更早版本恢复 MySQL 实现。
- ❌ 不可行:1.0.x 已经历 28 个增量版本(
update/目录),业务逻辑、表结构、插件体系都大幅演进,旧 MySQL 版远落后于当前功能,回退等于丢掉半年工作 - ⚠️ 仅可作为方言参照(旧版怎么写 SQL 的),不能作为基线
⇒ 采用路线 B。 下面所有方案基于路线 B 展开。
3. 核心改造:数据访问层
3.1 新增目录结构
app/Db/ ← 新增
├── DriverInterface.php 连接 + 执行 + 元数据 抽象
├── SqliteDriver.php 现有逻辑搬入(DBFactory 的 PRAGMA 等)
├── MysqlDriver.php 新增
├── Grammar.php 方言(DDL / 标识符引用 / upsert / 分页)
├── SqliteGrammar.php
├── MysqlGrammar.php
└── Migrator.php Schema 建表(DDL 按 Grammar 生成)改造原则:app/SplitDB/ 目录保留不删(它是 SQLite 专属实现),但 app/Core/Database.php 门面改为按驱动分派。
3.2 SQLite 专有语法清单(逐条 + 位置 + 改法)
这是必须逐条改的清单。已全部定位到
文件:行号。
① 幂等插入 INSERT OR IGNORE → INSERT IGNORE
| 位置 | 说明 |
|---|---|
Schema.php:200 | INSERT OR IGNORE INTO user_groups |
Schema.php:212 | INSERT OR IGNORE INTO level_config |
Schema.php:236 | INSERT OR IGNORE INTO settings |
Schema.php:248 | INSERT OR IGNORE INTO blog_categories |
Schema.php:257 | INSERT OR IGNORE INTO categories |
IDGenerator.php:37 | INSERT OR IGNORE INTO id_generator ... VALUES (...),(...) |
IDGenerator.php:170 | INSERT OR IGNORE INTO id_generator |
SearchIndexStore.php:247 | INSERT OR IGNORE INTO search_index ... |
Tag.php:95 | INSERT OR IGNORE INTO thread_tags |
cli/migrate.php:418 | INSERT OR IGNORE INTO id_generator |
② 覆盖插入 INSERT OR REPLACE → REPLACE INTO
| 位置 | 说明 |
|---|---|
cli/migrate.php:245 | 通用表迁移 |
cli/migrate.php:303/328 | topic / topic_index |
cli/migrate.php:370/382 | reply / reply_index |
cli/migrate.php:458 | attachments / votes |
⚠️ 语义陷阱:
REPLACE INTO在 MySQL 里是 DELETE + INSERT,会触发级联删除、重置自增、丢失未列出的列。推荐改用INSERT ... ON DUPLICATE KEY UPDATE,语义更接近INSERT OR REPLACE但更安全。
③ 条件更新 UPDATE OR IGNORE → UPDATE IGNORE
| 位置 | 说明 |
|---|---|
Schema.php:793 | UPDATE OR IGNORE thread_tags SET tag_id = ... |
④ UPSERT ON CONFLICT(x) DO UPDATE → ON DUPLICATE KEY UPDATE
| 位置 | 原写法 | MySQL 改法 |
|---|---|---|
Settings.php:265 | ON CONFLICT("key") DO UPDATE SET value = :value2 | ON DUPLICATE KEY UPDATE value = :value2 |
Settings.php:423 | ... DO UPDATE SET value = CAST(CAST(value AS INTEGER) + :vd AS TEXT) | ... = CAST(value AS SIGNED) + :vd(并注意 TEXT 列运算) |
Settings.php:478 | ON CONFLICT("key") DO UPDATE SET value = :v2 | ON DUPLICATE KEY UPDATE value = :v2 |
Settings.php:568 | ON CONFLICT(user_id) DO UPDATE SET last_activity = :now2 | ON DUPLICATE KEY UPDATE last_activity = :now2 |
Vote.php:187 | ON CONFLICT({idCol}, user_id) DO UPDATE SET vote = :vote2 WHERE vote <> 1 | ⚠️ MySQL 的 ON DUPLICATE KEY UPDATE 不支持 WHERE → 必须改用 IF() 表达式:vote = IF(vote <> 1, :vote2, vote) |
Setting.php:38 | ON CONFLICT("key") DO UPDATE SET value = excluded.value | ON DUPLICATE KEY UPDATE value = VALUES(value) |
SessionHandler.php:127 | ON CONFLICT(id) DO UPDATE SET data = :data2, expires = :expires2 | ON DUPLICATE KEY UPDATE data = :data2, expires = :expires2 |
Captcha.php:27 | ON CONFLICT("key") DO UPDATE SET value = :v2 | 同 Settings |
Permission.php:403 | ON CONFLICT(category_id, group_id) DO UPDATE SET can_view=:v2,... | ON DUPLICATE KEY UPDATE can_view=:v2,... |
Thread.php:127 | ON CONFLICT(id) DO UPDATE SET ... | 同 |
Tag.php:87 | ON CONFLICT(name) DO NOTHING | INSERT IGNORE |
⚠️
excluded.value→VALUES(value):MySQL 8.0.20+ 已弃用VALUES()函数,推荐表别名写法INSERT ... AS new ON DUPLICATE KEY UPDATE value = new.value。为兼容 5.7/8.0 双版本,建议统一用VALUES()并接受弃用告警,或按版本分支。
⑤ 日期函数
| 位置 | SQLite | MySQL |
|---|---|---|
Blog.php:47/49 | strftime('%Y-%m', created_at) | DATE_FORMAT(created_at, '%Y-%m') |
BlogController.php:60 | strftime('%Y-%m', b.created_at) = :archive | DATE_FORMAT(b.created_at, '%Y-%m') = :archive |
Schema.php:622 | created_at TEXT DEFAULT (datetime('now','localtime')) | created_at DATETIME DEFAULT CURRENT_TIMESTAMP |
⑥ 类型转换 CAST(x AS TEXT) → CAST(x AS CHAR)
Settings.php:423
⑦ 元数据查询
| 用途 | SQLite | MySQL | 位置 |
|---|---|---|---|
| 取列名 | PRAGMA table_info(t) | SHOW COLUMNS FROM t 或 information_schema.COLUMNS | Model.php:84、Schema.php:704/833/964、Queue.php:77、Settings.php:1164、cli/migrate.php:198 |
| 取表名 | SELECT name FROM sqlite_master WHERE type='table' | SHOW TABLES | Settings.php:1157 |
| schema 版本 | PRAGMA user_version(读/写) | 无对应 → 自建 fh_schema_meta(k, v) 表 | Schema.php:936/994、SearchIndexStore.php:99/114/140 |
⑧ 连接/事务/只读
| 用途 | SQLite | MySQL | 位置 |
|---|---|---|---|
| 事务开启 | BEGIN IMMEDIATE | START TRANSACTION(exec 形式) | IDGenerator.php:86、SearchIndexStore.php:224、ViewCounter.php:153 |
| 只读连接 | PRAGMA query_only = 1 | SET SESSION TRANSACTION READ ONLY 或只读账号 | Schema.php:1037、DatabaseController.php:480/546/633 |
| 锁等待 | PRAGMA busy_timeout = 5000 | SET SESSION innodb_lock_wait_timeout = 5 | DBFactory.php:39-48、IDGenerator.php:58/69 |
| WAL | PRAGMA journal_mode = WAL + 校验 | 无对应(InnoDB redo log 天然) | DBFactory.php:39-48,99-108 |
| 跨库 JOIN | ATTACH DATABASE ... AS mi | 原生跨库 JOIN | SearchIndexStore.php:362-382 |
| 锁错误判定 | 'database is locked' | 'Lock wait timeout exceeded' / 'Deadlock found' | IDGenerator.php:205-211 |
⑨ 备份/维护
| 用途 | SQLite | MySQL | 位置 |
|---|---|---|---|
| 热备份 | VACUUM INTO | 无(见 §5.1 后台方案) | DatabaseController.php:715 |
| 整理 | VACUUM + wal_checkpoint(TRUNCATE) | OPTIMIZE TABLE | DatabaseController.php:870-871 |
| 一致性 | 桶 vs 索引行数对拍 | CHECK TABLE + 应用层对拍 | DatabaseController.php:619-698 |
⑩ DDL 语法差异
| SQLite | MySQL | 影响 |
|---|---|---|
CREATE TABLE IF NOT EXISTS | ✅ 支持 | 无 |
CREATE INDEX IF NOT EXISTS | ❌ 8.0 不支持(MariaDB 支持) | 全部索引建表语句(约 40 条)需改为「先查 information_schema.STATISTICS 再建」 |
DROP INDEX IF EXISTS x | ❌ 8.0 不支持 | SearchIndexStore.php:143 |
ALTER TABLE t ADD COLUMN c | ✅ 支持 | 无 |
INTEGER PRIMARY KEY(隐式 rowid 自增) | BIGINT AUTO_INCREMENT PRIMARY KEY | 全部 30 张表 |
TEXT DEFAULT '' | ⚠️ 8.0.13 之前直接报错(BLOB/TEXT 不能有默认值) | 建议改 VARCHAR(n) DEFAULT '' 或 TEXT NULL;8.0.13+ 可用 DEFAULT ('') |
TEXT(无长度) | TEXT / LONGTEXT | 长正文用 LONGTEXT |
"双引号标识符" | ` 反引号 ` | 见 §3.3 |
| 部分索引 / 覆盖索引 | MySQL 无"覆盖索引"概念(但 EXPLAIN 有 Using index) | 索引策略需重审 |
3.3 ⚠️⚠️ 双引号标识符(最容易漏、后果最严重)
SQLite:"key" 是标识符引用(等同于反引号)
MySQL:默认 sql_mode 不含 ANSI_QUOTES 时,"key" 是字符串字面量!
⇒ SELECT * FROM settings WHERE "key" = :k 在 MySQL 里会变成「常量字符串 'key' 等于 :k」,不报错、返回错误结果。
受影响位置(需全部改为反引号):
| 文件 | 行 | 原写法 |
|---|---|---|
app/Core/Model.php | 27, 35, 49, 56, 124, 127, 164, 172, 180, 202 | "{$this->table}"、"{$key}"、"{$this->primaryKey}" |
app/Helpers/Settings.php | 265, 423, 478, 1164, 1176, 1184 | "key"、"value"、"$t" |
app/Helpers/Captcha.php | 27 | "key" |
app/Models/Setting.php | 38 | "key" |
app/SplitDB/Schema.php | 236 | "key" |
cli/migrate.php | 243, 456 | "{$col}" |
改造建议:在 Grammar 里提供 quoteIdent($name),Model / Settings 等统一走它,禁止手写引号。
附带风险:
settings表的列名key是 MySQL 保留字,任何地方漏引号都会语法错误 → 建议顺手把列名改为skey(但会牵连迁移与所有引用,收益不高,建议只做引号统一)。
3.4 其他 MySQL 严格模式陷阱(需全量扫描)
| 陷阱 | 说明 | 排查方式 |
|---|---|---|
ONLY_FULL_GROUP_BY | MySQL 5.7+ 默认开启。SELECT a, b FROM t GROUP BY a 会报错(SQLite 宽松放行) | 全量 grep GROUP BY,逐条核对 SELECT 列是否都在 GROUP BY 或聚合函数内 |
STRICT_TRANS_TABLES | 超长字符串/非法日期报错而非静默截断 | 建表时列宽要 ≥ 实际数据;TEXT 改 VARCHAR 时尤其注意 |
NO_ZERO_DATE / NO_ZERO_IN_DATE | '0000-00-00' 报错 | 检查历史数据里的空日期 |
| 标识符大小写 | Linux 下 lower_case_table_names=0 → 表名大小写敏感 | 全部用小写表名(现状已全小写 ✅) |
| 索引名长度 | 上限 64 字符 | 现有索引名都很短 ✅ |
LIKE 大小写 | 取决于 collation:utf8mb4_general_ci 不敏感 / utf8mb4_bin 敏感 | 搜索相关逻辑要固定 collation |
LIMIT 参数 | MySQL 的 LIMIT ? 在 EMULATE_PREPARES=false 时不能绑参(需 intval 内联) | 现有代码多为内联 (int)$limit ✅ |
TEXT 列不能建前缀外的完整索引 | 需指定前缀长度或改 VARCHAR | search_index.token 等需评估 |
4. 架构降维设计(SplitDB → MySQL)
这是整个方案最需要拍板的部分。SQLite 时代的"分库分表"在 MySQL 下要重新设计。
4.1 映射总表
| 现状(SQLite) | MySQL 方案 | 决策理由 |
|---|---|---|
business.sqlite 23 表 | 库 flinthub,表名不变 | 直接映射 |
main_index.sqlite(2 表) | 同库 topic_index / reply_index | 单库后可以真正 JOIN,ATTACH 那套全删 |
sessions.sqlite | 独立库 flinthub_session 或同库 | 建议独立库:保留"会话与业务物理隔离"的既有设计意图 |
global_id.sqlite(id_generator) | 方案 A:保留 id_generator 表(改动最小)<br>方案 B:改用 AUTO_INCREMENT(更 MySQL 原生) | ⚠️ 推荐 A:IDGenerator::nextId() 被 Thread/Post 深度依赖(跨桶取号),改 B 要重写取号链路 |
queue_0~2.sqlite(3 队列) | 单表 task_queue | 3 队列是为"打散 SQLite 写锁"而设,MySQL 行锁不需要。Queue::push() 的 mt_rand(0,2) 去掉 |
search_{YYYYQn}.sqlite(季度分文件) | 单表 search_index | 季度分文件是为绕开 SQLite 单文件膨胀。MySQL 单表几千万行无压力 |
bucket/{Q}/{N}.sqlite(topic/reply) | 单表 topic / reply | 季度+哈希分片在 MySQL 下完全不需要(B+ 树索引已解决) |
data/extern/**/*.txt(正文外置) | 方案 A:保留文件外置<br>方案 B:入 LONGTEXT 列 | ⚠️ 推荐 B:这是 MySQL 的"原生做法",且能消除 .bin/.idx 归档那一整套复杂逻辑(ExternMerger / ExternIdxRepairer / ExternNamespaceMigrator 共 1400+ 行全部可以下线) |
data/runtime/*.cache | 保留 | 文件缓存与存储引擎无关 |
protected/rate_cache/ | 保留 | 同上 |
APCu 浏览量计数(ViewCounter) | 保留 | 与存储引擎无关 |
4.2 bucket_path 字段的处置
现状:topic_index.bucket_path 是读取路由的唯一依据(Schema::bucketFromPath() Schema.php:1014)。
MySQL 下:
- 字段保留但废弃(
bucket_path恒为''),避免大改Thread/Post的读取链路 - 或彻底删除并清理所有引用点(
Thread.php65 处 SQL 调用里有多处) - ⚠️ 建议阶段一保留、阶段二清理,降低单次改动面
4.3 归档机制
现状:cli/archive.php 把 18 个月以上冷数据从 bucket/active/ 移到 bucket/archive/(物理文件移动)。
MySQL 下两个选项:
- A:
topic表加is_archived标记(topic_index已有此列,Schema.php:826),归档 = 批量UPDATE,推荐 - B:MySQL 8.0 原生分区表(
PARTITION BY RANGEoncreate_time)——更优雅但引入运维复杂度
4.4 计数器与并发
| 机制 | 现状 | MySQL 下 |
|---|---|---|
浏览量 ViewCounter | APCu 计数 + 30s 节流落库 | 不变(UPDATE ... SET view_count = view_count + ? 是原子的) |
运行时计数 Settings::runtimeIncr | 内存累加 + flushCounters() 批量落库 | 不变 |
帖子回复数 updateCounters() | 桶 + 索引双写 | 简化为单表 UPDATE(不再有"双写") |
| 全局 ID | BEGIN IMMEDIATE 表自增 | ⚠️ BEGIN IMMEDIATE → START TRANSACTION + SELECT ... FOR UPDATE |
5. 后台改造方案(重点)
后台数据库模块是受影响最集中的地方。现状:1 个控制器 1113 行 + 3 个视图,几乎全部围绕 SQLite 设计。
5.1 后台路由现状
index.php:285-291:
$r->get('/database', [AdminDatabaseController::class, 'index']);
$r->post('/database', [AdminDatabaseController::class, 'index']);
$r->get('/database/engine', [AdminDatabaseController::class, 'engine']);
$r->post('/database/engine', [AdminDatabaseController::class, 'engine']);
$r->get('/database/overview', [AdminDatabaseController::class, 'overview']);
$r->get('/database/settings', [AdminDatabaseController::class, 'settings']);
$r->post('/database/settings', [AdminDatabaseController::class, 'settings']);建议:路由全部保留(保老书签不失效),但页面内容按引擎分派。
5.2 页面一:数据库管理页 /admin/database
现状(DatabaseController::index() :23-145 + 视图 admin/database.php)
| 区块 | 现状实现 | MySQL 改造 |
|---|---|---|
| 信息卡「类型」 | 硬编码字符串 SplitDB(SQLite 分片)(视图 :56) | 改为动态:MySQL 8.0.x(InnoDB) / SQLite 分片 |
| 信息卡「路径」 | ShardRouter::dataPath() | 改为 主机:端口/库名 |
| 信息卡「大小」 | foreach (sqliteFiles()) filesize() 累加(:121-125) | SELECT SUM(data_length+index_length) FROM information_schema.TABLES WHERE table_schema = DATABASE() |
| 优化按钮 | PRAGMA wal_checkpoint(TRUNCATE) + VACUUM(:864-876) | OPTIMIZE TABLE 逐表(可加"碎片率 > X% 才优化"的判断) |
| 备份按钮 | ① checkpoint 全部库 ② Database::closeAll() ③ zipDataDir() 打包 data/(:705-733) | ⚠️ 必须重写,见 §5.2.1 |
| 恢复/导入 | .zip 解包覆盖 data/ / .sqlite 按文件名映射(:884-978) | 改为 .sql 文件导入 |
| 备份列表/下载/删除 | glob('forum_backup_*')(:99-119) | 保留(备份文件仍落 protected/backups/) |
| extern 合并区块 | .txt → .bin 分批合并(视图 :90-153) | ❌ 整个区块移除(MySQL 下无 extern 文件) |
| 危险操作警示 | 静态文案(视图 :49-51) | 文案改为 MySQL 语境 |
5.2.1 ⚠️⚠️ 备份方案的硬约束
核心矛盾:mysqldump 是命令行工具,需要 shell 执行;而项目红线明确「站长不用 SSH,升级器必须纯 Web」(见 REF_环境与数据 §A)。同时宝塔 disable_functions 常见禁 exec/shell_exec。
可选方案对比:
| 方案 | 可行性 | 说明 |
|---|---|---|
| A. 纯 PHP 逻辑导出 ✅ 推荐 | 高 | SHOW CREATE TABLE 取 DDL + 分块 SELECT 生成 INSERT 语句,流式写 .sql 文件。参照 migrate.php 的非缓冲查询思路(migrate.php:109-117)。缺点:大库慢,需分批 + meta refresh 续批(可直接复用 extern 合并那套续批模式,视图 :104-118) |
B. 依赖 mysqldump | 低 | 需 exec 可用 + 二进制在 PATH。宝塔环境不保证,且违反"纯 Web"红线 → 否决 |
C. SELECT ... INTO OUTFILE | 中 | 需 FILE 权限 + secure_file_priv 配置,宝塔默认禁用 → 不可靠 |
| D. 只做"导出下载",不留存服务器 | 中 | 简单,但失去"定时备份"能力 |
推荐组合:A(服务器端留存)+ 流式下载。备份文件名约定保持 forum_backup_{ts}.sql,与现有 forum_backup_*.zip 的轮转逻辑(rotateBackups() :742-760)复用。
5.2.2 导入/恢复的服务端确认
现状已有服务端 CONFIRM 硬校验(CONFIRM_TOKEN :703、:888、:938),这套机制原样保留,只把"文件类型"从 .zip/.sqlite 改为 .sql。
5.3 页面二:SplitDB 引擎概览 /admin/database/overview
现状(DatabaseController::overview() :177-195 + 视图 admin/database/overview.php)
展示:分片分布(32 桶 × topic/reply 行数)、总帖子数、建议桶数、当前季度。
MySQL 下这些概念全部消失。三个选项:
| 选项 | 说明 |
|---|---|
| A. 改造为「数据库运行状态」页 ✅ 推荐 | 展示:各表行数 + 数据/索引大小、慢查询 Top N、连接数/线程、InnoDB 缓冲池命中率、表碎片率 |
| B. 保留页面但改为「表清单」 | 简单,但价值低 |
| C. 下线该页 | 路由 301 到 /admin/database,最省事但丢监控能力 |
5.4 页面三:引擎设置 /admin/database/settings
现状(DatabaseController::settings() :201-290 + 视图 admin/database/settings.php 23KB)
| 设置项 | 现状 | MySQL 改造 |
|---|---|---|
桶数调整 handleSaveBuckets() :312-331 | 白名单 32/64/128/256,写 config.php | ❌ 移除(无分片概念) |
自动扩容 handleSaveAutoExpand() :336-352 | auto_expand_enabled + 阈值 | ❌ 移除 |
单桶容量 handleSaveCapacity() :357-369 | bucket_safe_capacity 白名单 | ❌ 移除 |
归档天数 handleSaveArchive() :375-389 | archive_after_days | ✅ 保留(逻辑改为 UPDATE ... SET is_archived=1) |
一致性检查 consistencyCheck() :619-698 | 桶文件行数 vs 索引行数对拍 | 🔄 改造为「表完整性检查」:CHECK TABLE + 应用层对拍(如 topic_index 行数 vs topic 行数) |
config.php 写权限检测 :246-247 | is_writable($configFile) | ✅ 保留(改为写 MySQL 连接配置) |
扩容记录(audit_logs 查询):257-268 | target_type='database' | 🔄 保留框架,内容改为"配置变更记录" |
连带影响:app/SplitDB/BucketAutoScaler.php(224 行)在 MySQL 下整体废弃。但它里面的 config.php 安全写入逻辑(备份 → 正则替换 → 原子写 → opcache 失效,:54-157)非常有价值,建议抽成 app/Core/ConfigWriter.php 复用到「MySQL 连接配置」场景。
5.5 新增页面:MySQL 连接配置
为什么必须新增:现在 config.php 里没有任何 DB 配置项(全是 SPLITDB_*),MySQL 需要 host/port/name/user/pass/charset。
设计:
后台 → 系统 → 数据库配置(新增路由 /admin/database/connection)
├── 表单:主机 / 端口 / 库名 / 用户名 / 密码 / 字符集 / 表前缀
├── 按钮:测试连接(实时探测,返回版本 + 权限)
├── 按钮:保存(写入配置)
└── 展示:当前驱动、连接状态、服务器版本⚠️ 密码存储:不能明文写 config.php(config.php 进包/可被下载)。参照现有 MAIL_PASS_KEY / CRON_KEY 的治理模式(config.php:44-62、:132-150):
- 写入
protected/db.php(内容<?php return ['pass' => '...'];,不进发行包) - 或环境变量
FLINTHUB_DB_PASS
⚠️ 同时必须:按项目红线,config.php 由 install.php::configTemplate() 生成、不进包 → 老用户永远拿不到新配置项 → 必须在 index.php(进包)做兼容前移(同 SITE_URL 那套处理,config.php:105-123)。
5.6 新增功能:SQLite → MySQL 迁移工具(Web 化)
这是"让存量站切过来"的关键。现状只有 CLI 版(cli/migrate.php),但站长不用 SSH。
设计(直接参照 extern 合并的续批模式,DatabaseController::index() :41-60 + 视图 :104-118):
后台 → 数据库 → 迁移到 MySQL
├── 步骤 1:填写 MySQL 连接 → 测试连接
├── 步骤 2:预检(dry-run)→ 展示各表行数、预计耗时、磁盘占用
├── 步骤 3:建表(执行 MySQL DDL)
├── 步骤 4:迁数据(分表分批,进度条 + meta refresh 自动续批 + 断点续跑)
│ 每批:读 SQLite 一页 → 写 MySQL → 记录断点
├── 步骤 5:校验(逐表行数对拍 + 抽样字段比对)
└── 步骤 6:切换(写 config.php 的 DB_DRIVER=mysql + 连接信息)红线(沿用 cli/migrate.php 的既有设计,:397-409):
- 绝不删源库(SQLite 保持原样,可随时回滚)
- 迁移中断可重跑(
INSERT ... ON DUPLICATE KEY UPDATE幂等) - 迁移期间建议置维护模式(
settings.site_closed,init.php:126)
5.7 安装向导 install.php
现状(752 行):5 步向导(欢迎 → 许可 → 环境检测 → 管理员 → 完成),全程无数据库配置。
改造点:
| 位置 | 现状 | 改造 |
|---|---|---|
detectEnvironment() :83-135 | 检测 PDO SQLite 驱动、SQLite 版本 ≥ 3.33(:88、:91-102) | 增加 PDO MySQL 驱动 + MySQL 版本 ≥ 5.7/8.0 检测;两项都保留(双引擎) |
步骤 4 表单 :704-728 | 只有管理员三项 | 新增数据库配置区(驱动选择 radio + MySQL 连接表单) |
configTemplate() :167-329 | 纯 SPLITDB_* 模板 | 增加 DB_DRIVER + DB_HOST/PORT/NAME/USER/CHARSET 等常量 |
runInstall() :332-415 | 调 Schema::bootstrap()(:359) | 按驱动分派:SQLite 走 Schema::bootstrap(),MySQL 走 MysqlMigrator::createAll() |
安装判定 :24-38 | 依据 SPLITDB_DATA_PATH 或 business.sqlite | 增加 MySQL 判定(能否连上 + 表是否存在) |
5.8 其他后台受影响点
| 位置 | 影响 | 改造 |
|---|---|---|
app/Views/admin/_sidebar.php | 侧边栏「数据库」文案/图标 | 文案调整 |
app/Views/admin/index.php | 仪表盘提到 SQLite | 改为动态展示当前驱动 |
app/Views/home/index.php | 首页提到 SQLite | 文案调整 |
app/Helpers/Settings.php:1157/1164 | cleanupOrphanAttachments 用 sqlite_master + PRAGMA table_info 扫插件库 | ⚠️ 重要:这是扫插件自己的 .sqlite 文件(data/ 下),与主库引擎无关 → 建议保留原逻辑(插件库仍是 SQLite),只把主库相关部分适配 |
app/Controllers/Admin/DashboardController.php | 基本信息页(:86-87 等处) | 检查是否有 DB 信息展示 |
app/SplitDB/ExternMerger.php(524 行)<br>ExternIdxRepairer.php(289 行)<br>ExternNamespaceMigrator.php(452 行) | extern 合并/修复/迁移 | ❌ MySQL 下全部下线(若采纳 §4.1 的正文入 LONGTEXT 方案) |
cli/archive.php(252 行) | 归档桶文件移动 | 🔄 改为 UPDATE ... SET is_archived=1 |
cli/rebuild_index.php / rebuild_search.php | 重建搜索索引 | 🔄 改为单表 TRUNCATE + 重建 |
cli/shrink_business.php | SQLite VACUUM 收缩 | ❌ 下线(MySQL 用 OPTIMIZE TABLE) |
cli/worker.php / worker2.php | 队列消费 | 🔄 单表队列适配 |
cron_trigger.php | 定时任务(宝塔拉起) | 🔄 队列/统计适配 |
6. 存量数据迁移方案
6.1 迁移器设计(反向复用 cli/migrate.php)
cli/migrate.php 已经定义了干净的抽象(:72-84):
interface MigrateSourceInterface {
public function tableNames(): array;
public function rows(string $table): iterable; // 逐行 yield,大表防 OOM
public function count(string $table): int;
public function close(): void;
}反向实现:
SqliteSplitDbSource implements MigrateSourceInterface—— 源改为「SplitDB 7 类库 + extern 文件」rows('threads')→ 遍历全部桶文件的topic表 + 读 extern 正文rows('posts')→ 遍历全部桶文件的reply表 + 读 extern 正文MysqlTarget—— 目标改为 PDO mysql
6.2 迁移顺序(依赖序)
1. 建表(MySQL DDL,全部表 + 索引)
2. business 23 表(users/categories/... → 逐表,保留 ID)
3. topic_index + reply_index(main_index)
4. topic + reply(遍历全部桶,季度 × 32 桶;正文从 extern 读入 LONGTEXT)
5. task_queue(3 队列合并为 1 表)
6. search_index(全部季度文件合并为 1 表)
7. sessions(可选,建议不迁——迁了会踢全站下线)
8. id_generator(游标推进到 max(id))
9. 全部 AUTO_INCREMENT 对齐(ALTER TABLE ... AUTO_INCREMENT = max+1)6.3 校验(必须做,不能省)
| 校验项 | 方法 |
|---|---|
| 行数对拍 | 每表 COUNT(*) SQLite vs MySQL |
| 主键连续性 | MAX(id) / MIN(id) 对比 |
| 正文完整性 | 抽样 N 条帖子,比对 extern 文件内容 vs MySQL LONGTEXT |
| 外键关系 | 抽查 reply.pid 是否都能在 topic 找到 |
| 索引计数 | topic_index 行数 vs topic 行数 |
6.4 回滚方案
这是路线 B 的最大优势:
- SQLite 数据原样保留(迁移器只读源库)
- 回滚 = 后台把
DB_DRIVER改回sqlite(或手工改config.php) - 迁移期间的写入(若没置维护模式)需要二次同步 → 强烈建议迁移时置维护模式
7. 插件兼容性
7.1 现状
- 48 个插件,约 1200 处 SQL 调用点
- 每个插件自带独立 SQLite 库(
plugins/<name>/data/*.sqlite) - 插件通过
Plugin::db()获取连接(经DBFactory)
7.2 兼容策略
| 策略 | 说明 |
|---|---|
| A. 插件库保持 SQLite ✅ 推荐 | 插件的 data/*.sqlite 继续用 SQLite,只有核心业务库切 MySQL。理由:① 插件库体量小、并发低 ② 改动面从 1200 处降到 0 ③ 保留插件生态稳定性 |
| B. 插件库也切 MySQL | 需要给每个插件的建表 SQL 做方言适配(48 个插件 × N 张表),工作量大且每次新插件都要遵守新规范 |
⇒ 推荐 A:Plugin::db() 继续返回 SQLite 连接,与核心驱动解耦。
7.3 必须排查的插件
即使用了策略 A,以下插件仍需检查(它们跨库引用核心表):
| 插件 | 风险点 | 位置 |
|---|---|---|
bitiful_storage | SyncQueue.php 有 7 处 SQLite 语法(INSERT OR IGNORE 等) | plugins/bitiful_storage/SyncQueue.php |
market_hub | MarketDb.php 14 处、ShotRepo.php 12 处 | plugins/market_hub/ |
mcp_server | TokenManager.php 21 处 | plugins/mcp_server/ |
mobile_api | TokenManager.php 15 处 | plugins/mobile_api/ |
daily_checkin | CheckinController.php 9 处 | plugins/daily_checkin/ |
ai_assistant | Plugin.php 70 处(最多) | plugins/ai_assistant/ |
⚠️ 注意:以上计数是全部 SQL 调用,不等于"需要改的"。真正要改的仍以 §3.2 的方言清单为准。
7.4 插件开发规范要同步更新
docs/插件开发规范.md 需要新增一节「双引擎兼容」:插件写 SQL 时禁用 INSERT OR IGNORE / ON CONFLICT / strftime 等 SQLite 专有语法,改用 Grammar 提供的兼容方法。
8. 分阶段实施计划
阶段 P0:抽象层(前置,无功能变化)
| 任务 | 产出 | 验收 |
|---|---|---|
抽 DriverInterface + Grammar | app/Db/ 目录 | SQLite 走新抽象,功能零回归 |
Database 门面改为分派 | app/Core/Database.php | 现有测试全绿 |
现有 Schema 的 DDL 抽到 SqliteGrammar | — | 建表结果与现状逐字节一致 |
这一阶段不碰 MySQL,纯粹为后续铺路。完成后可以单独发一版,验证抽象层无副作用。
阶段 P1:MySQL 驱动 + 建表
| 任务 | 产出 |
|---|---|
MysqlDriver + MysqlGrammar | 连接、执行、元数据、方言 |
| 30 张表的 MySQL DDL | 建表 + 索引(注意 CREATE INDEX IF NOT EXISTS 需先查 information_schema) |
install.php 支持 MySQL 安装 | 全新安装可跑 MySQL |
阶段 P2:核心 SQL 方言适配
| 任务 | 数量 |
|---|---|
| §3.2 的 10 类方言改写 | ~60 处 |
| §3.3 双引号标识符 → 反引号 | ~40 处 |
§3.4 严格模式排查(GROUP BY / 列宽 / 日期) | 全量扫描 |
阶段 P3:后台模块重写
| 任务 | 产出 |
|---|---|
| 数据库管理页改造 | DatabaseController::index() + 视图 |
| 备份改为纯 PHP 逻辑导出 | 新增 MysqlBackup 类 |
| 引擎概览页改造 | overview() + 视图 |
| 引擎设置页裁剪 | settings() + 视图 |
| 新增 MySQL 连接配置页 | 新控制器 + 新视图 |
| 新增 Web 迁移工具 | 新控制器 + 新视图 |
阶段 P4:存量迁移 + 校验
| 任务 | 产出 |
|---|---|
SqliteSplitDbSource 反向迁移器 | cli/migrate_to_mysql.php |
| Web 化迁移向导 | 后台页面 |
| 校验工具 | 行数/正文/关系对拍 |
阶段 P5:插件兼容 + 文档
| 任务 | 产出 |
|---|---|
| 6 个重点插件排查 | 兼容性修复 |
docs/插件开发规范.md 更新 | 双引擎章节 |
| 发布包体系适配 | cli/release_build.php 等 |
9. 风险清单与红线
| # | 风险 | 等级 | 缓解 |
|---|---|---|---|
| 1 | 双引号标识符静默错误(不报错、返回错数据) | 🔴 极高 | 全局搜索 " 包裹的标识符;Grammar::quoteIdent() 收口;必须写专项断言 |
| 2 | ONLY_FULL_GROUP_BY 报错(MySQL 5.7+ 默认) | 🟠 高 | 全量 grep GROUP BY 逐条核对 |
| 3 | TEXT DEFAULT '' 建表失败 | 🟠 高 | 统一改 VARCHAR(n) DEFAULT '' 或 TEXT NULL |
| 4 | 备份方案选错(依赖 mysqldump 导致生产不可用) | 🟠 高 | 纯 PHP 逻辑导出(§5.2.1) |
| 5 | 迁移中丢正文(extern 读取失败) | 🔴 极高 | 沿用 migrate.php 的红线设计(:397-409):失败即中止 + 清理 |
| 6 | REPLACE INTO 语义陷阱(DELETE+INSERT) | 🟠 高 | 改用 ON DUPLICATE KEY UPDATE |
| 7 | config.php 不进包 → 老用户拿不到 DB 配置 | 🟠 高 | 必须在 index.php(进包)做兼容前移 |
| 8 | 插件库跨引擎混用 | 🟡 中 | 采纳策略 A(插件库保持 SQLite) |
| 9 | 迁移期间数据双写不一致 | 🟠 高 | 迁移时置维护模式 |
| 10 | 索引 IF NOT EXISTS 不支持 | 🟡 中 | 先查 information_schema.STATISTICS 再建 |
| 11 | 性能不升反降(分片设计的初衷是绕开 SQLite 写锁,MySQL 下分片是纯负担) | 🟡 中 | 单表化后必须重做索引策略并压测 |
| 12 | 发布体系冲突(核心包、升级器、release_build.php) | 🟡 中 | 阶段 P5 统一处理 |
10. 工作量估算
| 阶段 | 内容 | 预估 |
|---|---|---|
| P0 | 抽象层(DriverInterface + Grammar) | 1~2 天 |
| P1 | MySQL 驱动 + 30 表 DDL + 安装器 | 1~2 天 |
| P2 | 核心 SQL 方言适配(~100 处) | 1~2 天 |
| P3 | 后台模块重写(控制器 + 3 视图 + 2 新页) | 2~3 天 |
| P4 | 反向迁移器 + Web 向导 + 校验 | 2~3 天 |
| P5 | 插件兼容 + 文档 + 发布体系 | 1~2 天 |
| — | 回归测试(核心链路 + 后台 + 迁移演练) | 2~3 天 |
| 合计 | 10~17 个工作日 |
11. 改造工作区 D:\phpstudy_pro\WW2(全量独立副本)
2026-10-11 已建:
WW2不是「只放待改文件的补丁区」,而是 MySQL 版的完整独立副本——全量复制
WWW的全部代码 + 全部样式 + 全部插件 + 全部数据,可独立启动运行(已实测通过)。从建立之日起,MySQL 版与 SplitDB(spd)版彻底分离,各自独立演进;源站
WWW一个字节未动。工作区说明 →
D:\phpstudy_pro\WW2\_WW2工作区说明.md;逐文件改动索引 →D:\phpstudy_pro\WW2\_改造清单.md。
11.1 复制口径(实测数据)
| 校验项 | 结果 |
|---|---|
| 复制文件总数 | 4 673 个(含 data/ 全量与 assets/uploads/ 全量) |
| 与源站逐文件 MD5 比对 | 4 670 个共有文件 → 0 处不一致(字节级一致) |
| 目录结构比对 | 与源站完全一致(含 assets/themes/、data/log/ 等空目录) |
| 磁盘占用 | WW2 ≈ 108 MB(WWW 921 MB,差额全是被排除的 4 棵无关树) |
| 独立运行冒烟 | ✅ 常量指向 WW2/data、WW2/assets/uploads/;users=291、topic_index=196、settings=192;Plugin::getPlugins()=56;assets/css/modern.css 91 278 B 等静态资源齐全;引导期零告警 |
11.2 明确排除项(均非本站运行代码)
| 排除项 | 原因 |
|---|---|
flinthub-client/(19 110 文件) | 独立的客户端项目,不属论坛站点 |
release/(7 067)、update/(663) | 发布/升级产物,可由 cli/release_build.php 重建 |
flinthub1.0.23/(583) | 历史版本完整副本 |
根级 *.zip、.workbuddy-ai/ | 安装/升级包;工作区记忆(新目录会自动新建) |
| 易失运行时缓存 | protected/cache/templates、protected/rate_cache/、protected/error.log、protected/backups/、data/runtime/migrate_uploads_*.log —— 全部可自动重建 |
⚠️
data/与assets/uploads/已整体复制,所以副本现在就能独立跑起来。等进入 MySQL 阶段后,
data/meta/*.sqlite、data/bucket/、data/extern/才会被 MySQL 取代 —— 届时再清。⚠️
WW2从此不再随WWW的升级包覆盖(两版已分离);需要同步某次修复时,手工挑文件搬。
11.3 需要改动的文件(索引 —— 不是工作区全貌)
收录口径:「含 SQLite 专有语法」∪「结构 / 文案必须改」的并集。
全仓 SQL 调用点约 1800 处,真正要改的约 100 处。逐文件明细见
WW2/_改造清单.md。
| 分组 | 文件数 | 方言命中行数 | 代表文件(命中数) |
|---|---|---|---|
P1 数据引擎 app/SplitDB/ | 14 | 62 | Schema.php(22)、DBFactory.php(9)、IDGenerator.php(9) |
| P2 SQL 方言点 | 14 | 30 | Helpers/Settings.php(11)、Helpers/Tag.php(5) |
| P3 后台模块 | 8 | 12 | Admin/DatabaseController.php(12) |
| P4 安装 / 配置 / 入口 | 5 | 7 | install.php(4) |
| P5 CLI 脚本 | 15 | 62 | migrate.php(9)、archive.php(9)、shrink_business.php(8) |
| P6 插件(策略 A 下可免改) | 13 | 30 | ai_assistant/Plugin.php(6)、market_hub/MarketDb.php(6) |
| 合计 | 73 | 203 |
11.4 可整份下线的「废弃 4 件套」
若采纳「正文入 LONGTEXT」,以下 4 个文件(合计 1736 行)可整体删除:
| 文件 | 行数 |
|---|---|
app/SplitDB/ExternStorage.php | 471 |
app/SplitDB/ExternMerger.php | 524 |
app/SplitDB/ExternIdxRepairer.php | 289 |
app/SplitDB/ExternNamespaceMigrator.php | 452 |
同理可下线 BucketAutoScaler.php(224 行,桶扩容)与 ShardRouter.php 的分片路由逻辑。
11.5 需新建的文件(WW2 内原本不存在,属新增)
app/Db/DriverInterface.php 驱动契约
app/Db/SqliteDriver.php 现有逻辑搬入
app/Db/MysqlDriver.php 新增
app/Db/Grammar.php + SqliteGrammar / MysqlGrammar(方言收口)
app/Db/Migrator.php 建表(DDL 按 Grammar 生成)
app/Core/ConfigWriter.php 从 BucketAutoScaler 抽出的 config.php 安全写入11.6 对照方法
# 单文件对照
diff -u /d/phpstudy_pro/WWW/app/SplitDB/Schema.php \
/d/phpstudy_pro/WW2/app/SplitDB/Schema.php
# 批量看改动概况(两棵目录现在完全同构,可直接 -r 比对)
diff -rq /d/phpstudy_pro/WWW/app /d/phpstudy_pro/WW2/app
# ⚠️ 全仓 -r 比对必须排除无关树,否则会扫到 flinthub-client(19 110 文件)
diff -rq -x 'flinthub-client' -x 'release' -x 'update' -x 'flinthub1.0.23' -x '*.zip' \
/d/phpstudy_pro/WWW /d/phpstudy_pro/WW212. 待你拍板的 5 个决策点
- 路线:是否采纳路线 B(双引擎)?还是直接单引擎替换?
- 正文存储:
extern正文是入 MySQLLONGTEXT(推荐,可下线 1400 行 extern 代码)还是保留文件外置? - 全局 ID:保留
id_generator表(改动小)还是改AUTO_INCREMENT(更原生)? - 插件库:是否采纳策略 A(插件库保持 SQLite)?
- 迁移范围:是否需要存量数据迁移(决定是否要做 P4 阶段,影响 2~3 天工期)?
本文档为方案设计,未改动任何代码。 确认路线后,建议从 P0 抽象层起步——那一阶段不引入 MySQL、不改变现有行为,是风险最低的切入点。