No translation yet — showing the Chinese original. · View Chinese original

案例:古书屋 gushuwu.com —— 100 万+ 内容页,依然秒开

一句话

100 万+ 内容页,依然秒开。

古书屋(gushuwu.com)是一个刚上线的公版古籍站:站内已发布内容 1,054,521 篇、全文合计 12.17 亿字36,217 位作者,另有 139,319 条繁体译文。在整页缓存命中时,首页、分类页与内容页都在 20–30 毫秒量级返回。

本文所有数字都来自实测:规模数字取自站点数据库的 SQL,性能数字取自对源站的现场重测。每个数字的口径与取数命令见下方「口径与取数」。 ⚠️ 一个重要前提:本文写作时,该站正在并行导入内容(实测约 100 条/秒)。所以「内容行总数」「全文总量」「关联数」这类累计值在持续增长——下表是 2026-09-11 15:21 UTC 的快照,不是长期常量。相对的,分类规模(诗 843,621 / 词 37,304 / 古籍 12,049 …)在这一时刻是稳定的。

站点简介

古书屋(gushuwu.com)做的是公版古籍的阅读与检索——把经史子集、历代诗文、字典韵书整理成人可读、机器可检的形态,全部内容为公版文献。

内容版图(顶层三条内容轴 + 一条工具轴):

  • 诗轴:诗总目 843,684 条,下含楚辞(63)
  • 词轴:词 37,304 + 曲 34,705 = 72,009 条
  • 古籍轴:书目 12,049 部,下含四部(经 2,639 / 史 3,199 / 子 4,026 / 集 1,109)、另编(道教部 83 / 佛教部 5 / 小说部 8 / 丛书部 3)与文类(蒙学 9 / 文言文 531 / 散文 218 / 随笔 219)
  • 工具轴:名句 5,684、成語 30,906、部首 214、甲骨文 6,361、古籍導航 502,以及字典三书(漢語字典 14,810 / 康熙字典 46,814 / 說文解字 9,833)

阅读与检索形态:

  • 服务端繁简双语:简体为默认,全站有 139,319 条繁体版本,走 /tw/… 独立 URL(服务端渲染,不是浏览器端转换)
  • 多入口检索:全文检索、书名拼音索引(/pinyin.html)、作者三轴索引(朝代 35,481 位 / 拼音 36,217 位 / 部首 184 部、35,697 位)、分类与标签总目、年月归档
  • 阅读工具:内容页「▶ 朗读」(在线合成 + 逐句高亮跟读)、阅读轨迹与划线笔记、收藏书架
  • 校勘公示:校勘类目 57 部 / 57 条校勘条目

规模数据

取数时间:2026-09-11 15:21 UTC(北京时间 23:21),数据库 gushu_stage,快照前后 4 秒内取完。

指标数值口径此刻是否在变
内容行总数1,054,539ye_contents 全部行(COUNT(*)在增长(约 100 行/秒)
已发布内容1,054,521type='post' AND status='publish'在增长
独立页面15type='page' AND status='publish'(10 个专题 + 关于 / 隐私 / 版权 + 作者索引 + 拼音索引)稳定
全文总量1,217,087,332 字(12.17 亿)SUM(words),限 post + publish + 未删除在增长
作者36,217侧表 ye_gushu_authors 行数稳定
繁体译文139,319ye_poly_contentslang='tw' AND status='publish'稳定
分类 / 标签72 / 38,598ye_metastype 分组标签在增长
分类标签关联3,134,868ye_relationships 行数在增长
诗文篇目915,693诗轴 843,684 + 词轴 72,009稳定
· 诗843,621分类「诗与未分类诗文」稳定
· 楚辞63诗轴下级稳定
· 词37,304稳定
· 曲34,705词轴下级稳定
古籍(书目)12,049古籍轴:本级 + 一级子类之和稳定
· 经部 / 史部 / 子部 / 集部2,639 / 3,199 / 4,026 / 1,109四部分类稳定
字典三书71,457康熙字典 46,814 + 漢語字典 14,810 + 說文解字 9,833稳定
名句5,684名句类目稳定
成語30,906成語类目稳定
部首214部首类目稳定
甲骨文6,361甲骨文类目稳定
古籍導航502导航类目稳定
校勘57校勘类目(另有 57 条校勘条目)稳定
专题10另有 10 个子专题各 1 部稳定
蒙学 / 文言文 / 散文 / 随笔9 / 531 / 218 / 219文类稳定
数据库体积9,518.7 MBinformation_schema:数据 8,777.3 MB + 索引 741.4 MB缓慢增长
数据目录磁盘占用10 GBdu -sh 数据目录 gushu_stage缓慢增长
站点目录333 MB其中 uploads/ 占 110 MB稳定
Sitemap27 个分片 + 1 个索引,共 1,240,531 条 URL每片 ≤ 5 万条 / ≤ 50 MB(静态文件,尚未随导入重建)待重建
口径说明:站点的规模数字有两套——分类维度读的是 ye_metas.count(随导入线维护的去规范化计数列),内容维度COUNT(*)。本次逐一用 COUNT(DISTINCT content_id) 对分类数字做了复核,并排除了「分类计数是旧值」的可能。

站点自己显示的数字,与库内实测逐个对账

古书屋首页有一条统计条。把它显示的六个数字,与同一时刻用 SQL 查出来的值并列:

首页统计条显示同刻库内实测对应取数
11,045 部 四部典籍11,045COUNT(DISTINCT content_id),限 8 个部类 slug(经部 / 史部 / 子部 / 集部 / 道教部 / 佛教部 / 小说部 / 丛书部)
915,693 篇 詩文篇目915,693诗轴 843,684 + 词轴 72,009
36,217 位 作者小傳36,217SELECT COUNT(*) FROM ye_gushu_authors
12.16 億字 全文總量12.17 億(1,217,087,332 字)SUM(words),限 post + publish + 未删除
139,319 條 繁體可讀139,319ye_poly_contentslang='tw' AND status='publish'
5,584 部 存目待補5,584标签 cunmu 下的 COUNT(DISTINCT content_id)

6 项里 5 项逐位一致,第 4 项差 0.01 亿——这一项对不上的原因已经查清,而且不是数据错误:站点首页的统计条结果有 1 小时缓存,而该站此刻正在以约 100 条/秒导入内容。也就是说,统计条显示的是「缓存生成那一刻」的 12.16 亿,而我现在实时查出来是 12.17 亿。缓存过期重算后就会追上。

这里正是「口径要先写清楚」的意义:最后一项「存目待补」指的是只存目录、没有正文的那 5,584 部书(站点把它们和「有全文」的 12,049 部分开计)。如果只看数字,很容易把它误读成「待补的书有多少」。

性能实测

所有请求从源站本机发起(curl --resolve gushuwu.com:443:127.0.0.1),绕开 Cloudflare,所以下表不含公网与 CDN 的传输时间——量的是站点自身的渲染与缓存成本。热/冷由响应头 X-Cache: HIT / MISS 判定,不靠推断。

页型(路径)热态:整页缓存命中冷态:清空整页缓存后的首次请求走整页缓存
首页 /0.012 – 0.027 s0.061 s
分类页 /category/诗/0.012 – 0.035 s0.250 s
分类页 /category/jiaokan/0.020 – 0.023 s0.302 s
内容页 /lun-yu.html(论语)0.017 – 0.025 s0.517 s
内容页 /shi-jing.html(诗经)0.016 – 0.028 s未单独取到
繁体页 /tw/lun-yu.html0.015 – 0.026 s0.548 s
年月归档 /archives/2026/08/0.017 – 0.030 s0.407 s
书名拼音索引 /pinyin.html0.021 – 0.044 s0.047 s
全文搜索 /search?q=古0.058 – 0.065 s0.446 s
阅读器 /reader0.045 – 0.152 s0.018 s否(插件页)
专题 /topics0.023 – 0.084 s未单独取到否(插件页)
校勘公示 /corrections0.066 – 0.087 s0.105 s否(插件页)
作者索引 /author.html1.046 – 1.131 s未单独取到
在线朗诵 /tts?text=…0.025 s(磁盘缓存命中)1.368 s(首次合成)否(二进制端点)

测量条件(请连着数字一起看):

  • 取数时间:2026-09-11 14:39–15:10 UTC(北京时间 22:39–23:10)
  • 请求间隔 0.8 s——该站 nginx 有 limit_req zone=gushuwu_dyn rate=6r/s,不留间隔会被 503 干扰
  • 「冷态」= 清空 var/cache/pages 后的首次请求。此时站点的统计缓存仍是热的,所以这不是「整机重启后的第一次」——真正的整机冷启动会更慢
  • 测量期间同一台机器上还有其它任务在跑,1 分钟负载在 1.8 – 11.7 之间波动:热态给的是多次测量的区间(最小值对并发队列最不敏感),冷态取的是负载 1.8–5.5 那一段的实测值
  • 作为对照:当机器负载升到 12 时,同一批请求里连整页缓存命中都会排队到 5–14 秒。那是机器被占满,不是渲染路径的成本——所以我把热态按区间给出,而不是挑一次最好看的数

两处如实记录的波动与短板:

  • 繁体内容页 /tw/lun-yu.html首次渲染曾观测到 12.0 s(那一刻它的辅助缓存也全是冷的),紧接着重测是 0.55 s,之后还有一次 7.7 s 的离群值。这一页的冷态波动是全站最大的,我没有把它从数据里拿掉。
  • 作者索引 /author.html(36,217 位作者的三轴总目)稳定在 1.05 s 以上,是全站最慢的页型,目前还没有做针对性优化

用到的 YeCha 能力

能力在古书屋上的用法
全页缓存公开页 GET 走文件整页缓存,命中即直出;文档里带 X-Cache: HIT / MISS 响应头,可直接用 curl 断言
服务端繁简双语(Poly 插件)139,319 条繁体译文成组存于 ye_poly_contents,前台以 /tw/… 独立 URL 服务端渲染;不是前端 JS 转换
双主题皮肤 + 访客切换gushu 主题内置 A 書林 / B 丹鉛 两套皮肤 × 明暗,右下角控件即时切换、写入 cookie 全站继承
阅读器插件阅读轨迹(可「继续阅读」回到上次位置)、正文划线 + 笔记、收藏书架;未登录存本机、登录后并集同步
在线朗诵/tts?text=… 走 Edge TTS 合成,本地磁盘缓存 + LRU 上限;前端逐句高亮跟读、逐字注音可关
Sitemap 分片1,240,531 条 URL 切成 27 片,每片 ≤ 5 万条,索引页 /sitemap.xml 直出静态文件
插件机制站内共装 11 个插件:poly(多语言)、reader、recite、zhconv(繁简转换)、zidian(字典)、jiaokan(校勘)、gushuauthors(作者库)、autosave、subscribe、links、market
归档与计数年月归档(/archives/2026/08/)与自然日去重的阅读量计数

三个技术要点

三个都是「问题 → 做法 → 实测收益」的结构。

要点 1:两步取 id,消除跨表 filesort

问题。 繁体列表原先一条 SQL 写完:

FROM ye_poly_contents p JOIN ye_contents c ON c.id = p.group_id
WHERE p.lang = ? AND p.status = 'publish' AND ...
ORDER BY c.published_at DESC, p.id DESC LIMIT n

排序键跨两张表,MySQL 无法用索引排序,只能对 13.9 万行 join 结果做 filesort;而 SELECT 列表里还带着正文 LONGTEXT(全站 3.2 GB),filesort 阶段还要把正文一起物化。实测后果:

  • /tw/<slug>.html:单条 SQL 1,689 ms(当时页面 1.71 s)
  • /tw/category/<部>/:COUNT 6,831 ms,随后的 SELECT 撞上 max_execution_time=8000 被杀 → HTTP 500
  • /tw/archives/:GROUP BY 被 8 s 上限杀掉 → HTTP 500
  • /tw/page/2/:同样 500

做法。 统一改成两步:① 只用窄列(id / published_at)走索引拿到本页所需的 id 集合;② 只按这批 id 回表取要展示的列,在很小的结果集里再排序。分页 OFFSET 语义不变(先按 id 过滤再 OFFSET),候选窗口不足时自动放大重试。

实测收益。 原先被 8 秒上限杀掉、直接 500 的 /tw/archives/ 冷查询,降到亚秒级。按项目内 core/Front.php 的补丁记录,两条路径返回的 id 序列已实测逐行相同——所以这是纯性能改动,没有语义变化。(这一条一致性结论来自项目自身的补丁记录,不是本轮我对页面的重测。)

要点 2:列表页不拖正文,字数去规范化成一列

问题。 列表页其实只需要「标题 + 摘要 + 字数」,但原始写法会把整列正文一起取回来。更隐蔽的一处:主题在展示字数时,如果行里没有 words 列,就会回退去正文里用正则数字数——一篇 16 MB 的正文跑一次正则是秒级操作。这正是 /tw/archives/2026/08/ 要 3.4 s 的真凶。

做法。 两条一起改:

  1. words(字数)在入库时就算好并持久化进 ye_contents.words 列,列表页直接读列,不再数正文;
  2. 列表页回表时显式排除 content(用 information_schema 拼出「除正文外的全部列」),正文根本不出现在结果集里。

实测收益。 20 行的回表从 906 ms → 1 ms(实测样本:某一页 20 条正文合计 35.6 MB);繁体归档页从 3.4 s → 0.2 s

要点 3:窗口化 + 密度守卫

问题。 分类 / 标签这类术语页,如果一律从「关联表」出发(relationshipsmeta_id 驱动),对超大型术语就要对 9 万+ 命中行做跨表 filesort:「唐」有 95,003 条关联、「诗」有 105,378 条。实测 /tag/dyn-1/ 278 ms,全冷时 1.1–2.5 s

做法。 按术语规模分两条路:

  • 小术语(命中 ≤ 15,000):继续用关联表驱动 + 跨表排序——命中行少,filesort 很便宜;
  • 大术语:改成「窗口化」——先按 (published_at DESC, id DESC) 从内容表取一个候选窗口(初始 = 本页所需条数 × 8,上限 8,192),再只在这个窗口里按归属过滤。窗口是全局有序序列的前缀,所以结果一定正确;窗口内命中不够本页时按 8 倍放大重试。

另加一条密度守卫:先用「术语命中数 ÷ 站内总数」估算这页需要多大的窗口,如果所需窗口超过 4,096(也就是至少要重试一轮),就不再白试——因为重试那一步的 IN (最多 8192 个 id) + ORDER BY + OFFSET 实测要 0.4–2 s(例如「宋」在第 2000 条处实测 1,895 ms),而直接走关联表只要 245 ms。此时径直走关联表。

实测收益。 大术语页从「对 9 万+ 行 filesort」变成「在千级窗口里过滤」;密度守卫那一条,把注定要重试的深翻页从 1,895 ms 拉回 245 ms 的路径。守卫只决定要不要去试窗口,不改变任何返回数据——按项目内 core/Front.php 的补丁记录,两条路径的 id 序列已实测逐行相同;拿不到总数或参数异常时一律不拦、保持原行为。(同上,一致性结论来自项目补丁记录。)

顺带修掉的一处。 首页统计条要用「全站已发布内容总数」,原写法每次 COUNT(*) 实测 519.8 ms(走覆盖索引全扫 132 万行)。改成一个带 1 小时缓存的总数方法后,这 519.8 ms 变成约 0。


边界:还没做完的事

一个案例页如果只讲成绩就不值得看,所以这里如实列出已知的口径边界与未完成项

  • 体裁细分还粗。「诗」这一轴目前是 843,621 条,其中混着「未分类诗文」——诗、赋、骈文、杂文暂时没有拆开。按朝代、按体裁的细分还只覆盖了常见朝代。
  • 民国及以后的诗文口径待定。目前站内绝大部分内容落在 published_at = 2026-08(导入日期),不是作品的真实年代;真实年代存在标签(如「朝代:宋」)而不是发布时间的维度里。所以「按年月归档」在这站上反映的是入库时间,不是文学史时间——这一点在页面上没有被伪装成后者。
  • 作者只在「已系年」的范围内完整。36,217 位作者里有 736 位未系年,这部分先按拼音 / 部首走。
  • 繁体是「可读」而不是「已校」。139,319 条繁体为机器转换结果,尚未逐条人工校对;站点把它标注为「繁體可讀」而没有说成「定本」。
  • Sitemap 与内容的条数有微小差:繁体分片合计 139,316 条,ye_poly_contentslang='tw' AND status='publish' 是 139,319 条,差 3 条——分片生成器与计数口径存在未查明的 3 条差异,已如实保留、未做抹平。
  • 性能数字的测量条件:本轮实测在源站本机(127.0.0.1,绕开 Cloudflare)完成,测量期间机器上还有其它任务在跑,负载见下方实测表。冷态数字是「清掉整页缓存后的首次请求」,热点会被同时段的其它负载放大——同一 URL 重复测会出现明显波动,这是同一台机器上的真实情况,没有挑最好看的那一次。
  • 数据是有时间戳的快照,而且写的时候还在变。上表的取数时间是 2026-09-11 15:21 UTC;那一刻该站正在并行导入内容(实测约 100 条/秒),所以「内容行总数 / 已发布内容 / 全文总量 / 关联数 / 标签数」这类累计值会继续增长。它们是那一刻的真实值,不是长期常量——本文没有把它们写成「截至目前为止」。
  • 站点统计条与实时 SQL 有 0.01 亿的差,原因是统计条有 1 小时缓存(详见上文对账小节)。这是缓存行为,不是数据错误;但读者若在不同时刻两边对照,会看到这个差。
  • 本文写作期间,站点的作者数据搬了一次家:36,217 行作者页从 ye_contents 迁到了侧表 ye_gushu_authors。「内容行总数」因此一度由 1,079,206 变为 1,042,989(差额恰为 36,217)。内容一条没少,只是换了存放位置;本文采用搬迁后的口径(作者数直接报侧表行数)。
一句话总结:这套系统的能力边界是「把百万级公版文本稳定地读出来、检出来」;它还不是一个已经做完的学术整理平台,体裁细分、系年、繁体校勘都还有明确的工作量。

口径与取数

以下是本文数字对应的取数命令(在源站执行,gushu_stage 为只读来源):

-- 内容行数 / 已发布内容 / 独立页面
SELECT COUNT(*) FROM ye_contents;
SELECT COUNT(*) FROM ye_contents WHERE type='post' AND status='publish';
SELECT COUNT(*) FROM ye_contents WHERE type='page' AND status='publish';

-- 全文总量(post + publish + 未删除)
SELECT SUM(words) FROM ye_contents
 WHERE type='post' AND status='publish' AND deleted_at IS NULL;

-- 作者 / 繁体译文 / 关联
SELECT COUNT(*) FROM ye_gushu_authors;
SELECT COUNT(*) FROM ye_poly_contents WHERE lang='tw' AND status='publish';
SELECT COUNT(*) FROM ye_relationships;

-- 分类与标签规模 / 分类计数复核
SELECT type, COUNT(*) FROM ye_metas GROUP BY type;
SELECT COUNT(DISTINCT r.content_id) FROM ye_relationships r
  JOIN ye_metas m ON m.id = r.meta_id
 WHERE m.type='category' AND m.slug='诗';

-- 数据库体积(数据 + 索引)
SELECT ROUND(SUM(data_length+index_length)/1024/1024,1) FROM information_schema.tables
 WHERE table_schema='gushu_stage';
# 磁盘占用
du -sh /www/server/data/gushu_stage
du -sh /www/wwwroot/xuecha-yecha-stage

# Sitemap:分片文件数 / URL 总条数
ls -1 sitemap*.xml | wc -l
cat sitemap-*.xml | grep -c '<loc>'

# 性能:热态(读整页缓存)与冷态(强制渲染,不读不写整页缓存)
curl -k --resolve gushuwu.com:443:127.0.0.1 -D - -o /dev/null \
     -w '%{http_code} %{time_total}\n' https://gushuwu.com/
curl -k --resolve gushuwu.com:443:127.0.0.1 -H 'X-YC-NOCACHE: 1' -D - -o /dev/null \
     -w '%{http_code} %{time_total}\n' https://gushuwu.com/