[开发]FlintHub MySQL 版本改造方案

👑Lv.11 元老 🌞 骨干会员
2026-10-24 00:35:15

本文由 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.sqlite23 张业务表(用户/版块/设置/博客/私信/附件/标签/投票/积分/通知/审计…)Schema::businessDb() Schema.php:265
data/meta/main_index.sqlitetopic_index / reply_index 帖子主索引Schema::mainIndexDb() Schema.php:271
data/meta/sessions.sqlite会话(与业务库物理隔离)Schema::sessionDb() Schema.php:277
data/meta/global_id.sqliteid_generator 全局取号Schema::globalIdDb() Schema.php:283
data/meta/task_queue/queue_0~2.sqlite3 个队列库(打散写锁竞争)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_index
  • ensureSessions() Schema.php:883-894 — sessions
  • ensureBucketTables() Schema.php:929-996 — 桶内 topic / reply
  • ensureGlobalId() Schema.php:1045-1048 — id_generator
  • Queue::initTable() Queue.php:41-68 — task_queue
  • SearchIndexStore::createTable() SearchIndexStore.php:74-86 — search_index

幂等补列机制:Schema::ensureColumnPatches() Schema.php:641-765(哈希 marker 门控)——MySQL 下需要整套重设计。

1.3 SQL 调用规模(改造面统计)

范围SQL 调用点数涉及文件数
app/(核心)~59060
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:200INSERT OR IGNORE INTO user_groups
Schema.php:212INSERT OR IGNORE INTO level_config
Schema.php:236INSERT OR IGNORE INTO settings
Schema.php:248INSERT OR IGNORE INTO blog_categories
Schema.php:257INSERT OR IGNORE INTO categories
IDGenerator.php:37INSERT OR IGNORE INTO id_generator ... VALUES (...),(...)
IDGenerator.php:170INSERT OR IGNORE INTO id_generator
SearchIndexStore.php:247INSERT OR IGNORE INTO search_index ...
Tag.php:95INSERT OR IGNORE INTO thread_tags
cli/migrate.php:418INSERT OR IGNORE INTO id_generator

② 覆盖插入 INSERT OR REPLACE → REPLACE INTO

位置说明
cli/migrate.php:245通用表迁移
cli/migrate.php:303/328topic / topic_index
cli/migrate.php:370/382reply / reply_index
cli/migrate.php:458attachments / votes

⚠️ 语义陷阱:REPLACE INTO 在 MySQL 里是 DELETE + INSERT,会触发级联删除、重置自增、丢失未列出的列。推荐改用 INSERT ... ON DUPLICATE KEY UPDATE,语义更接近 INSERT OR REPLACE 但更安全。

③ 条件更新 UPDATE OR IGNORE → UPDATE IGNORE

位置说明
Schema.php:793UPDATE OR IGNORE thread_tags SET tag_id = ...

④ UPSERT ON CONFLICT(x) DO UPDATE → ON DUPLICATE KEY UPDATE

位置原写法MySQL 改法
Settings.php:265ON CONFLICT("key") DO UPDATE SET value = :value2ON 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:478ON CONFLICT("key") DO UPDATE SET value = :v2ON DUPLICATE KEY UPDATE value = :v2
Settings.php:568ON CONFLICT(user_id) DO UPDATE SET last_activity = :now2ON DUPLICATE KEY UPDATE last_activity = :now2
Vote.php:187ON 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:38ON CONFLICT("key") DO UPDATE SET value = excluded.valueON DUPLICATE KEY UPDATE value = VALUES(value)
SessionHandler.php:127ON CONFLICT(id) DO UPDATE SET data = :data2, expires = :expires2ON DUPLICATE KEY UPDATE data = :data2, expires = :expires2
Captcha.php:27ON CONFLICT("key") DO UPDATE SET value = :v2同 Settings
Permission.php:403ON CONFLICT(category_id, group_id) DO UPDATE SET can_view=:v2,...ON DUPLICATE KEY UPDATE can_view=:v2,...
Thread.php:127ON CONFLICT(id) DO UPDATE SET ...同
Tag.php:87ON CONFLICT(name) DO NOTHINGINSERT 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() 并接受弃用告警,或按版本分支。

⑤ 日期函数

位置SQLiteMySQL
Blog.php:47/49strftime('%Y-%m', created_at)DATE_FORMAT(created_at, '%Y-%m')
BlogController.php:60strftime('%Y-%m', b.created_at) = :archiveDATE_FORMAT(b.created_at, '%Y-%m') = :archive
Schema.php:622created_at TEXT DEFAULT (datetime('now','localtime'))created_at DATETIME DEFAULT CURRENT_TIMESTAMP

⑥ 类型转换 CAST(x AS TEXT) → CAST(x AS CHAR)

  • Settings.php:423

⑦ 元数据查询

用途SQLiteMySQL位置
取列名PRAGMA table_info(t)SHOW COLUMNS FROM t 或 information_schema.COLUMNSModel.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 TABLESSettings.php:1157
schema 版本PRAGMA user_version(读/写)无对应 → 自建 fh_schema_meta(k, v) 表Schema.php:936/994、SearchIndexStore.php:99/114/140

⑧ 连接/事务/只读

用途SQLiteMySQL位置
事务开启BEGIN IMMEDIATESTART TRANSACTION(exec 形式)IDGenerator.php:86、SearchIndexStore.php:224、ViewCounter.php:153
只读连接PRAGMA query_only = 1SET SESSION TRANSACTION READ ONLY 或只读账号Schema.php:1037、DatabaseController.php:480/546/633
锁等待PRAGMA busy_timeout = 5000SET SESSION innodb_lock_wait_timeout = 5DBFactory.php:39-48、IDGenerator.php:58/69
WALPRAGMA journal_mode = WAL + 校验无对应(InnoDB redo log 天然)DBFactory.php:39-48,99-108
跨库 JOINATTACH DATABASE ... AS mi原生跨库 JOINSearchIndexStore.php:362-382
锁错误判定'database is locked''Lock wait timeout exceeded' / 'Deadlock found'IDGenerator.php:205-211

⑨ 备份/维护

用途SQLiteMySQL位置
热备份VACUUM INTO无(见 §5.1 后台方案)DatabaseController.php:715
整理VACUUM + wal_checkpoint(TRUNCATE)OPTIMIZE TABLEDatabaseController.php:870-871
一致性桶 vs 索引行数对拍CHECK TABLE + 应用层对拍DatabaseController.php:619-698

⑩ DDL 语法差异

SQLiteMySQL影响
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.php27, 35, 49, 56, 124, 127, 164, 172, 180, 202"{$this->table}"、"{$key}"、"{$this->primaryKey}"
app/Helpers/Settings.php265, 423, 478, 1164, 1176, 1184"key"、"value"、"$t"
app/Helpers/Captcha.php27"key"
app/Models/Setting.php38"key"
app/SplitDB/Schema.php236"key"
cli/migrate.php243, 456"{$col}"

改造建议:在 Grammar 里提供 quoteIdent($name),Model / Settings 等统一走它,禁止手写引号。

附带风险:settings 表的列名 key 是 MySQL 保留字,任何地方漏引号都会语法错误 → 建议顺手把列名改为 skey(但会牵连迁移与所有引用,收益不高,建议只做引号统一)。

3.4 其他 MySQL 严格模式陷阱(需全量扫描)

陷阱说明排查方式
ONLY_FULL_GROUP_BYMySQL 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 列不能建前缀外的完整索引需指定前缀长度或改 VARCHARsearch_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_queue3 队列是为"打散 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.php 65 处 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 RANGE on create_time)——更优雅但引入运维复杂度

4.4 计数器与并发

机制现状MySQL 下
浏览量 ViewCounterAPCu 计数 + 30s 节流落库不变(UPDATE ... SET view_count = view_count + ? 是原子的)
运行时计数 Settings::runtimeIncr内存累加 + flushCounters() 批量落库不变
帖子回复数 updateCounters()桶 + 索引双写简化为单表 UPDATE(不再有"双写")
全局 IDBEGIN 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-352auto_expand_enabled + 阈值❌ 移除
单桶容量 handleSaveCapacity() :357-369bucket_safe_capacity 白名单❌ 移除
归档天数 handleSaveArchive() :375-389archive_after_days✅ 保留(逻辑改为 UPDATE ... SET is_archived=1)
一致性检查 consistencyCheck() :619-698桶文件行数 vs 索引行数对拍🔄 改造为「表完整性检查」:CHECK TABLE + 应用层对拍(如 topic_index 行数 vs topic 行数)
config.php 写权限检测 :246-247is_writable($configFile)✅ 保留(改为写 MySQL 连接配置)
扩容记录(audit_logs 查询):257-268target_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/1164cleanupOrphanAttachments 用 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.phpSQLite 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 的最大优势:

  1. SQLite 数据原样保留(迁移器只读源库)
  2. 回滚 = 后台把 DB_DRIVER 改回 sqlite(或手工改 config.php)
  3. 迁移期间的写入(若没置维护模式)需要二次同步 → 强烈建议迁移时置维护模式

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_storageSyncQueue.php 有 7 处 SQLite 语法(INSERT OR IGNORE 等)plugins/bitiful_storage/SyncQueue.php
market_hubMarketDb.php 14 处、ShotRepo.php 12 处plugins/market_hub/
mcp_serverTokenManager.php 21 处plugins/mcp_server/
mobile_apiTokenManager.php 15 处plugins/mobile_api/
daily_checkinCheckinController.php 9 处plugins/daily_checkin/
ai_assistantPlugin.php 70 处(最多)plugins/ai_assistant/

⚠️ 注意:以上计数是全部 SQL 调用,不等于"需要改的"。真正要改的仍以 §3.2 的方言清单为准。

7.4 插件开发规范要同步更新

docs/插件开发规范.md 需要新增一节「双引擎兼容」:插件写 SQL 时禁用 INSERT OR IGNORE / ON CONFLICT / strftime 等 SQLite 专有语法,改用 Grammar 提供的兼容方法。


8. 分阶段实施计划

阶段 P0:抽象层(前置,无功能变化)

任务产出验收
抽 DriverInterface + Grammarapp/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() 收口;必须写专项断言
2ONLY_FULL_GROUP_BY 报错(MySQL 5.7+ 默认)🟠 高全量 grep GROUP BY 逐条核对
3TEXT DEFAULT '' 建表失败🟠 高统一改 VARCHAR(n) DEFAULT '' 或 TEXT NULL
4备份方案选错(依赖 mysqldump 导致生产不可用)🟠 高纯 PHP 逻辑导出(§5.2.1)
5迁移中丢正文(extern 读取失败)🔴 极高沿用 migrate.php 的红线设计(:397-409):失败即中止 + 清理
6REPLACE INTO 语义陷阱(DELETE+INSERT)🟠 高改用 ON DUPLICATE KEY UPDATE
7config.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 天
P1MySQL 驱动 + 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/1462Schema.php(22)、DBFactory.php(9)、IDGenerator.php(9)
P2 SQL 方言点1430Helpers/Settings.php(11)、Helpers/Tag.php(5)
P3 后台模块812Admin/DatabaseController.php(12)
P4 安装 / 配置 / 入口57install.php(4)
P5 CLI 脚本1562migrate.php(9)、archive.php(9)、shrink_business.php(8)
P6 插件(策略 A 下可免改)1330ai_assistant/Plugin.php(6)、market_hub/MarketDb.php(6)
合计73203

11.4 可整份下线的「废弃 4 件套」

若采纳「正文入 LONGTEXT」,以下 4 个文件(合计 1736 行)可整体删除:

文件行数
app/SplitDB/ExternStorage.php471
app/SplitDB/ExternMerger.php524
app/SplitDB/ExternIdxRepairer.php289
app/SplitDB/ExternNamespaceMigrator.php452

同理可下线 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/WW2


12. 待你拍板的 5 个决策点

  1. 路线:是否采纳路线 B(双引擎)?还是直接单引擎替换?
  2. 正文存储:extern 正文是入 MySQL LONGTEXT(推荐,可下线 1400 行 extern 代码)还是保留文件外置?
  3. 全局 ID:保留 id_generator 表(改动小)还是改 AUTO_INCREMENT(更原生)?
  4. 插件库:是否采纳策略 A(插件库保持 SQLite)?
  5. 迁移范围:是否需要存量数据迁移(决定是否要做 P4 阶段,影响 2~3 天工期)?

本文档为方案设计,未改动任何代码。 确认路线后,建议从 P0 抽象层起步——那一阶段不引入 MySQL、不改变现有行为,是风险最低的切入点。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 4 次 | 回覆 0 次

全部回覆 (0)

暫無回覆

請 登入