资讯动态

Egg.js生产环境三件套:安全防护、性能调优与日志体系实战

发布时间:2026/10/20 20:21:16 来源:尧图企业网站定制
学习计划推进到第11天进度条走了三分之二。前10天是什么感觉呢就相当于给一辆车装好了发动机、变速箱、轮子和方向盘车能动了但踩油门的时候心里特别虚——怕刹车刹不住怕安全气囊弹不出来怕仪表盘全是乱码看不懂。今天这三样东西恰好就是安全防护、性能调优和日志体系。用我说人话的版本概括别让应用被人轻易攻破、别让请求慢到用户骂娘、别让线上出问题时两眼一抹黑。这套15天学完eggjs的系列笔记我写在2026年但内容不是纸上谈兵每一行都是自己动手在真实项目里跑过的。第11天是我整个学习曲线里感觉最扎实的一天因为今天学的东西不是新语法、新API而是一套思维方式。如果你也刚好学到eggjs的中后期或者已经能写完整CRUD但不知道怎么往上线靠这一篇应该能给你一些参考。我先把今天的内容框架摆出来从安全配置讲起然后深入eggjs的多进程模型和性能优化再聊日志体系怎么搭最后分享三个今天实际踩到的坑。全程会穿插可复制的配置代码和真实压测数据看完可以直接照着调。1. 第11天的学习目标和前置准备1.1 为什么第11天要同时啃安全、性能和日志前10天的学习路线大致是这样的环境搭建、路由与控制器、服务层、数据库模型、中间件、插件、用户认证一路走到第10天结束一个基础应用已经完整成型——能注册登录、能操作数据表、能跑定时任务。听起来挺完整但要真把它部署到服务器上心里是不踏实的。我当时做了三个小实验直接暴露了问题。第一个实验用接口调试工具随便往没有校验的接口发了一堆伪造请求发现全部都能通过数据库里多了一堆垃圾数据。第二个实验跑了1000个并发请求部分接口的平均响应时间飙到几百毫秒。第三个实验更尴尬应用里报了个错我在服务器上翻了半天控制台全是没加任何前缀的console.log根本分不清是哪个模块打出来的。这三个问题有一个共同点它们不是功能缺失而是“工程质量”缺失。第1到10天解决的是“把功能做出来”第11天开始解决的是“让应用能扛住真实流量”。安全、性能、日志这三块在 eggjs 里恰好是框架能力强项也是生产环境最绕不开的三个话题所以被安排在同一个学习日并不奇怪。1.2 动手操作前需要的环境和基础今天的内容偏实操会改配置、跑压测、看日志需要你有一个能正常启动的eggjs项目。我用的环境如下你可以作为参考Node.js 16eggjs 3.xMySQL 8.0Redis 6.x性能优化部分会用到强烈建议装一个如果还没写过完整的CRUD建议先回头把前10天的内容补上再来。今天的实验都是建立在一个已有应用之上的没有基础应用的话你会发现自己连“优化了什么”都看不出来更别说对比优化前后的差异了。另外学习节奏跟前10天不一样。前10天是学新写法属于增量积累越写越有成就感。今天是做存量优化属于校验和打磨需要你刻意去看配置项、看日志输出、看压测数据。说白了前10天是造车第11天是检车。2. 安全防线搭建CSRF、XSS 和跨域配置实操2.1 CSRF 防护eggjs 内置的安全网CSRF 的全称是跨站请求伪造原理不复杂。攻击者构造一个恶意页面诱导已登录用户点进去然后这个页面偷偷向用户已登录的应用发请求。因为浏览器会自动带上用户的cookie服务端根本分辨不出来这个请求到底是不是用户本人点出来的。打个比方有人冒充你去银行办转账柜员不核实本人证件就把业务给办了这就是CSRF。eggjs把CSRF防护默认就开着了核心机制是服务端在渲染页面时生成一个token放在cookie里前端每次提交请求时把这个token带回来服务端校验通过才放行。在config/config.default.js里可以这样配置exports.security { csrf: { enable: true, ignore: [/api/webhook, /api/upload/callback], queryName: _csrf, bodyName: _csrf, headerName: x-csrf-token } };这里几个字段值得展开说。ignore是数组表示哪些路径跳过校验一般用来放过第三方回调接口因为回调接口没法拿到你页面里的token。queryName、bodyName、headerName分别指定token可以出现在哪几个位置。实际开发中我推荐统一把token放进请求头里也就是配好headerName前端所有请求都走同一个头比往每个表单里塞隐藏字段干净得多。前端在使用fetch时可以这样把token带过去fetch(/api/user/update, { method: POST, headers: { Content-Type: application/json, x-csrf-token: getCsrfToken() }, credentials: include, body: JSON.stringify(data) });getCsrfToken可以从首次GET请求的响应里读取cookie拿到也可以在模板渲染时直接放到一个meta标签里让前端取用。注意前后端完全分离的跨域场景下CSRF校验需要和CORS配合起来理解。CSRF是防止伪造请求CORS是控制浏览器跨域权限两者不是一回事但经常一起出协作问题。2.2 XSS 防护模板转义与输入过滤XSS跨站脚本攻击的常见形式是攻击者往输入框提交一段脚本如果应用没有处理就把这段脚本原样输出到页面它会被浏览器当作脚本执行攻击者就能拿到cookie甚至篡改页面。eggjs在模板引擎层默认做了转义。用模板引擎渲染{{ content }}时里面的标签会被转义成lt;scriptgt;这样的普通文本浏览器只会把它当作字符串显示不会执行。这个机制在框架内部处理只要你别没事干用“显示原文”这类过滤器强行跳过转义风险就是可控的。但在纯接口场景下要格外小心。返回给前端的JSON数据不会自动转义如果前端直接把接口返回的字符串用innerHTML插进页面同样会被注入。所以我今天做两件事。第一给用户输入的字段加类型校验和长度校验。过长的字符串、特殊字符集中的字段大概率有猫腻。推荐结合egg-validate来定义入参结构const rule { nickname: { type: string, max: 20 }, bio: { type: string, max: 200 }, link: { type: string, format: /^https?:\/\//i } };第二在输出侧对不可信内容做转义。如果项目里要渲染富文本或者Markdown不能直接整段输出建议用白名单式的HTML净化工具只允许p、br、strong、em这些基础标签其他的一律剔除。2.3 CORS 跨域配置的三种场景前后端分离的项目几乎躲不开跨域问题。前端跑在http://localhost:8080后端跑在http://localhost:7001两边端口不一样浏览器默认会拦截跨域请求。eggjs的官方方案是egg-cors插件。安装后先启用// config/plugin.js exports.cors { enable: true, package: egg-cors };然后配置就要分场景看了。第一种纯公共接口允许所有来源访问exports.cors { origin: * };这种方式只适合不需要登录态的公共服务API。注意credentials: true不能和origin: *一起用会报错。第二种生产环境限定白名单来源exports.cors { origin: function(ctx) { const allowList [https://admin.example.com, https://www.example.com]; return allowList.includes(ctx.get(origin)) ? ctx.get(origin) : false; }, credentials: true };我一般建议用“回显验证”的策略只有请求头里的origin在白名单内才把对应的值返回到Access-Control-Allow-Origin。这样既能带cookie跨越域又能挡住非法来源。第三种开发环境的动态回显。本地调试的时候前端端口经常因为Vite或者热更新换来换去可以把回显逻辑放开直接返回请求方originexports.cors { origin: ctx ctx.get(origin), credentials: true };这种配置仅限本地开发环境。要是真把它放到生产环境等于允许任意网站带用户的cookie来请求你的接口CSRF防线直接被绕过后果很严重。2.4 安全配置容易踩的两个坑实操中我踩过两个坑值得单独记一笔。坑一CSRF token会过期但前端把token放在localStorage里持久化导致token失效后所有写请求全部403。这个错很隐蔽因为接口测试工具通常不校验token只有真实浏览器里才暴露。正确做法是每次页面加载时重新获取最新token不要缓存。坑二配置了CORS之后跨域请求还是报错。排查了半天发现是代理层把OPTIONS预检拦截了直接返回204后端根本没收到预检请求。eggjs的cors插件本身会处理预检但如果在代理层提前拦掉服务端就没办法正常响应浏览器最终还是拒绝。解决方法是让OPTIONS请求透传到eggjs或者统一走插件的预检处理逻辑。3. 多进程模型与性能调优实战3.1 理解 eggjs 的多进程架构性能调优绕不开进程模型这一点。eggjs启动后会拉起多个Node进程一个master进程、若干个worker进程、一个agent进程。这是框架最核心的设计也是今天理解性能的钥匙。master进程是总调度负责监控worker状态、分配端口、处理平滑重启不处理具体业务。worker进程才是真正干活的人每个worker是独立的V8实例有自己的堆内存和事件循环。agent进程是“管事”的角色适合跑和数据请求无关的共享任务比如定时抓数据、从数据库预加载配置。默认创建的worker数量等于CPU核心数。可以把应用比作餐厅开了N个窗口每个窗口一个厨师理论上窗口越多单位时间能服务的客人越多。但如果厨房只有一条出菜通道开再多窗口也没用所以加worker数量不等于加性能瓶颈要从整条链路看。3.2 用 messenger 打通进程间的消息通信多个worker之间是封闭的A worker里定义的一个全局变量B worker读不到。要让它们协同要么走外部存储比如Redis要么走框架提供的messenger机制。messenger的作用是在worker和agent之间、worker和worker之间传递消息。我处理过一个配置缓存不一致的问题。场景是全局配置缓存需要刷新如果在每个worker里各自刷新某几个worker可能刷得早、某几个刷得晚线上看到的行为不一致。我的方案是让agent进程统一加载配置再通过messenger广播给所有worker。// app.js class AppBootHook { constructor(app) { this.app app; } configWillLoad() { // worker监听agent广播来的消息 this.app.messenger.on(config-refreshed, data { this.app.logger.info(收到配置刷新通知: %j, data); }); // worker向agent请求刷新 this.app.messenger.on(request-refresh, async () { const data await this.app.mysql.get(config_table, { id: 1 }); this.app.messenger.sendToAgent(refresh-config, data); }); } }简单解释一下上面的代码。sendToAgent是把消息发给agent进程send是广播给所有worker。agent进程适合承载数据库预加载这类不参与HTTP请求的任务这样做的好处是不阻塞业务接口同时能保证全局只有一个数据源在更新缓存。3.3 性能调优三板斧缓存、查询优化、连接复用压测自己那个应用之后发现响应慢的接口九成以上是直接把锅甩给了数据库而且很多存在N1查询问题。N1长什么样先查一个订单列表然后对列表里的每一行再查一次用户信息一页10条数据实际执行了11条SQL。数据库不累才怪。解决N1的优先级是先优化查询方式再上缓存。能用一条连表SQL查出来的数据就不要再拆成多条。缓存解决的是数据库压力不是SQL效率问题。我这次做了一组对比实验改造前后的数据如下方案单次请求平均耗时数据库QPS说明直接查数据库280ms高每个请求都打到DB加Redis缓存22ms大幅降低缓存命中率约95%缓存加防击穿20ms稳定热点key加锁兜底保护实现缓存防击穿其实不复杂。核心思路是当缓存失效的一瞬间不能让所有并发请求直接涌向数据库。用Redis的setNX命令可以模拟一把互斥锁// app/service/product.js const cacheKey product:${id}; let product await app.redis.get(cacheKey); if (product) { return JSON.parse(product); } const lockKey product:lock:${id}; const locked await app.redis.set(lockKey, 1, EX, 5, NX); if (locked OK) { product await app.mysql.get(product, { id }); await app.redis.set(cacheKey, JSON.stringify(product), EX, 60); await app.redis.del(lockKey); return product; } // 没抢到锁的请求短暂等待后重试 await sleep(50); return await app.redis.get(cacheKey);这里有两个关键点。第一锁必须设置过期时间。如果某个worker抢到锁之后崩了没释放这把锁会永远卡死后续所有请求都会停下来傻等。设置过期时间相当于给锁加了个自动熔断机制。第二抢到锁的worker把数据库里的数据写回缓存再释放锁给其他请求一条快速通道。连接复用方面eggjs的mysql插件自带连接池默认配置能覆盖大部分场景。但要注意如果服务拆成了多个实例每个实例的连接池都开得很大数据库会被连接数打爆。合理的思路是让连接池上限和单个实例的并发能力匹配而不是把池子开到无限大。3.4 压测工具的选择与结果解读性能优化做没做成功不能靠感觉要拿数据说话。我用abApacheBench做压测命令很简单ab -n 10000 -c 100 http://127.0.0.1:7001/api/hello-n是总请求数-c是并发数。输出结果里重点看四个指标。第一个是Requests per secondQPS也就是每秒钟能处理多少请求。第二个是Time per request平均每个请求耗时。第三个是Complete requests和Failed requests失败率太高说明系统扛不住或者代码有bug。第四个是Transfer rate吞吐量会被响应体大小和带宽影响。压测工具最好跑在独立机器上不要压在目标服务器本地。我第一次压测就是本地又压服务又跑工具目标服务器CPU被压测工具本身分走了一部分测出来的数据严重失真。换到局域网另一台机器之后数值准确了很多。另外一个容易走进的误区是无脑加worker数量。我的服务器是8核默认8个worker我把配置改成16个worker后再压测QPS不但没有翻倍反而因为进程切换开销变多略微下降了。这说明瓶颈不在worker数量而在更深层的IO或者数据库连接上。压测的真正价值是帮定位木桶的短板而不是堆参数凑数字。4. 日志体系搭建与线上问题排查4.1 配置一个规范的日志目录和级别前10天靠console.log调程序还没觉得有什么真正把应用部署到测试环境之后才发现console.log在线上环境根本没法看。eggjs内置的日志体系是分类型的应用日志、上下文日志、agent日志、核心日志。应用日志记录业务逻辑相关信息上下文日志会自动带上请求的URL和耗时agent日志记录agent进程输出核心日志记录框架内部运行信息。config/config.default.js里的配置可以参考exports.logger { level: INFO, consoleLevel: INFO, dir: logs/myapp, appLogName: myapp-web.log, coreLogName: egg-web.log, agentLogName: myapp-agent.log, errorLogName: myapp-error.log };level是写日志文件的级别consoleLevel是输出到控制台的级别。生产环境建议把consoleLevel调成WARN避免一堆INFO日志刷屏。dir指定日志目录部署的时候要把它挂到持久化磁盘别放在容器内部否则容器重启日志就全丢了。4.2 自定义业务日志的两种写法日志怎么写得“可查”很有讲究。我的习惯是每个关键业务动作都留痕但每条日志必须带上业务标识比如订单号、用户ID、请求ID。这样后续排查只要拿一个ID去日志里grep整条链路就串起来了。方法一直接用ctx.logger这个方法会自动带上当前请求的上下文信息ctx.logger.info([order] 创建订单成功订单号: %s, 金额: %s, orderNo, amount); ctx.logger.error([order] 创建订单失败用户: %s, 错误: %s, userId, err.message);方法二定义独立的业务logger。适合需要把某类业务日志单独写到一个文件的场景比如用户操作日志const userLogger ctx.getLogger(userLogger); userLogger.info([login] 用户登录成功uid: %s, ip: %s, uid, ip);在配置里通过customLogger字段声明exports.customLogger { userLogger: { file: user.log } };单独分文件的好处是后续做日志分析或数据统计时更方便不用从一个大杂烩文件里反复筛选。4.3 用 requestId 串起完整调用链线上排查问题最怕的一件事就是“单个报错看不出是哪个请求报的”。尤其是多个请求交叉混在日志里上下文很容易乱。我给自己的应用加了一个中间件统一生成requestId并把它注入到整个请求生命周期中。// app/middleware/request_id.js module.exports () { return async function requestId(ctx, next) { const requestId ctx.headers[x-request-id] || ${Date.now()}-${Math.random().toString(36).slice(2, 10)}; ctx.requestId requestId; ctx.set(x-request-id, requestId); try { await next(); } finally { ctx.logger.info([request] 响应状态: %s, 耗时: %sms, ctx.status, Date.now() - ctx.startTime); } }; };客户报问题时只要让对方把请求头里的x-request-id发过来就能在整个日志文件里定位到这一个请求从进入应用到最后返回的全过程。再结合数据库慢查询日志就能判断慢的原因到底在SQL、依赖服务还是业务代码本身。4.4 日志切割与留存策略日志文件不切割的话跑几天就是几个GB的庞然大物IO变慢磁盘告警想查一个旧请求都费劲。eggjs的日志轮转配置可以按天切割并控制保留份数exports.logger { disableConsoleAfterReady: true, rotates: [ { file: myapp-web.log, interval: 1d, maxFiles: 15 }, { file: myapp-error.log, interval: 1d, maxFiles: 15 } ] };interval: 1d代表每天切换一个新日志文件maxFiles: 15表示保留最近15天。如果你的项目接入了集中式日志采集本地只需要留很短时间的文件就够了每天的日志上传走之后就可以清理掉磁盘压力会小很多。5. 第11天踩坑实录与学习心得5.1 今天实际踩过的三个坑第一个坑多worker环境下的定时任务重复执行。我设了一个每分钟刷新缓存的定时任务上线后一看日志8个worker每分钟都在跑同一个任务数据库被查询请求打爆了。排查之后用Redis分布式锁解决只有抢到锁的worker才执行任务执行完释放避免重复压迫数据库const lockKey schedule:refresh:lock; const locked await app.redis.set(lockKey, 1, EX, 10, NX); if (locked OK) { // 执行刷新逻辑 }第二个坑CSRF token没取到导致前端所有写请求403。排查过程就是看请求日志发现eggjs抛出的错误很明确token校验不通过。最后定位到是前端没走“每次刷新页面重新获取token”的逻辑而是把旧token存进了localStorage。改成每次进入页面重新拉取后问题消失。第三个坑压测发现加worker数量后性能不升反降。排查之后发现瓶颈根本不在CPU而是数据库连接池。8个worker同时去连数据库把连接池一瞬间占满后面所有请求都在排队等连接。调整连接池大小后QPS恢复正常。这个坑给我的启示是性能瓶颈要按“木桶理论”找哪块板最短补哪块。5.2 第11天改变了我的学习方法今天跟前面10天最大的区别是学习模式从“照着写”变成了“想办法验证”。每做一个优化我都要问三个问题改完效果能测量吗测量结果能对比吗出问题能追溯到原因吗这三个问题都能回答“能”再动手改。性能调优尤其能体现这个思路。你很难直接说“这个项目性能好”只能说“在什么负债、什么并发下响应时间是多少、QPS是多少”。没有压测数据调优就是盲人摸象。有了数据基线每一次改动前后对比都有依据心里是踏实的。5.3 对后续几天学习的启发第11天的安全、性能、日志其实构成了一个后端应用上线的“质检三件套”。第12天我会做部署上线和进程守护第13天写单元测试和接口测试第14天整理工程化结构第15天做整体复盘。把今天这三块底子打牢了后几天会更顺手。尤其是日志体系搭好之后后面测试和部署阶段观察运行状态会轻松很多。我个人在今天的实操中体会最深的一点是框架默认配置其实已经考虑了很多边界情况比如CSRF默认开、worker数量按CPU核数定、日志自动切分新手刚接触时容易为了“显得懂行”去乱改配置结果反而把默认保护改没了。真正该做的不是增加配置而是理解每一项默认设计在防什么问题按需调整而不是凭感觉操控。这个思路放之任何技术栈都适用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑