perf(用户导入): 批量导入从几秒降到半秒
Some checks failed
Deploy / deploy (push) Has been cancelled

导入一个班(45 人)要转好几秒,慢的全在密码哈希这一处:

- 哈希在重名检查之前算。老师习惯把同一份名单粘两次,那种情况要白等
  一整个班的 argon2 才看到 409。把校验和重名查询提到前面,这条路径
  现在是 5.6ms 返回。
- 45 次 argon2 串行 await。改成固定 4 路并发池;不用 Promise.all 是因为
  oj-api 的 mem_limit 只有 512m,一个年级 300 人全量并发撑不住。
- argon2id 参数从 Bun 默认的 m=64MiB 显式降到 OWASP 推荐下限 m=19MiB,
  单次 140ms → 20ms。参数编码在哈希串里,存量账号照常验证、不用迁移,
  旧的 pbkdf2 那条分支也不受影响。

45 人端到端 2.04s → 0.55s。验过:旧 m=65536 的哈希、新 m=19456 的哈希、
Django 的 pbkdf2 三种都能正常登录,错误密码照常拒绝。

顺带把生成页下载的 CSV 裁成用户名和密码两列 —— 发给学生的就这两样。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
This commit is contained in:
2026-09-01 00:28:16 -06:00
parent e5a6f2d1e7
commit a2f67eebf6
3 changed files with 39 additions and 11 deletions

View File

@@ -53,6 +53,22 @@ export async function verifyPassword(password: string, encoded: string) {
return { valid: false, needsUpgrade: false }
}
/**
* 参数是显式写死的,不用 Bun 的默认值(`m=65536, t=2, p=1`,即每次哈希占 64 MiB
* 取的是 OWASP 对 argon2id 的推荐下限 `m=19MiB, t=2, p=1`
*
* - 批量导入一个班要连算几十次哈希64 MiB 那档单次 ~140ms而 `oj-api` 的
* mem_limit 只有 512mdocker/compose.debian.yml并发度被内存卡死。
* 19 MiB 这档单次 ~20ms并发 4 路的峰值也才 76 MiB。
* - 参数是编码进哈希串本身的(`$argon2id$v=19$m=19456,t=2,p=1$...`),所以**存量
* 账号一个都不用迁移**Bun.password.verify 读串里的参数验,改这里只影响此后新写的哈希。
*/
const ARGON2_OPTIONS = {
algorithm: "argon2id",
memoryCost: 19456,
timeCost: 2,
} as const
/**
* 写密码的**唯一入口**。五个调用方都走这里:注册、管理员改密码、批量导入用户、
* 重置密码、登录时升级存量 pbkdf2。
@@ -68,5 +84,5 @@ export async function verifyPassword(password: string, encoded: string) {
* 账号;靠开关只能拦住将来,修不了已经发生的。
*/
export function hashPassword(password: string) {
return Bun.password.hash(password, { algorithm: "argon2id" })
return Bun.password.hash(password, ARGON2_OPTIONS)
}

View File

@@ -238,18 +238,15 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const rows = parsed.data.users
const prepared: { username: string; password: string; raw: string; email: string; realName: string; className: string | null }[] = []
type Prepared = { username: string; password: string; raw: string; email: string; realName: string; className: string | null }
// 先把不花钱的校验全做完,再动 argon2。班级号错、用户名重复这两种情况占了失败的绝大多数
// (老师习惯把同一份名单粘两次),先算哈希的话要白等一整个班的 argon2 才看到报错。
const prepared: Prepared[] = []
for (const [username, password, email, realName] of rows) {
const className = classNameOf(username)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
prepared.push({
username,
password: await hashPassword(password),
raw: password,
email,
realName,
className: className.value,
})
prepared.push({ username, password: "", raw: password, email, realName, className: className.value })
}
const existing = await db.select({ username: schema.user.username }).from(schema.user)
@@ -258,6 +255,19 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
return failure(c, 409, "username-exists", `用户名已存在:${existing.map((row) => row.username).join("、")}`)
}
// argon2id 是**故意**做慢的,串行 await 的话一个班要转好几秒。但也不能 Promise.all
// 全量:每次哈希占 m=19MiB见 auth/password.ts 的 ARGON2_OPTIONS一个年级 300 人
// 同时开就是 5.7GB,而 oj-api 的 mem_limit 只有 512mdocker/compose.debian.yml
// 固定 4 路并发,瞬时峰值 76MiB 封顶。
const HASH_CONCURRENCY = 4
let cursor = 0
await Promise.all(Array.from({ length: Math.min(HASH_CONCURRENCY, prepared.length) }, async () => {
while (cursor < prepared.length) {
const item = prepared[cursor++]!
item.password = await hashPassword(item.raw)
}
}))
// 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在
const created = await db.transaction(async (tx) => {
const users = await tx.insert(schema.user).values(prepared.map((item) => ({

View File

@@ -50,7 +50,9 @@ async function uploadUsers() {
toggleLoading(true)
await importUsers(users.value)
message.success("用户已上传成功")
const csv = users.value.map((u) => u.join(",")).join("\n")
// 只导出用户名和密码两列 —— 发给学生的就是这两样,邮箱是占位生成的、
// 真名本来就是老师粘进来的那一列,都不用回传
const csv = users.value.map(([username, password]) => `${username},${password}`).join("\n")
const hiddenElement = document.createElement("a")
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
hiddenElement.target = "_blank"