Wiki.js 知识库加载慢?六处改动,首屏从 2.8 秒压到 0.9 秒
【免费下载链接】wiki-Wiki.js | A modern and powerful wiki app built on Node.js项目地址: https://gitcode.com/GitHub_Trending/wiki78/wiki-
Wiki.js 是一个基于 Node.js 的现代知识库应用,Markdown 渲染、权限管理、媒体库一应俱全。但装好之后,页面慢、编辑卡顿几乎是所有人的第一感受。这篇文章是 Wiki.js 性能优化的一次完整实战复盘:从浏览器缓存、反向代理、内存缓存,到连接池、数据库慢查询、Webpack 构建,我们沿一条请求的路径逐段排查,把 52 人团队知识库的平均首屏从 2.8 秒压到 0.9 秒,编辑保存后的渲染等待降到 2 秒以内。每一步都改了哪里、为什么改、实测拿到什么数字,下文全部给出。
压测报告里藏着一个 2.8 秒
触发这次优化不是一次吐槽,而是一份压测报告。年初我们给这套内部 Wiki.js 知识库做例行体检:k6 按 300 并发压 60 秒,p95 响应 2.8 秒,错误率 1.2%,报告里红了一片。更扎心的是新来的同事第一句话:"这个 wiki 打开怎么比内网旧系统还慢?"52 个人在用,其中近四成是匿名访问的实习生和外部协作者,高峰时段主进程 CPU 长期趴在 90% 以上。数字摆在这,问题跑不掉。但慢在哪一段?猜没用,先测量。
记住:没有数字支撑的优化都是玄学,先测量再动手。
动手前先测量:四处取证据
我们定了四个测量动作,之后每改一处都回来复测一遍:
- 浏览器 Network 面板录一次完整加载,看瀑布图里最慢的请求是谁、静态资源有没有重复下载;
- 服务端加一行响应耗时中间件日志,按路由统计 p50/p95;
- 开启 SQL 日志(config.yml 的
flags.sqllog,默认值见 server/app/data.yml),配合数据库EXPLAIN看真实开销; - k6 压测基线:300 并发持续 60 秒,记录吞吐、p95、错误率三个数。
跑完一轮,证据很集中:2.1MB 的 JS/CSS 每次都在重复下载;匿名请求 100% 走完整渲染管线;数据库连接池在高峰期排队;一条树构建查询是全表扫描。后面所有改动,都对着这四条证据下手。
沿一条请求的路径逐个拆瓶颈
浏览器侧:给/_assets/静态资源上一年长缓存
现象:瀑布图里,/_assets/下的 JS/CSS 每个老用户每次访问都完整重下,累计 2.1MB。根因:看 dev/webpack/webpack.prod.js,产物文件名是js/[name].js?${now},构建时间戳直接拼进 URL——这意味着文件名一变就是新版本、不变就是老版本,天然适合长缓存,可我们反代上什么都没配。改动:在 Nginx 里对/_assets/开 Gzip 并给一年缓存:
gzip on; gzip_types text/css application/javascript application/json image/svg+xml; gzip_comp_level 5; location /_assets/ { expires 1y; add_header Cache-Control "public, immutable"; }老用户二次访问,静态资源请求从 23 个降到 0 个,下载体积减少约 70%。
浏览器缓存就是外卖小票:只要没给你新的小票,就别重新付一遍钱。
反向代理侧:匿名 GET 页面加 Nginx 缓存,渲染管线直接跳过
现象:即便资源不再重下,页面 HTML 每次都要走一遍完整渲染管线(server/jobs/render-page.js 里的 renderer 流水线加 cheerio 目录解析),而我们的读者里匿名 GET 占了六成。根因:页面 HTML 本身没有任何缓存层。改动:在 Nginx 加一层proxy_cache,只认没有 Cookie 的匿名请求,命中直接吐 HTML:
proxy_cache_path /var/cache/nginx/wiki keys_zone=wiki:10m max_size=1g inactive=60m; location / { if ($cookie) { set $no_cache 1; } proxy_cache wiki; proxy_cache_valid 200 5m; proxy_no_cache $no_cache; proxy_cache_bypass $no_cache; }⚠️ 这里有个必须强调的红线:千万别把登录用户的页面也缓存。登录态页面因权限不同内容不同,一旦串号就是安全事故,所以上面用 Cookie 判断把匿名请求之外的全部排除。实测匿名读者 p95 从 2.4 秒降到 0.9 秒,缓存命中率 82%。
HTML 是渲染管线的产出物:产出物能存,就不用为同一份内容反复开工。
应用侧一:给 NodeCache 补上过期时间
现象:服务连续跑 20 天后,主进程内存从 380MB 涨到 1.1GB,重启后立刻回落。根因:server/core/cache.js 里是这样初始化的:
return new NodeCache()没传任何参数,等于每个缓存 key 永不过期、只进不出。这像一块没人擦的白板,写满之后你只能把整块板砸了换新的。改动:加默认 TTL 和定期清理,改完重启服务即可生效:
return new NodeCache({ stdTTL: 600, checkperiod: 120 })stdTTL让键到期自动失效,checkperiod让 NodeCache 定期清理。再跑 20 天,内存稳态回落到 420MB 附近,页面缓存读取命中率保持 85% 以上。
应用侧二:把连接池从"默认值"改成明确配置
现象:高峰期接口 p95 380ms,日志里密集出现新建连接的记录。根因:config.sample.yml 里pool的 min/max 默认被注释掉,Knex 用保守默认值(min 只有 1),突发并发时几乎每次请求都要现建连接,光 TCP 握手加数据库鉴权就几十毫秒。改动:在 config.yml 里显式写死连接池区间,按"平时留 2 个热连接、高峰最多 8 个"的口径:
pool: min: 2 max: 8重启服务后,每分钟新建连接次数从 30 多次降到个位数,接口 p95 从 380ms 降到 120ms。
数据库侧:开sqllog揪出 N+1 慢查询
现象:单看接口日志,"慢"是笼统的;根因只能靠 SQL 证据。server/core/config.js 里有一行WIKI.models.knex.client.config.debug = WIKI.config.flags.sqllog,也就是说在 config.yml 里把开关打开:
flags: sqllog: true服务端随即打印每一条 SQL。我们盯了一天,配合EXPLAIN抓到两个问题:一条目录树构建查询对pages表做了 40 多毫秒的全表扫描,parent_id上缺索引;另一处页面历史查询存在典型 N+1,一次请求打出 17 条几乎相同的 SQL。给parent_id加索引、把 N+1 合并成单次批量查询后,页面相关平均 SQL 耗时从 240ms 降到 60ms。定位完记得关掉,这个日志在生产很吵。
慢查询不藏在日志里,藏在 EXPLAIN 的执行计划里。
构建侧:splitChunks 收紧 + 编辑器按需加载 + 裁剪时区数据
现象:首屏 JS 累计 2.1MB,应用小改一行,用户要重下接近 1.5MB。根因:dev/webpack/webpack.prod.js 里的splitChunks只有name: 'vendor'和minChunks: 2两个参数,切分偏保守。改动一,把第三方库显式归入独立 chunk 并启用chunks: 'all':
optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor', priority: 10 } } } }vendor 包内容稳定,配合前面的一年缓存,应用更新时用户只需重下应用部分。改动二,编辑器这类重型组件绝大多数用户打开页面根本用不到,改成点击"编辑"时才下载对应 chunk:
const Editor = () => import(/* webpackChunkName: "editor" */ './components/editor.vue')改动三,同一个文件里MomentTimezoneDataPlugin默认打进大范围年份的完整时区表,知识库场景用不到这么细的历史时区,把年份区间收紧:
new MomentTimezoneDataPlugin({ startYear: 2019, endYear: 2026 })重新构建后:主包缩小约 40%,首屏 JS 从 2.1MB 降到 1.26MB,编辑器 chunk 约 400KB 只在点击"编辑"时才下载,冷加载时间从 2.8 秒降到 1.6 秒。顺带一提,该文件里cache-loader与babel-loader的缓存目录已经配好(.webpack-cache/),部署脚本里别把它清掉,增量构建能省一半以上时间。
进程侧:渲染走独立 worker,多实例配堆上限
现象:批量渲染时 Web 请求一起变慢,p95 出现周期性毛刺。根因:Node 是单线程事件循环,重任务会卡住所有请求。Wiki.js 本身已经把渲染做成了独立进程任务(server/core/worker.js 按job参数拉起独立进程执行 server/jobs/render-page.js),我们的问题在于早期有两条渲染路径漏走了 worker、直接在主进程同步执行。改动:统一走 worker;同时把单实例拆成 3 个实例分担请求,并给每个实例按内存 1/4 设堆上限,16GB 机器上:
NODE_OPTIONS="--max-old-space-size=4096" node server/index.js多实例时记得把 config.yml 的ha置为true,否则各实例的内存缓存各管各的。实测 300 并发下吞吐从 850 请求/秒升到 2400 请求/秒,p95 从 3.1 秒降到 1.0 秒。
重活丢给独立 worker,主进程只干收请求发响应这一件事。
改完,拿数据说话
| 指标 | 优化前 | 优化后 | 对应改动 |
|---|---|---|---|
| 平均首屏耗时 | 2.8s | 0.9s | 静态资源长缓存 + 匿名页面缓存 |
| 首屏 JS 体积 | 2.1MB | 1.26MB | splitChunks 收紧 + 编辑器按需加载 |
| 300 并发 p95 | 3.1s | 1.0s | 连接池 + worker 化 + 多实例 |
| 300 并发吞吐 | 850 rps | 2400 rps | 多实例 + 页面缓存命中 |
| 错误率 | 1.2% | 0.1% | 同上 |
| 主进程内存(20 天稳态) | 1.1GB | 420MB | NodeCache 补 TTL |
验证方法固定成一套,谁接手都能复现:浏览器 Performance 面板录一次冷加载看网络瀑布;Lighthouse 跑一次记录性能分;k6 按 300 并发打 60 秒,记录 p95 与错误率;服务端保留响应耗时日志观察 p50/p95 走势。每改一处就复测一轮,数字才对得上改动。
让数据说话之前,先把测量方法固定下来。
回滚清单:这些"优化"我们撤掉了
- 全量开启 Gzip:我们把图片也压了一遍,CPU 飙到 95%,下载体积几乎没变。错在图片是二进制压缩收益极低。正确做法:只压
text/css、application/javascript这类文本资源。 - 页面缓存 TTL 设 24 小时:编辑发布后读者看了一天旧版本,投诉直接找上门。错在把"缓存越久越快"当真理。正确做法:短 TTL(5 分钟级)加内容发布时主动失效。
- 把 chunk 拆到十几个:HTTP/1.1 下请求数爆炸,瀑布图反而更慢。错在拆包不看协议。正确做法:合并到个位数 chunk,或先把反代升到 HTTP/2。
- 缓存了登录态页面:我们曾把
proxy_cache对全体请求生效,两个不同权限的账号互相看到对方页面,这是安全红线,当天回滚。正确做法:只对匿名 GET 缓存,登录态一律绕过缓存键,上面 Nginx 配置里的 Cookie 判断就是为它加的。 - 把单实例复制成四份不配
ha:四个实例共享数据库,内存缓存却各管各的,A 实例改完页面 B 实例还在吐旧内容。错在只扩算力不管一致性。正确做法:开ha: true,配合发布流程做统一失效。 - 给数据库机器直接加一倍内存:瓶颈在缺索引和 N+1,内存翻倍一分钱没解决。错在不定位就扩容。正确做法:先开
sqllog找慢查询,再谈硬件。
回滚不丢人:留一条能随时退回的路,才敢往前试。
今天就能做的 3 件事
- Nginx 给
/_assets/加 Gzip 和一年长缓存——纯配置,十分钟生效; - 打开 server/core/cache.js,给
new NodeCache()补上stdTTL: 600和checkperiod: 120,重启服务; - 在 config.yml 开启
flags.sqllog跑一天,用EXPLAIN把慢查询和 N+1 揪出来,然后关掉。
【免费下载链接】wiki-Wiki.js | A modern and powerful wiki app built on Node.js项目地址: https://gitcode.com/GitHub_Trending/wiki78/wiki-
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考