
EmDash 404 日志上限优化解析仅在插入新路径时执行MAX_404_LOG_ROWS上限校验【免费下载链接】emdashEmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress项目地址: https://gitcode.com/gh_mirrors/emdas/emdash导读本文围绕 EmDash 开源 CMS 中一次典型的性能回归修复展开log404之前会在每一次404 命中后都执行全表COUNT(*)来校验_emdash_404_log表是否超出MAX_404_LOG_ROWS10,000 行上限即使该路径只是重复命中也会白白扫描整张表。本次修复将上限校验收敛为仅在新唯一路径首次插入时触发从而显著降低站点在大量重复 404 场景下的 D1 行读取量。读完本文你将理解这一优化背后的数据模型、原子 upsert 实现、容量驱逐策略以及配套的回归测试是如何用 SQL 日志断言来验证重复命中不再 COUNT这一行为的。变更内容概览变更记录位于 .changeset/skip-404-cap-on-updates.md属于emdash: patch级别的补丁核心改动一句话即可概括修复 404 日志记录仅在插入新的唯一路径时强制执行MAX_404_LOG_ROWS上限重复命中跳过全表COUNT(*)显著减少为大量重复 404 服务的站点的 D1 行读取。在展开源码细节之前先交代三个关键背景知识_emdash_404_log表的结构、log404的去重机制以及MAX_404_LOG_ROWS上限的来源。_emdash_404_log表结构与常量定义建表语句_emdash_404_log表在 packages/core/src/database/migrations/029_redirects.ts 中创建其结构如下await db.schema .createTable(_emdash_404_log) .addColumn(id, text, (col) col.primaryKey()) .addColumn(path, text, (col) col.notNull()) .addColumn(referrer, text) .addColumn(user_agent, text) .addColumn(ip, text) .addColumn(created_at, text, (col) col.defaultTo(currentTimestamp(db))) .execute(); await db.schema.createIndex(idx_404_log_path).on(_emdash_404_log).column(path).execute(); await db.schema .createIndex(idx_404_log_created) .on(_emdash_404_log) .column(created_at) .execute();关键点path上有唯一索引idx_404_log_path这正是后面原子 upsert 依赖的约束基础referrer、user_agent、ip均为可空文本列后续迁移为表增加了hits、last_seen_at列见log404的 upsert 语句用于去重统计。上限与截断常量在 packages/core/src/database/repositories/redirect.ts 中定义了三组核心常量/** Hard cap on rows stored in _emdash_404_log. When exceeded, the oldest * rows (by last_seen_at) are evicted on insert. Prevents an unauthenticated * attacker from growing the table without bound by requesting unique URLs. */ export const MAX_404_LOG_ROWS 10_000; /** Max stored length for the Referer header — truncated on insert. */ export const REFERRER_MAX_LENGTH 512; /** Max stored length for the User-Agent header — truncated on insert. */ export const USER_AGENT_MAX_LENGTH 256;MAX_404_LOG_ROWS 10_000表行数硬上限超过后按last_seen_at升序驱逐最老记录防止未认证攻击者通过请求海量唯一 URL 无限撑大表REFERRER_MAX_LENGTH 512与USER_AGENT_MAX_LENGTH 256恶意客户端无法通过超大请求头撑爆存储。配套的截断工具函数truncateOrNull会保留null/undefined为null、空字符串保持为空串由调用方决定是否进一步归一化function truncateOrNull(value: string | null | undefined, max: number): string | null { if (value null || value undefined) return null; return value.length max ? value.slice(0, max) : value; }log404的调用链公共中间件中的发后即忘log404并非直接暴露给用户而是由公共重定向中间件在响应阶段调用。见 packages/core/src/astro/middleware/redirect.ts// No redirect matched -- proceed and check for 404 const response await next(); // Log misses (fire-and-forget) under the path the visitor requested. const location response.headers.get(location); const missedByRedirect isRedirectCode(response.status) (location /404 || location /404/); const missedDirectly response.status 404 pathname ! /404 pathname ! /404/; if (missedDirectly || missedByRedirect) { const referrer context.request.headers.get(referer) ?? null; const userAgent context.request.headers.get(user-agent) ?? null; repo .log404({ path: pathname, referrer, userAgent, }) .catch(() {}); }这里有两个值得注意的设计发后即忘fire-and-forgetlog404(...).catch(() {})不阻塞响应。源码注释明确说明——这个方法必须永远不对未认证调用方抛异常失败会冒泡到中间件并被吞掉见 redirect.ts 注释。两种 404 形态都会记录直接返回 404 的未匹配路由以及模板中常见的内容缺失时重定向到/404模式此时 missed path 只存在于浏览器跟随重定向之前的第一次请求中。错误页本身/404路径不会被记录因为它按设计返回 404 且不携带路径信息。从调用链可见一个站点 404 越多log404被调用的频率就越高如果每次都做全表COUNT(*)代价会被放大到难以接受——这正是本次优化要解决的问题。修复前的问题重复命中也要 COUNT在优化之前log404的逻辑大致是每次 upsert 之后无条件调用enforce404Cap()而enforce404Cap的第一步就是对整张表执行COUNT(*)private async enforce404Cap(): Promisevoid { const countRow await this.db .selectFrom(_emdash_404_log) .select((eb) eb.fn.countAllnumber().as(c)) .executeTakeFirst(); const count Number(countRow?.c ?? 0); if (count MAX_404_LOG_ROWS) return; // ...evict oldest rows... }问题在于重复命中的路径走的是ON CONFLICT DO UPDATE分支只更新已有行的hits与last_seen_at不会增加表行数。此时再执行全表COUNT(*)属于完全无效的工作——表行数根本没有变化。对于服务大量重复 404 的站点例如爬虫反复请求同一批失效链接每次命中都白扫一遍全表D1 行读取量随 404 流量线性增长既不产生任何收益又抬高了数据库成本。修复后的实现用返回值区分插入与更新修复后的log404在 packages/core/src/database/repositories/redirect.ts 中通过RETURNING id与自生成 id 的比较精确区分本次操作是新插入还是更新已有行async log404(entry: { path: string; referrer?: string | null; userAgent?: string | null; ip?: string | null; }): Promisevoid { const now new Date().toISOString(); const referrer truncateOrNull(entry.referrer, REFERRER_MAX_LENGTH); const userAgent truncateOrNull(entry.userAgent, USER_AGENT_MAX_LENGTH); const ip entry.ip ?? null; const id ulid(); // Atomic upsert by path. The UNIQUE index on path makes this safe // under concurrency: two requests for the same new path cant both // insert — the second one hits the conflict branch and increments // hits instead of failing with a uniqueness error. const result await this.db .insertInto(_emdash_404_log) .values({ id, path: entry.path, referrer, user_agent: userAgent, ip, hits: 1, last_seen_at: now, created_at: now, }) .onConflict((oc) oc.column(path).doUpdateSet({ hits: sqlhits 1, last_seen_at: now, referrer, user_agent: userAgent, ip, }), ) .returning(id) .executeTakeFirst(); // The conflict branch only updates existing rows, so repeat hits // cannot grow the table. Only enforce the row cap when we actually // inserted a new path. if (result?.id ! id) return; await this.enforce404Cap(); }关键机制拆解1. 原子 upsertON CONFLICT DO UPDATE整个写入是单条 SQL 语句完成的原子操作。path上的唯一索引保证并发安全两个请求同时命中同一个新路径时第一个执行 INSERT第二个进入冲突分支执行 UPDATEhits 1而不是抛出唯一性冲突错误。注释中特别指出这是对早期SELECT 后 INSERT/UPDATE竞态问题的回归修复——旧实现下两个并发调用都可能先 SELECT 不到记录随后第二个 INSERT 因唯一索引报错。2.RETURNING id与自生成 id 的判别新插入时返回的id就是本次生成的那个ulid()因此result?.id id继续执行enforce404Cap()冲突更新时返回的是已有行的id与本次生成的id不同result?.id ! id立即return跳过上限校验。3. 只读成本被压到最低重复命中的路径在数据库侧只发生一次带索引的 UPDATEWHERE path ?或等价的主键定位外加一次网络往返不再触碰任何全表聚合。这就是变更记录中所说的significantly reducing D1 row reads显著减少 D1 行读取的实现机理。容量驱逐enforce404Cap的边界处理当插入确实让表行数超过上限时enforce404Cap负责清理。其完整实现见 redirect.tsprivate async enforce404Cap(): Promisevoid { const countRow await this.db .selectFrom(_emdash_404_log) .select((eb) eb.fn.countAllnumber().as(c)) .executeTakeFirst(); const count Number(countRow?.c ?? 0); if (count MAX_404_LOG_ROWS) return; const excess count - MAX_404_LOG_ROWS; // Evict the oldest rows in a single SQL statement. Using a subquery // (rather than materialising the victim IDs in JS and passing them // back as bind parameters) keeps the statement bounded regardless of // how far over cap the table is — important for existing installs // that crossed the threshold before this cap was introduced. await this.db .deleteFrom(_emdash_404_log) .where( id, in, this.db .selectFrom(_emdash_404_log) .select(id) .orderBy(last_seen_at, asc) .orderBy(id, asc) .limit(excess), ) .execute(); }实现要点驱逐顺序按last_seen_at升序辅以id升序作为稳定次序选出最旧的excess行并删除因此持续被命中的路径即使首次出现很早也会保留在表中——冷路径被淘汰热路径被保留这与hits去重语义天然自洽。单语句删除使用子查询内联选择受害行 id而非在 JS 中物化 id 列表后再回传绑定参数。这样无论表超限多少SQL 语句大小都是有界的对在上限引入之前就已经超限的存量安装同样成立。何时会被调用enforce404Cap是私有方法注释明确仅由log404在新路径插入后调用。修复后它在重复命中路径上不再被触发。回归测试用 SQL 日志断言不 COUNT本次修复最有力的证据来自集成测试 packages/core/tests/integration/redirects/log404-bounded.test.ts。该测试文件标题直指unbounded 404 logging DoS无界 404 日志的 DoS回归验证了四类行为按路径去重连续三次log404({ path: /missing })后表内只有 1 行hits 3请求头截断10,000 字符的超大Referer/User-Agent被截断到REFERRER_MAX_LENGTH/USER_AGENT_MAX_LENGTH且null不会被强转成空字符串容量驱逐直接向表灌入MAX_404_LOG_ROWS行分批 500 行插入以规避 SQLite 每语句绑定参数限制随后插入新路径/brand-new断言表行数仍为上限、最老的seed-000000被删除、新路径存在并补充验证容量已满时重复命中已有路径只 bumphits不驱逐任何行并发原子性10 个并发log404({ path: /race })结束后恰好 1 行、hits 10证明 upsert 无丢失更新、无唯一性异常。其中与本次变更最直接相关的是最后一个用例only enforces the row cap on a new unique path, not on repeat hitslog404-bounded.test.ts。它用 Kysely 的查询日志钩子捕获了执行过的全部 SQLconst loggedDb new KyselyDatabase({ dialect: new SqliteDialect({ database: openNodeSqliteDatabase(:memory:) }), log(event) { if (event.level query) { captured.push(event.query.sql); } }, });随后分别统计插入与更新路径时count(*)风格 SQL 的出现次数captured.length 0; await loggedRepo.log404({ path: /new-path }); const countAfterInsert captured.filter((sql) /count\s*\(\s*\*\s*\)/i.test(sql)).length; expect(countAfterInsert).toBe(1); captured.length 0; await loggedRepo.log404({ path: /new-path }); const countAfterUpdate captured.filter((sql) /count\s*\(\s*\*\s*\)/i.test(sql)).length; expect(countAfterUpdate).toBe(0);断言语义一目了然新路径首次插入时COUNT(*)恰好执行 1 次同一路径重复命中时COUNT(*)执行 0 次。这正是skip-404-cap-on-updates这个 changeset 名称的技术注脚——用可执行测试锁死了行为契约防止未来重构把全表 COUNT 悄悄加回来。收益分析与适用前提收益量化设某站点 404 流量中重复路径的占比为r0 ≤ r 1每次 404 的 D1 行读取量近似为修复前每次命中 ≈ 1upsert 全表扫描COUNT(*) 修复后新路径命中 ≈ 1upsert 全表扫描COUNT(*)重复命中 ≈ 1UPDATE当站点由爬虫或失效外链驱动、r趋近于 1 时例如仅几个失效路径被高频请求修复后 D1 行读取量可降低一个量级以上。需要强调的是COUNT(*)是全表聚合其成本随表行数增长即使表始终远低于上限比如只有几百行重复命中时跳过 COUNT 依然节省了 404 流量 × 表大小 数量级的无效读取。适用前提与限制前提一表必须存在path唯一索引原子 upsert 才能成立。该索引在迁移 029_redirects.ts 中建立前提二enforce404Cap的驱逐语义是按last_seen_at驱逐最旧热路径优先保留。若运营者期望的是按首次出现时间created_at驱逐则需要另行调整限制修复只优化了重复命中这一主路径的读放大。新路径首次插入时仍执行一次全表COUNT(*)对于持续涌入海量全新 URL的攻击型流量读成本依然存在——MAX_404_LOG_ROWS上限机制本身才是防无界增长的最终防线本次优化解决的是正常业务场景下的无谓开销兼容性属于patch级别变更不改变log404的对外签名与行为语义去重、截断、驱逐均不变存量部署可直接升级。小结本次 changeset 是一个典型的小改动、大收益案例通过在原子 upsert 中利用RETURNING id与自生成 id 的差异将MAX_404_LOG_ROWS上限校验从每次命中必执行收紧为仅新路径插入时执行配合单语句有界驱逐与 SQL 日志断言测试同时保证了性能、安全与可回归性。对于需要高频记录 404、又担心 D1 读配额被重复命中烧光的 EmDash 站点这一实现思路同样可以迁移到其他去重计数 容量上限场景。关键代码速查关注点位置changeset 声明.changeset/skip-404-cap-on-updates.md常量与log404/enforce404Cap实现packages/core/src/database/repositories/redirect.ts中间件调用链fire-and-forgetpackages/core/src/astro/middleware/redirect.ts_emdash_404_log建表与索引packages/core/src/database/migrations/029_redirects.ts有界日志回归测试含 COUNT 断言packages/core/tests/integration/redirects/log404-bounded.test.ts【免费下载链接】emdashEmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress项目地址: https://gitcode.com/gh_mirrors/emdas/emdash创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考