[开发]FlintHub 数据查询优化分析报告

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

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/FlintHub查询优化分析报告.md。

FlintHub 数据查询优化分析报告

  • 分析日期:2026-08-27
  • 分析范围:前台全部页面 + 主要 API + 后台列表页(app/Controllers/、app/Models/、app/Helpers/*)
  • 结论性质:仅分析报告,未改动任何代码(等指令再动手改)

一、结论摘要(TL;DR)

  1. 查询总量不算失控,但存在 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 里数未读数,随后又查一次分页列表。
  1. 页脚"查询次数"严重低估真实 SQL 数:Database::getQueryCount() 只统计 business.sqlite 经 Database::query() 的查询;main_index(Schema::mainIndexDb())与分片桶(Schema::bucketFromPath())的查询不计数。详情页实际 SQL ≈ 页脚显示 +5 次,标签页实际 ≈ 显示 +40 次。
  2. 全局可削减项:4 个站点计数器(Settings::getTotalUsers/Threads/Posts/Blogs)每页各查 1 次(共 4 次),可合并为一条 IN 查询;getOnlineCount 无缓存而 getOnlineData 有 30s 文件缓存,口径不一致;Permission::check 对同一用户重复查 group_id。
  3. 估算收益:全站 15 个高频页面合计每页平均可减少 约 30~45% 的 business 查询;最严重的标签页可从 60+ 降到 10 次以内。
页面当前查询数(business,约)优化后(约)降幅
/ 首页12~158~10-30%
/forum 论坛11~148~10-30%
/forum/category/{id} 版块12~169~11-30%
/thread/{id} 帖子详情14~209~12-40%
/post/new 发帖页21~313~5-85%
/tag/{id} 标签详情60+~10-85%
/message/inbox 收件箱3~41~2-50%
/blog 博客列表~8~5-35%
/blog/{id} 博客详情~7~5-30%
/profile 个人中心6~84~5-30%


二、方法论与口径说明(重要)

2.1 查询计数口径

全站 SQL 分三层,页脚显示的数字只包含第一层:

层入口是否计入页脚 getQueryCount
business.sqliteDatabase::query/fetchAll/fetchOne(Model 继承)✅ 计入
main_index(topic_index/reply_index)Schema::mainIndexDb()->query/prepare❌ 不计入
分片桶(topic/reply 数据表)Schema::bucketFromPath()❌ 不计入
sessions.sqliteDatabase::getSessionConnection()❌ 不计入

示例:帖子详情页页脚可能显示"查询 15 次",但实际 SQL 为 15 + main_index ~3 次 + 桶 ~2 次 ≈ 20 次。下文"当前查询数"列以 business 层计数为准,并单独标注索引/桶层的额外开销。

2.2 每页固定开销(所有页面共享)

以下查询在任何页面都会发生(除非特殊标注),是"基础分":

来源游客登录用户
Settings::updateOnlineStatus()(init.php)01~3(SELECT + 可能 UPSERT + 清理 DELETE)
Auth::getCurrentUser()(view 布局)01(静态缓存,仅 1 次)
Settings::getUnreadMessageCount()(view 布局)01
Settings::get() 首次加载(缓存文件缺失时)0~10~1
Theme::getCurrent() / Csrf::token() / I18n::current()00(文件/会话级)
小计0~13~6

说明:Settings::get() 走 protected/cache/settings.cache.php 文件缓存,缓存文件存在时 0 次 SQL,故未计入多数页面。


三、逐页查询明细与量化

3.1 首页 /(HomeController::index,portal 模式)

#查询次数
1Blog::getLatest()(blogs JOIN users JOIN blog_categories)1
2Category::allOrdered()1
3BlogCategory::allOrdered()1
4Permission::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
8Settings::getTotalUsers/Threads/Posts/Blogs4(每计数器 1 次 SELECT)
9COUNT(blog_comments) 直查1
10Settings::getOnlineData()0~1(30s 文件缓存)
11Tag::getPopular(20)1
12Vote::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)

#查询次数
1Category::allOrdered()1
2Permission::getAuthorizedCategoryIds()2~4
3Category::getCategoryStats()(主题/回复计数映射)0~1(永久文件缓存,发帖失效;缺失时 main_index GROUP BY,不计)
4Thread::getIndexPaginated() 列表main_index 1(不计)+ users IN 1 + categories IN 1
5每版块 getLatestThreads($cid, 1)快照命中 0 / 缺失 2×N(main_index 不计)
6Settings::getTotalUsers/Threads/Posts3
7Settings::getOnlineCount()1(无缓存,每次 COUNT)
8Tag::getPopular(20)1
9Vote::getCounts('thread', ids)1~2
合计游客 11~14 / 登录 16~20

问题:第 7 项 getOnlineCount() 每次请求都 COUNT(online_users),而首页用的是带 30s 文件缓存的 getOnlineData()——同一类数据两种实现,口径不一致,论坛/版块/博客页都在付全量 COUNT 成本。

3.3 版块页 /forum/category/{id}(ForumController::category)

#查询次数
1Category::find($id)1
2Permission::canView()登录 2~3 / 游客 1~3
3Thread::countActive([id])(读统计缓存)0
4Thread::getByCategoryPaginated()main_index 1(不计)+ users IN 1 + categories IN 1
5Category::allOrdered()(侧栏版块列表)1
6Permission::getAuthorizedCategoryIds()(过滤侧栏)2~4(与 #2 重复查 group_id/权限表)
7Vote::getCounts('thread', ids)1~2
8Settings::getTotalUsers/Threads/Posts3
9Settings::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)

#查询次数
1Thread::getById()main_index 1(不计)+ 桶 1(不计)+ users 1
2Permission::canView()2~3
3Thread::incrementView()APCu 0 / 无 APCu 时 main_index UPDATE(不计)
4归档惰性标记(旧帖首次)main_index UPDATE 1(不计,一次性)
5Permission::canReply()2~3(重复查 group_id)
6Post::countByThread()main_index 1(不计)+ 桶 COUNT 1(不计)
7Post::getByThread() 分页列表main_index 1(不计)+ 桶 1(不计)+ users IN 1
8Vote::getCounts('post', ids)1~2
9Vote::getCounts('thread', [id])1~2
10Settings::getThreadAttachments()1
11Settings::getPostAttachments()1(可与 #10 合并为一次查询)
12Permission::canAttach()(PRAGMA + check)1(PRAGMA 缓存)+ 2~3
13Tag::getByThread()1
14Category::allOrdered()1
15固定开销游客 0~1 / 登录 3~6
合计游客 14~20 / 登录 19~26(另含索引/桶 ~5 次不计)

问题:

  • #10 + #11 两次附件查询仅条件不同(post_id IS NULL vs post_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)—— 重灾区之一

#查询次数
1Category::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 层)+ canView 2~3 + allOrdered 1 + Tag::getByThread 1 + getThreadAttachments 1 ≈ 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)

#查询次数
1User::find($currentUser['id'])1(与 Auth::getCurrentUser 重复查同一用户)
2Permission::getAuthorizedCategoryIds()2~4
3User::getThreads()main_index 1(不计)+ categories IN 1
4User::getBlogs()1
5User::getStats()main_index 2(不计)+ blogs COUNT 1
6固定开销3~6
合计business 6~8 + 固定 3~6

问题:#1 与 Auth::getCurrentUser() 查询同一行用户数据,可去掉或改用缓存。

3.8 私信三页(MessageController)

收件箱 /message/inbox(重灾区之一):

#查询次数
1getInbox() 无 LIMIT 全量拉取全部私信(仅用于数未读)1(全表!)
2countInbox()1
3getInbox($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):

#查询次数
1Tag::find($id)1
2Tag::getThreads():查 tag → COUNT → 分页 thread_ids3
3循环每帖 Thread::find($tid)每帖 3 次(main_index 1 + 桶 1 + users 1)
4Tag::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 + getCounts 1~2 ≈ 2~3 次 business,正常。
  • /api/post/{id}/body:getById 3 + canView 2~3 + Points::canViewContent 0~1 ≈ 6~7 次,中低频,可接受。
  • /api/favorites-count:Session 缓存 + 1 次计数查询,正常。

3.13 后台页面(摘要)

  • Dashboard:4 个计数器(4 次)+ getOnlineData 0~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/new10 版块 = 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_idapp/Helpers/Permission.php::check()详情页/版块页/发帖页每页 2~3 次冗余
P6getOnlineCount 无缓存,与 getOnlineData 口径不一致app/Helpers/Settings.php/forum、版块页、/blog每页 1 次全表 COUNT
P7附件主楼/回帖两次独立查询app/Helpers/Settings.php + ThreadController::show()/thread/{id}2 → 1
P8详情页 countByThread 与 getByThread 重复 fetchThreadIndexapp/Models/Post.php/thread/{id}main_index 2 → 1
P9个人中心重复查当前用户ProfileController::index()/profile1 次冗余
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/全表问题(收益最大、改动局部)

  1. P1 标签详情批量化:在 Tag::getThreads() 中,把"循环 Thread::find()"改为"一次 topic_index WHERE id IN (...) 取索引行 + 批量 users IN (...) 补用户名/头像"(复用 Thread::decorateList() 或新增批量方法)。
  • 预计:65 SQL → ~10 SQL;改动仅 1 个方法。
  1. P2 发帖页权限一次查询:请求内先取当前用户 group_id(复用 Auth::getCurrentUser()),再一次性查询该组全部 category_permissions 建立映射,循环内只做内存判断。
  • 预计:21~31 SQL → 3~5 SQL;改动 Permission::check() 增加 group 级缓存 + PostController::create() 复用。
  1. P3 收件箱未读数改 COUNT:删除第一次无 LIMIT 的 getInbox() 全量调用,改为 COUNT(*) WHERE receiver_id = ? AND is_read = 0(或并入 countInbox());分页列表在 PHP 内统计未读。
  • 预计:全表拉取 → 1 次轻量 COUNT。

第二优先:全局性削减(影响所有页面)

  1. P4 计数器合并:Settings::runtimeGet() 增加"批量取"方法或改为一次 WHERE key IN ('_runtime_total_users','_runtime_total_threads','_runtime_total_posts','_runtime_total_blogs') 填充内存缓存;页面已有 4 次独立 SELECT 的地方直接受益。
  2. P6 在线数统一走 getOnlineData():论坛/版块/博客页把 getOnlineCount() 换成带 30s 文件缓存的 getOnlineData()['count'](首页已如此)。
  3. P5 权限 group_id 请求级缓存:Permission::check() 与 getAuthorizedCategoryIds() 内部对 group_id 做静态缓存(或从 Auth::getCurrentUser() 取),消除同页多次重复。
  4. P10 统计口径修正:如希望页脚真实反映 SQL 总量,可在 main_index/桶连接处增加计数(或另设"总 SQL"统计项),与 business 计数分开显示——便于后续持续监控优化效果。

第三优先:单页细节

  1. P7 附件查询合并:ThreadController::show() 一次 SELECT * FROM attachments WHERE thread_id = ?,PHP 按 post_id 是否为空分组为主楼/回帖附件。
  2. P8 详情页复用索引行:Post::countByThread() 与 getByThread() 合并为一次 fetchThreadIndex() + 一次桶查询返回 {total, rows},或让 getByThread() 顺带返回 COUNT。
  3. P9 个人中心去重:直接使用 Auth::getCurrentUser() 的返回数组,去掉 User::find。
  4. P11 archives 缓存:博客归档分组结果加短 TTL 文件缓存(类似 getOnlineData)。

六、风险与注意事项

  • 所有优化均为查询层重构,不改表结构、不改对外接口(URL/字段),按上述清单逐项实施即可灰度回归。
  • P1/P2 涉及权限与数据展示正确性,改动后需重点回归:标签页帖子排序(置顶优先 + 最后回复时间)、发帖页各版块"可发帖"判定。
  • P3 涉及私信未读红点,回归时确认收件箱未读数与布局顶部未读数一致。
  • P4/P6 为缓存增强,需确认发帖/回帖后计数器与在线数不会显示陈旧值(现有 flushCounters/invalidateCategoryStats 机制已具备,仅需对齐)。

报告完毕。以上为分析与建议,未修改任何代码,等待您的指令后再实施。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 2 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录