仅注释变更,**无行为变更**。EGG_WORKERS 实际由 deploy/.env.production 注入 +
deploy/start-backend.sh 兜底决定,本次把注释里写死的旧口径同步为 2,避免
「注释与配置漂移」再次误导后人。
- app.js:
· 「生产显式设为 4」-> 2
· 「4×20=80(安全)」->「2×20=40(安全)」
· 「本地 dev 也会加载 .env.production,其中写着 4」-> 2
· 「dev 下真 worker=1 却报 4×20=80」-> 2×20=40
- config/config.default.js:
· 「生产值写在 deploy/.env.production(EGG_WORKERS=4)」-> 2(并注明 2026-09-29
R40 由 4 校正为 2)
· 「4 × 20 = 80」->「2 × 20 = 40」
· pool 段「worker 数由 EGG_WORKERS 显式控制(生产 = 4)」-> 2
· 「本机/多数服务器为 8 核 → 8 个 worker」->「核数越多起得越多(开发机 8 核 → 8 个)」
(原文把"8 核"当成多数服务器的事实,与实测的 2 核服务器不符)
背景(R40 · BACKLOG C2):worker 数 ≈ CPU 核数。worker 数 > 核数对 CPU 型路径
(bcrypt 登录)**零收益** —— 加 worker 不加核(native bcrypt 走 libuv threadpool),
只增加 context switch 与内存(单 worker 空闲 RSS ≈108MB)。登录容量由物理核数
决定(实测 ≈12.6 次/核·秒)⇒ 2 与 4 的登录容量相同。连接池 2×20=40 ≤ 151-20 ✅
验证:node --check app.js / config/config.default.js 通过;
verify-c1c2.py 含「五处文档/代码注释已无旧口径「4」残留」断言(24/24 通过)。
119 lines
5.5 KiB
JavaScript
119 lines
5.5 KiB
JavaScript
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
/**
|
||
* Egg.js 应用启动钩子 - AppBootHook
|
||
* 表结构通过 migration/seeder 管理,不在启动时自动同步
|
||
*/
|
||
class AppBootHook {
|
||
constructor(app) {
|
||
this.app = app;
|
||
}
|
||
|
||
async willReady() {
|
||
// 数据库表结构由 migration 管理,不在启动时自动 sync
|
||
this.app.logger.info('[AppBoot] 服务启动完成');
|
||
}
|
||
|
||
/**
|
||
* 进程就绪后,确保 app/public 下所有业务目录存在。
|
||
*
|
||
* 关键背景(生产踩坑):
|
||
* app/public/{works,answers,materials,tmp,...} 这类上传目录通常被 .gitignore 忽略,
|
||
* 每次「整目录重新部署」(rsync --delete / 重新 clone) 都会被清空。
|
||
* 若运行中的代码某条写入路径恰好没有即时 mkdir,就会 ENOENT → 上传失败 / 下载 404,
|
||
* 且旧版 auth 中间件会把这类磁盘异常伪装成「401 登录过期」误导排查。
|
||
* 此处统一在启动时把全部已知写入目录递归建好,作为永久兜底,
|
||
* 与各处写入点的 fs.mkdirSync(recursive:true) 形成双保险。
|
||
*/
|
||
async didReady() {
|
||
const publicDir = path.join(this.app.baseDir, 'app/public');
|
||
const groups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
|
||
|
||
// 需要按「目录/组别」创建的(组别子目录)
|
||
const grouped = ['works', 'answers', 'materials', 'templates'];
|
||
// 需要直接创建(无组别)的
|
||
const flat = ['tmp', 'tmp/excel', 'tmp/zip', 'carousel', 'news'];
|
||
|
||
const targets = [];
|
||
for (const d of grouped) {
|
||
for (const g of groups) targets.push(path.join(publicDir, d, g));
|
||
}
|
||
for (const d of flat) targets.push(path.join(publicDir, d));
|
||
|
||
let created = 0;
|
||
let existed = 0;
|
||
for (const t of targets) {
|
||
try {
|
||
if (!fs.existsSync(t)) {
|
||
fs.mkdirSync(t, { recursive: true });
|
||
created++;
|
||
} else {
|
||
existed++;
|
||
}
|
||
} catch (e) {
|
||
this.app.logger.error('[AppBoot] 创建上传目录失败', { target: t, message: e.message });
|
||
}
|
||
}
|
||
this.app.logger.info(
|
||
`[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})`
|
||
);
|
||
|
||
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):总连接数 = worker 数 × pool.max。
|
||
// 若超过 MySQL max_connections(默认 151),高峰期会偶发 "connection acquire
|
||
// timeout" / "too many connections"。启动期只做 WARN(不阻断启动)。
|
||
//
|
||
// 🔴 2026-09-21 修正(并发压测结论):原实现用 `os.cpus().length` 估 worker 数,
|
||
// 这是**误估** —— 真实 worker 数由 egg-scripts 的 --workers / EGG_WORKERS 决定
|
||
// (生产显式设为 2,见 config.default.js「集群 worker 数」段),与 CPU 核数无必然关系。
|
||
// 误估会在 8 核机上把「2×20=40(安全)」误报成「8×20=160(越限)」,
|
||
// 既制造假告警、也会掩盖真实的越限,使这条断言失去作用。
|
||
// 改为优先读 EGG_WORKERS(worker 进程继承 master 的环境变量),回退才用 CPU 数,
|
||
// 并在日志中显式标注估算依据,便于排障时判断该行可信度。
|
||
try {
|
||
const os = require('os');
|
||
const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20;
|
||
const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT });
|
||
const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151;
|
||
const explicitWorkers = parseInt(process.env.EGG_WORKERS, 10);
|
||
const usingExplicit = Number.isFinite(explicitWorkers) && explicitWorkers > 0;
|
||
// 🔴 2026-09-21 二次修正:egg-bin dev / test 会**显式传** `workers: 1`
|
||
// (node_modules/egg-bin/scripts/start-cluster.js),此时即便环境里存在
|
||
// EGG_WORKERS(本地 dev 也会加载 deploy/.env.production,其中写着 2),
|
||
// 也不是真实 worker 数 —— 真实值是 1。故必须先按 app.config.env 判定
|
||
// 单 worker 的运行形态,否则本地仍会打出一条假数字日志(实测:dev 下
|
||
// 真 worker=1 却报 2×20=40)。
|
||
const envName = (this.app.config && this.app.config.env) || '';
|
||
const singleWorkerEnv = envName === 'local' || envName === 'unittest';
|
||
let workers;
|
||
let workersSource;
|
||
if (singleWorkerEnv) {
|
||
workers = 1;
|
||
workersSource = `egg env=${envName}(egg-bin 强制单 app worker)`;
|
||
} else if (usingExplicit) {
|
||
workers = explicitWorkers;
|
||
workersSource = `EGG_WORKERS=${explicitWorkers}(显式)`;
|
||
} else {
|
||
workers = os.cpus().length;
|
||
workersSource = `os.cpus()=${workers}(EGG_WORKERS 未设置,为回退估算)`;
|
||
}
|
||
const estimated = workers * poolMax;
|
||
if (estimated > dbMax) {
|
||
this.app.logger.warn(
|
||
`[AppBoot][pool] 预估总连接数 ${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX / EGG_WORKERS`
|
||
);
|
||
} else {
|
||
this.app.logger.info(
|
||
`[AppBoot][pool] 连接池容量 OK:${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) ≤ MySQL max_connections=${dbMax}`
|
||
);
|
||
}
|
||
} catch (e) {
|
||
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message));
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = AppBootHook;
|