学习计划推进到第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 16+
- eggjs 3.x
- MySQL 8.0
- Redis 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 }}时,里面的标签会被转义成<script>这样的普通文本,浏览器只会把它当作字符串显示,不会执行。这个机制在框架内部处理,只要你别没事干用“显示原文”这类过滤器强行跳过转义,风险就是可控的。
但在纯接口场景下要格外小心。返回给前端的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或者热更新换来换去,可以把回显逻辑放开,直接返回请求方origin:
exports.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 性能调优三板斧:缓存、查询优化、连接复用
压测自己那个应用之后发现,响应慢的接口九成以上是直接把锅甩给了数据库,而且很多存在N+1查询问题。N+1长什么样?先查一个订单列表,然后对列表里的每一行再查一次用户信息,一页10条数据,实际执行了11条SQL。数据库不累才怪。
解决N+1的优先级是:先优化查询方式,再上缓存。能用一条连表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 压测工具的选择与结果解读
性能优化做没做成功,不能靠感觉,要拿数据说话。我用ab(ApacheBench)做压测,命令很简单:
ab -n 10000 -c 100 http://127.0.0.1:7001/api/hello-n是总请求数,-c是并发数。输出结果里重点看四个指标。
第一个是Requests per second,QPS,也就是每秒钟能处理多少请求。第二个是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核数定、日志自动切分,新手刚接触时容易为了“显得懂行”去乱改配置,结果反而把默认保护改没了。真正该做的不是增加配置,而是理解每一项默认设计在防什么问题,按需调整而不是凭感觉操控。这个思路放之任何技术栈都适用。