From e6a3da14b0ee40b18ccdc7f46b3cb860a817688e Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 26 Oct 2026 18:36:54 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E6=8F=90=E4=BA=A4=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=AD=89=E6=9F=A5=E8=AF=A2=E6=94=B9=E8=B5=B0=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=EF=BC=8C=E9=81=BF=E5=85=8D=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=20html/css/js=20=E5=A4=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite 把 html/css/js 存在行内,而 user_id、task_id、flag 等列排在它们后面, 读这些列也要先读完整段代码;线上库也没有统计信息,规划器常挑错索引。 - 新增覆盖索引 submission_submission_slim - 提交列表、随机打分先在索引上取 rowid,再只读这一页 / 这一行的完整内容 - 列表的 flag / zone 筛选用一元 + 避开单列索引 - 打开数据库时执行 PRAGMA optimize,之后每小时一次 线上备份实测:提交列表 844 ms → 6 ms,打分后重算分数 11.5 ms → 0.2 ms。 Co-Authored-By: Claude Opus 5.5 --- server/src/db.ts | 5 +++++ server/src/index.ts | 4 ++++ server/src/routes/submission.ts | 37 +++++++++++++++++++++------------ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/server/src/db.ts b/server/src/db.ts index f830012..f1b5989 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -25,6 +25,9 @@ CREATE INDEX IF NOT EXISTS "submission_submission_task_id_91e9556b" ON "submissi CREATE INDEX IF NOT EXISTS "submission_submission_user_id_71741691" ON "submission_submission" ("user_id"); CREATE INDEX IF NOT EXISTS "submission_submission_flag_d96bc4aa" ON "submission_submission" ("flag"); CREATE INDEX IF NOT EXISTS "submission_submission_zone_a1bbf0a2" ON "submission_submission" ("zone"); +-- 非 Django 原有:除 html/css/js 外的常用列。user_id 等列在表里排在 html/css/js 后面, +-- 直接读表要先读完整段代码,所以列表、打分、统计等扫描都靠这个覆盖索引 +CREATE INDEX IF NOT EXISTS "submission_submission_slim" ON "submission_submission" ("user_id", "task_id", "created", "score", "flag", "zone", "raw_score", "id"); CREATE TABLE IF NOT EXISTS "submission_rating" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "score" integer NOT NULL, "created" datetime NOT NULL, "submission_id" char(32) NOT NULL REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED); CREATE UNIQUE INDEX IF NOT EXISTS "submission_rating_user_id_submission_id_29be4c39_uniq" ON "submission_rating" ("user_id", "submission_id"); CREATE INDEX IF NOT EXISTS "submission_rating_submission_id_a20dcece" ON "submission_rating" ("submission_id"); @@ -57,6 +60,8 @@ export function openDb(path: string): Database { db.run("PRAGMA busy_timeout = 5000") db.run("PRAGMA foreign_keys = ON") db.run(SCHEMA) + // SQLite 不会自动 ANALYZE,没有统计信息时规划器常挑错索引;0x10002 = 打开时只分析必要的表 + db.run("PRAGMA optimize=0x10002") return db } diff --git a/server/src/index.ts b/server/src/index.ts index 6000c5a..8c4b75d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,6 @@ import { app } from "./app" import { config } from "./config" +import { db } from "./db" import { upgrade, websocket, type WsData } from "./ws" const server = Bun.serve({ @@ -16,4 +17,7 @@ const server = Bun.serve({ websocket, }) +// 长期运行的连接按 SQLite 文档建议定期刷新统计信息 +setInterval(() => db.run("PRAGMA optimize"), 60 * 60 * 1000) + console.log(`webpreview server listening on http://localhost:${server.port}`) diff --git a/server/src/routes/submission.ts b/server/src/routes/submission.ts index 4fa1387..559bd06 100644 --- a/server/src/routes/submission.ts +++ b/server/src/routes/submission.ts @@ -234,13 +234,14 @@ router.get("/", (c) => { where.push("s.user_id = $user_id") params.user_id = f.user_id } - if (f.flag === "any") where.push("s.flag IS NOT NULL") + // flag / zone 前的一元 + 让 SQLite 不用这两列的单列索引(那样要回表读大行),改走 slim 覆盖索引 + if (f.flag === "any") where.push("+s.flag IS NOT NULL") else if (f.flag) { - where.push("s.flag = $flag") + where.push("+s.flag = $flag") params.flag = f.flag } if (f.zone) { - where.push("s.zone = $zone") + where.push("+s.zone = $zone") params.zone = f.zone } if (f.score_lt_threshold !== undefined) { @@ -258,7 +259,8 @@ router.get("/", (c) => { } if (f.grouped) { // 分组模式:每个 (user, task) 只保留最新一条 - where.push(`s.id = (SELECT s3.id FROM submission_submission s3 + // 用 rowid 而不是 id,这样子查询能完全走 submission_submission_slim 覆盖索引 + where.push(`s.rowid = (SELECT s3.rowid FROM submission_submission s3 WHERE s3.user_id = s.user_id AND s3.task_id = s.task_id ORDER BY s3.created DESC LIMIT 1)`) } @@ -271,12 +273,16 @@ router.get("/", (c) => { const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "" const { limit, offset } = pageWindow(f) + // 先只在索引上排序分页拿到这一页的 rowid,再读这几行的完整内容; + // 否则排序时要把每条提交的 html/css/js 大行都读一遍 const items = all( `${SUBMISSION_SELECT}, (SELECT COUNT(*) FROM submission_submission s2 WHERE s2.user_id = s.user_id AND s2.task_id = s.task_id) AS submit_count - ${SUBMISSION_FROM} ${whereSql} - ORDER BY ${orderBy} LIMIT $limit OFFSET $offset`, + ${SUBMISSION_FROM} + WHERE s.rowid IN (SELECT s.rowid ${SUBMISSION_FROM} ${whereSql} + ORDER BY ${orderBy} LIMIT $limit OFFSET $offset) + ORDER BY ${orderBy}`, { ...params, me: user.id, limit, offset }, ) const count = get<{ n: number }>(`SELECT COUNT(*) AS n ${SUBMISSION_FROM} ${whereSql}`, params)!.n @@ -305,20 +311,25 @@ router.get("/random-for-rating", (c) => { } if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) return c.json(null) - const base = `SELECT s.id, u.username, t.title AS task_title, t.display AS task_display, - t.task_type, s.html, s.css, s.js - ${SUBMISSION_FROM} + // 只在索引上随机挑出 rowid,再读这一行的代码;否则每个候选都要读一遍 html/css/js + const base = `SELECT s.rowid AS rid FROM submission_submission s WHERE s.user_id != $me AND NOT EXISTS (SELECT 1 FROM submission_rating r WHERE r.submission_id = s.id AND r.user_id = $me) ${excludeId ? "AND s.id != $exclude" : ""}` const params = { me: user.id, ...(excludeId ? { exclude: excludeId } : {}) } - const pick = - get( + const picked = + get<{ rid: number }>( `${base} AND (SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5 ORDER BY RANDOM() LIMIT 1`, params, - ) ?? get(`${base} ORDER BY RANDOM() LIMIT 1`, params) - if (!pick) return c.json(null) + ) ?? get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params) + if (!picked) return c.json(null) + const pick = get( + `SELECT s.id, u.username, t.title AS task_title, t.display AS task_display, + t.task_type, s.html, s.css, s.js + ${SUBMISSION_FROM} WHERE s.rowid = $rid`, + { rid: picked.rid }, + )! return c.json({ submission_id: uuidOut(pick.id), username: pick.username,