本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/FlintHub查询优化分析报告.md。
FlintHub 数据查询优化分析报告
- 分析日期:2026-08-27
- 分析范围:前台全部页面 + 主要 API + 后台列表页(
app/Controllers/、app/Models/、app/Helpers/*) - 结论性质:仅分析报告,未改动任何代码(等指令再动手改)
一、结论摘要(TL;DR)
- 查询总量不算失控,但存在 2 处严重的 N+1 和 1 处全表拉取,是主要的优化目标:
Tag::getThreads():标签详情页/tag/{id}对每个帖子逐条Thread::find(),每帖 3 次 SQL(索引 + 桶 + 用户),20 帖/页 ≈ 60+ 次查询;PostController::create():发帖页/post/new对每个版块逐条Permission::canPost(),每版块 2~3 次 SQL(group_id + 权限),10 个版块 ≈ 20~30 次查询;MessageController::inbox():收件箱页第一次getInbox()无 LIMIT 全量拉取全部私信,只为在 PHP 里数未读数,随后又查一次分页列表。
- 页脚"查询次数"严重低估真实 SQL 数:
Database::getQueryCount()只统计 business.sqlite 经Database::query()的查询;main_index(Schema::mainIndexDb())与分片桶(Schema::bucketFromPath())的查询不计数。详情页实际 SQL ≈ 页脚显示 +5 次,标签页实际 ≈ 显示 +40 次。 - 全局可削减项:4 个站点计数器(
Settings::getTotalUsers/Threads/Posts/Blogs)每页各查 1 次(共 4 次),可合并为一条IN查询;getOnlineCount无缓存而getOnlineData有 30s 文件缓存,口径不一致;Permission::check对同一用户重复查group_id。 - 估算收益:全站 15 个高频页面合计每页平均可减少 约 30~45% 的 business 查询;最严重的标签页可从 60+ 降到 10 次以内。
| 页面 | 当前查询数(business,约) | 优化后(约) | 降幅 |
|---|---|---|---|
/ 首页 | 12~15 | 8~10 | -30% |
/forum 论坛 | 11~14 | 8~10 | -30% |
/forum/category/{id} 版块 | 12~16 | 9~11 | -30% |
/thread/{id} 帖子详情 | 14~20 | 9~12 | -40% |
/post/new 发帖页 | 21~31 | 3~5 | -85% |
/tag/{id} 标签详情 | 60+ | ~10 | -85% |
/message/inbox 收件箱 | 3~4 | 1~2 | -50% |
/blog 博客列表 | ~8 | ~5 | -35% |
/blog/{id} 博客详情 | ~7 | ~5 | -30% |
/profile 个人中心 | 6~8 | 4~5 | -30% |
二、方法论与口径说明(重要)
2.1 查询计数口径
全站 SQL 分三层,页脚显示的数字只包含第一层:
| 层 | 入口 | 是否计入页脚 getQueryCount |
|---|---|---|
| business.sqlite | Database::query/fetchAll/fetchOne(Model 继承) | ✅ 计入 |
| main_index(topic_index/reply_index) | Schema::mainIndexDb()->query/prepare | ❌ 不计入 |
| 分片桶(topic/reply 数据表) | Schema::bucketFromPath() | ❌ 不计入 |
| sessions.sqlite | Database::getSessionConnection() | ❌ 不计入 |
示例:帖子详情页页脚可能显示"查询 15 次",但实际 SQL 为 15 + main_index ~3 次 + 桶 ~2 次 ≈ 20 次。下文"当前查询数"列以 business 层计数为准,并单独标注索引/桶层的额外开销。
2.2 每页固定开销(所有页面共享)
以下查询在任何页面都会发生(除非特殊标注),是"基础分":
| 来源 | 游客 | 登录用户 |
|---|---|---|
Settings::updateOnlineStatus()(init.php) | 0 | 1~3(SELECT + 可能 UPSERT + 清理 DELETE) |
Auth::getCurrentUser()(view 布局) | 0 | 1(静态缓存,仅 1 次) |
Settings::getUnreadMessageCount()(view 布局) | 0 | 1 |
Settings::get() 首次加载(缓存文件缺失时) | 0~1 | 0~1 |
Theme::getCurrent() / Csrf::token() / I18n::current() | 0 | 0(文件/会话级) |
| 小计 | 0~1 | 3~6 |
说明:
Settings::get()走protected/cache/settings.cache.php文件缓存,缓存文件存在时 0 次 SQL,故未计入多数页面。
三、逐页查询明细与量化
3.1 首页 /(HomeController::index,portal 模式)
| # | 查询 | 次数 |
|---|---|---|
| 1 | Blog::getLatest()(blogs JOIN users JOIN blog_categories) | 1 |
| 2 | Category::allOrdered() | 1 |
| 3 | BlogCategory::allOrdered() | 1 |
| 4 | Permission::getAuthorizedCategoryIds() | 游客 1~3 / 登录 2~4 |
| 5 | 每版块 Category::getLatestThreads($cid, 3) | 快照命中 0;快照缺失时每版块 2(main_index + users 批量,main_index 不计) |
| 6 | 批量补用户(users IN) | 1 |
| 7 | 批量补计数(topic_index IN,不计) | 1 |
| 8 | Settings::getTotalUsers/Threads/Posts/Blogs | 4(每计数器 1 次 SELECT) |
| 9 | COUNT(blog_comments) 直查 | 1 |
| 10 | Settings::getOnlineData() | 0~1(30s 文件缓存) |
| 11 | Tag::getPopular(20) | 1 |
| 12 | Vote::getCounts('thread', ids) | 游客 1 / 登录 2 |
| 合计 | 游客 12~15 / 登录 17~22 |
问题:第 8 项 4 个计数器各自独立 SELECT value FROM settings WHERE key = '_runtime_*'(见 Settings::runtimeGet() 中"数据库覆盖缓存"逻辑——即使文件缓存已有值也强制查库取最新),可合并为一条 WHERE key IN (4 键)。
3.2 论坛页 /forum(ForumController::index)
| # | 查询 | 次数 |
|---|---|---|
| 1 | Category::allOrdered() | 1 |
| 2 | Permission::getAuthorizedCategoryIds() | 2~4 |
| 3 | Category::getCategoryStats()(主题/回复计数映射) | 0~1(永久文件缓存,发帖失效;缺失时 main_index GROUP BY,不计) |
| 4 | Thread::getIndexPaginated() 列表 | main_index 1(不计)+ users IN 1 + categories IN 1 |
| 5 | 每版块 getLatestThreads($cid, 1) | 快照命中 0 / 缺失 2×N(main_index 不计) |
| 6 | Settings::getTotalUsers/Threads/Posts | 3 |
| 7 | Settings::getOnlineCount() | 1(无缓存,每次 COUNT) |
| 8 | Tag::getPopular(20) | 1 |
| 9 | Vote::getCounts('thread', ids) | 1~2 |
| 合计 | 游客 11~14 / 登录 16~20 |
问题:第 7 项 getOnlineCount() 每次请求都 COUNT(online_users),而首页用的是带 30s 文件缓存的 getOnlineData()——同一类数据两种实现,口径不一致,论坛/版块/博客页都在付全量 COUNT 成本。
3.3 版块页 /forum/category/{id}(ForumController::category)
| # | 查询 | 次数 |
|---|---|---|
| 1 | Category::find($id) | 1 |
| 2 | Permission::canView() | 登录 2~3 / 游客 1~3 |
| 3 | Thread::countActive([id])(读统计缓存) | 0 |
| 4 | Thread::getByCategoryPaginated() | main_index 1(不计)+ users IN 1 + categories IN 1 |
| 5 | Category::allOrdered()(侧栏版块列表) | 1 |
| 6 | Permission::getAuthorizedCategoryIds()(过滤侧栏) | 2~4(与 #2 重复查 group_id/权限表) |
| 7 | Vote::getCounts('thread', ids) | 1~2 |
| 8 | Settings::getTotalUsers/Threads/Posts | 3 |
| 9 | Settings::getOnlineCount() | 1 |
| 合计 | 游客 12~16 / 登录 17~21 |
问题:#2 canView 与 #6 getAuthorizedCategoryIds 各自独立查询用户 group_id 与 category_permissions,同一页面重复查同一份权限数据(Permission::check() 的请求内缓存以 perm_category_user 为键,跨权限类型不共享 group_id 查询)。
3.4 帖子详情页 /thread/{id}(ThreadController::show)
| # | 查询 | 次数 |
|---|---|---|
| 1 | Thread::getById() | main_index 1(不计)+ 桶 1(不计)+ users 1 |
| 2 | Permission::canView() | 2~3 |
| 3 | Thread::incrementView() | APCu 0 / 无 APCu 时 main_index UPDATE(不计) |
| 4 | 归档惰性标记(旧帖首次) | main_index UPDATE 1(不计,一次性) |
| 5 | Permission::canReply() | 2~3(重复查 group_id) |
| 6 | Post::countByThread() | main_index 1(不计)+ 桶 COUNT 1(不计) |
| 7 | Post::getByThread() 分页列表 | main_index 1(不计)+ 桶 1(不计)+ users IN 1 |
| 8 | Vote::getCounts('post', ids) | 1~2 |
| 9 | Vote::getCounts('thread', [id]) | 1~2 |
| 10 | Settings::getThreadAttachments() | 1 |
| 11 | Settings::getPostAttachments() | 1(可与 #10 合并为一次查询) |
| 12 | Permission::canAttach()(PRAGMA + check) | 1(PRAGMA 缓存)+ 2~3 |
| 13 | Tag::getByThread() | 1 |
| 14 | Category::allOrdered() | 1 |
| 15 | 固定开销 | 游客 0~1 / 登录 3~6 |
| 合计 | 游客 14~20 / 登录 19~26(另含索引/桶 ~5 次不计) |
问题:
- #10 + #11 两次附件查询仅条件不同(
post_id IS NULLvspost_id > 0),可合并为一次SELECT * FROM attachments WHERE thread_id = ?后在 PHP 侧分组; - #6 + #7 分别
fetchThreadIndex,同一主题的 main_index 行被查 2 次,可复用一次结果; - #2/#5/#12 三次权限检查,每次内部重复查询
group_id(Permission::check()未对 group_id 做请求级缓存)。
3.5 发帖页 /post/new(PostController::create,GET)—— 重灾区之一
| # | 查询 | 次数 |
|---|---|---|
| 1 | Category::allOrdered() | 1 |
| 2 | 循环每版块 Permission::canPost() | 每版块 2~3 次(登录:group_id 1 + 权限 1 [+ hasAnyPerm 1]) |
| 合计 | 1 + 2N~3N(N = 版块数) |
- 10 个版块 → 21~31 次查询;20 个版块 → 41~61 次。
- 根因:
Permission::check()按(perm, category_id, user)缓存,发帖页对每个版块调用一次,每次都重新执行SELECT group_id FROM users和SELECT {perm} FROM category_permissions。 - 优化方向:请求内一次性查询
SELECT category_id, can_post, can_reply, can_view, can_attach FROM category_permissions WHERE group_id = ?(或复用Auth::getCurrentUser()已取到的 group_id),内存映射后做判断——发帖页总查询可降到 3~5 次。
3.6 主题编辑 /thread/{id}/edit、回帖编辑 /post/{id}/edit
/thread/{id}/edit:Thread::find(3 层)+canView2~3 +allOrdered1 +Tag::getByThread1 +getThreadAttachments1 ≈ 8~10 次(+固定)。/post/{id}/edit:Post::find(3 层)+Thread::find(3 层,两次 find 各自读 main_index+桶,重复开销)+ 固定 ≈ 6~8 次。- 均为低频页面,优先级低;
Post::edit可考虑由post行反查thread时复用索引行。
3.7 个人中心 /profile(ProfileController::index)
| # | 查询 | 次数 |
|---|---|---|
| 1 | User::find($currentUser['id']) | 1(与 Auth::getCurrentUser 重复查同一用户) |
| 2 | Permission::getAuthorizedCategoryIds() | 2~4 |
| 3 | User::getThreads() | main_index 1(不计)+ categories IN 1 |
| 4 | User::getBlogs() | 1 |
| 5 | User::getStats() | main_index 2(不计)+ blogs COUNT 1 |
| 6 | 固定开销 | 3~6 |
| 合计 | business 6~8 + 固定 3~6 |
问题:#1 与 Auth::getCurrentUser() 查询同一行用户数据,可去掉或改用缓存。
3.8 私信三页(MessageController)
收件箱 /message/inbox(重灾区之一):
| # | 查询 | 次数 |
|---|---|---|
| 1 | getInbox() 无 LIMIT 全量拉取全部私信(仅用于数未读) | 1(全表!) |
| 2 | countInbox() | 1 |
| 3 | getInbox($perPage, $offset) 分页列表 | 1 |
| 4 | 固定开销(view 布局已含未读数查询) | 3~6 |
| 合计 | 3 + 固定 |
- 第 1 次查询拉取用户全部私信只为统计未读数,可改为
COUNT(*) WHERE is_read = 0,或将未读数并入countInbox()一次性返回;分页列表结果也可在控制器内直接数未读(当前第 1 次查询结果被丢弃)。 - 发件箱
/message/sent:countSent+getSent≈ 2 次(+固定),无问题。 - 详情
/message/view/{id}:find+markAsRead≈ 2 次(+固定),无问题。
3.9 博客列表 /blog 与详情 /blog/{id}
列表:COUNT blogs 1 + 列表 JOIN 1 + BlogCategory::allOrdered 1 + getTotalBlogs 1 + getTotalUsers 1 + COUNT(blog_comments) 1 + getOnlineCount 1 + archives GROUP BY 1 ≈ 8 次。
详情:getWithUser 1 + view_count UPDATE 1 + countByBlog 1 + getByBlogPaginated 1 + allOrdered 1 + getTotalBlogs 1 + archives GROUP BY 1 ≈ 7 次。
问题:
getOnlineCount无缓存(同 §3.2);- archives 年月分组查询在列表页和详情页重复执行,可做 5 分钟文件缓存;
- 计数器
getTotalBlogs独立查库(同 §3.1 问题)。
3.10 搜索 /search
User::search 1 + Search::search 1~2(索引查询)+ getAuthorizedCategoryIds 2~4 + RateLimiter(文件,0 SQL)≈ 4~7 次。
- 搜索本身是重 IO 路径(索引库 + 权限过滤补位),已做"数据+计数一次调用"的优化,空间有限;建议关注索引查询本身的成本(本次未深入 Search helper 内部)。
3.11 标签列表 /tags 与标签详情 /tag/{id}(重灾区之一)
列表 /tags:Tag::getAllWithCount()(tags LEFT JOIN thread_tags GROUP BY)1 次 + 固定,无问题。
详情 /tag/{id}(严重 N+1):
| # | 查询 | 次数 |
|---|---|---|
| 1 | Tag::find($id) | 1 |
| 2 | Tag::getThreads():查 tag → COUNT → 分页 thread_ids | 3 |
| 3 | 循环每帖 Thread::find($tid) | 每帖 3 次(main_index 1 + 桶 1 + users 1) |
| 4 | Tag::getPopular(20) | 1 |
| 合计 | 5 + 3N(N = 每页帖数,默认 20 → 65 次 SQL,其中 business 约 23 次) |
- 根因:
app/Helpers/Tag.php::getThreads()拿到 thread_id 列表后foreach ($threadIds as $tid) { $thread->find($tid); },没有像decorateList()那样批量取索引行 + 批量补用户。 - 优化方向:一次性
topic_index IN (...)取索引行 +users IN (...)批量补用户(Thread::decorateList已有现成实现,可复用),单页降到 ~10 次。
3.12 API 接口
/api/vote:目标存在性校验(main_index,不计)+ INSERT/UPSERT 1 +getCounts1~2 ≈ 2~3 次 business,正常。/api/post/{id}/body:getById3 +canView2~3 +Points::canViewContent0~1 ≈ 6~7 次,中低频,可接受。/api/favorites-count:Session 缓存 + 1 次计数查询,正常。
3.13 后台页面(摘要)
- Dashboard:4 个计数器(4 次)+
getOnlineData0~1 + latestUsers 1 + latestThreads 1 + sqlite_version 1 ≈ 8~10 次,低频,计数器合并后约 6 次。 - 各列表页(users/threads/categories/tags/trashed/levels/groups/blog):分页查询 + 计数查询 + 每行统计(多数有 60s/永久文件缓存),总体健康;未发现 N+1 循环(
AdminThreadController::batch_delete循环调Search::removeFromIndex是删除路径,非页面渲染路径)。
四、核心问题清单(按严重度排序)
| # | 问题 | 位置 | 影响页面 | 量级 |
|---|---|---|---|---|
| P1 | 标签详情循环逐帖 Thread::find()(N+1) | app/Helpers/Tag.php::getThreads() | /tag/{id} | 20 帖 = 60+ SQL |
| P2 | 发帖页循环逐版块 Permission::canPost() | app/Controllers/PostController.php::create() + Permission::check() | /post/new | 10 版块 = 20~30 SQL |
| P3 | 收件箱无 LIMIT 全量拉取私信 + 重复分页查询 | app/Controllers/MessageController.php::inbox() | /message/inbox | 全表 + 1 次重复 |
| P4 | 站点计数器每页 4 次独立单键查询 | app/Helpers/Settings.php::runtimeGet() | 首页/论坛/版块/博客等 | 每页 3~4 次可并 1 |
| P5 | 权限检查对同一用户重复查 group_id | app/Helpers/Permission.php::check() | 详情页/版块页/发帖页 | 每页 2~3 次冗余 |
| P6 | getOnlineCount 无缓存,与 getOnlineData 口径不一致 | app/Helpers/Settings.php | /forum、版块页、/blog | 每页 1 次全表 COUNT |
| P7 | 附件主楼/回帖两次独立查询 | app/Helpers/Settings.php + ThreadController::show() | /thread/{id} | 2 → 1 |
| P8 | 详情页 countByThread 与 getByThread 重复 fetchThreadIndex | app/Models/Post.php | /thread/{id} | main_index 2 → 1 |
| P9 | 个人中心重复查当前用户 | ProfileController::index() | /profile | 1 次冗余 |
| P10 | 页脚查询计数不含 main_index/分片桶(口径失真) | app/Core/Database.php::getQueryCount() | 全部 | 显示数低估 20~60% |
| P11 | 博客 archives 分组查询列表/详情重复执行 | BlogController::index/show() | /blog、/blog/{id} | 每页 1 次可缓存 |
| P12 | 首页/论坛快照冷启动时每版块 2 次查询 | Category::getLatestThreads() | /、/forum | 一次性/失效后 |
五、优化建议(分优先级,等指令后实施)
第一优先:三个 N+1/全表问题(收益最大、改动局部)
- P1 标签详情批量化:在
Tag::getThreads()中,把"循环Thread::find()"改为"一次topic_index WHERE id IN (...)取索引行 + 批量users IN (...)补用户名/头像"(复用Thread::decorateList()或新增批量方法)。
- 预计:65 SQL → ~10 SQL;改动仅 1 个方法。
- P2 发帖页权限一次查询:请求内先取当前用户
group_id(复用Auth::getCurrentUser()),再一次性查询该组全部category_permissions建立映射,循环内只做内存判断。
- 预计:21~31 SQL → 3~5 SQL;改动
Permission::check()增加 group 级缓存 +PostController::create()复用。
- P3 收件箱未读数改 COUNT:删除第一次无 LIMIT 的
getInbox()全量调用,改为COUNT(*) WHERE receiver_id = ? AND is_read = 0(或并入countInbox());分页列表在 PHP 内统计未读。
- 预计:全表拉取 → 1 次轻量 COUNT。
第二优先:全局性削减(影响所有页面)
- P4 计数器合并:
Settings::runtimeGet()增加"批量取"方法或改为一次WHERE key IN ('_runtime_total_users','_runtime_total_threads','_runtime_total_posts','_runtime_total_blogs')填充内存缓存;页面已有 4 次独立 SELECT 的地方直接受益。 - P6 在线数统一走
getOnlineData():论坛/版块/博客页把getOnlineCount()换成带 30s 文件缓存的getOnlineData()['count'](首页已如此)。 - P5 权限 group_id 请求级缓存:
Permission::check()与getAuthorizedCategoryIds()内部对group_id做静态缓存(或从Auth::getCurrentUser()取),消除同页多次重复。 - P10 统计口径修正:如希望页脚真实反映 SQL 总量,可在
main_index/桶连接处增加计数(或另设"总 SQL"统计项),与 business 计数分开显示——便于后续持续监控优化效果。
第三优先:单页细节
- P7 附件查询合并:
ThreadController::show()一次SELECT * FROM attachments WHERE thread_id = ?,PHP 按post_id是否为空分组为主楼/回帖附件。 - P8 详情页复用索引行:
Post::countByThread()与getByThread()合并为一次fetchThreadIndex()+ 一次桶查询返回{total, rows},或让getByThread()顺带返回 COUNT。 - P9 个人中心去重:直接使用
Auth::getCurrentUser()的返回数组,去掉User::find。 - P11 archives 缓存:博客归档分组结果加短 TTL 文件缓存(类似
getOnlineData)。
六、风险与注意事项
- 所有优化均为查询层重构,不改表结构、不改对外接口(URL/字段),按上述清单逐项实施即可灰度回归。
- P1/P2 涉及权限与数据展示正确性,改动后需重点回归:标签页帖子排序(置顶优先 + 最后回复时间)、发帖页各版块"可发帖"判定。
- P3 涉及私信未读红点,回归时确认收件箱未读数与布局顶部未读数一致。
- P4/P6 为缓存增强,需确认发帖/回帖后计数器与在线数不会显示陈旧值(现有
flushCounters/invalidateCategoryStats机制已具备,仅需对齐)。
报告完毕。以上为分析与建议,未修改任何代码,等待您的指令后再实施。