事件 & 异步
一、Node 单线程 + 事件循环
1)Node 是单线程?但为什么能高并发?
要候选人答出这三点:
- JS 主线程只有 1 个(执行代码、处理回调)
- I/O 操作(网络、文件、数据库)不阻塞主线程,交给 libuv 线程池
- 结果回来后扔到事件队列,主线程空了再执行
一句话判断水平:
垃圾:"Node 多线程,所以快"
合格:"单线程 + 非阻塞 I/O + 事件循环"
优秀:区分 JS 主线程 和 libuv 工作线程
2)事件循环 6 个阶段(顺序固定)
Node 事件循环是分阶段、按顺序跑的,每一轮循环叫一个 tick。
顺序:
timers:setTimeout、setInterval
pending callbacks:上一轮没执行完的系统回调(如 TCP 错误)
idle/prepare:内部用
poll (最重要):
- 获取新 I/O 事件
- 会阻塞等待直到有事件
check:setImmediate
close callbacks:close 事件(socket.on ('close'))
二、宏任务 / 微任务(Node 与浏览器不一样)
1)执行优先级(铁律)
每一个阶段执行前 / 后,都会清空微任务队列!
顺序:
- 同步代码
- 所有微任务(全部清空)
- 宏任务(取一个执行)
- 再清空微任务
- 进入下一个阶段
2)宏任务、微任务分别有哪些
微任务(Microtasks)
- Promise.then/catch/finally
- process.nextTick(比 Promise 还早!)
- async/await 本质就是 Promise + 微任务
宏任务(Macrotasks)
- setTimeout / setInterval
- setImmediate
- I/O(文件、网络、数据库)
- DOM 事件(浏览器)
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(()=>console.log('setTimeout'),0)
async1()
new Promise(resolve=>{
console.log('Promise1')
resolve()
}).then(()=>console.log('Promise2'))
console.log('script end')
正确顺序(Node & 浏览器一致):
script start
async1 start
async2
Promise1
script end
async1 end
Promise2
setTimeout
能答对 = 异步基础扎实。
三、回调 / Promise /async/await 实际项目怎么选型?
1)回调(callback)
- 现在只用于老库、底层 API
- 绝对禁止业务代码嵌套 → 回调地狱
- 缺点:异常难捕获、嵌套深、控制流混乱
2)Promise
- 解决回调地狱
- 适合:并行、并发、多个异步组合
.then链式清晰3)async/await(现在标准写法)
- 业务代码 99% 用这个
- 同步写法,可读性最强
- 可以
try/catch捕获异常面试官标准答案:
- 业务逻辑:async/await 优先
- 并发控制:Promise.all / race / allSettled
- 老库兼容:包装成 Promise 再用 async/await
四、Promise.all/race /allSettled 场景 & 坑
1)Promise.all
- 所有成功才成功,一个失败直接整体失败
- 场景:
- 同时请求多个接口,必须全部成功才继续
- 批量查询、批量初始化
- **大坑:**一个失败,全部失败,无法拿到成功部分结果。
all 中一个失败,其他请求还在跑吗?→ 还在跑,只是不会返回结果
all 并发量太大,把服务打挂→ 必须做限流、分批
2)Promise.race
- 谁快返回谁,不管成功失败
- 场景:
- 请求超时控制
Promise.race([ fetch(url), new Promise((_,reject)=>setTimeout(()=>reject('timeout'),5000)) ])
3)Promise.allSettled(ES2020)
- 全部执行完,不管成功失败
- 返回数组:
{ status: 'fulfilled/rejected', value/reason }- 场景:
- 批量上报、批量任务,不关心个别失败
五、你面试时可以直接问的 5 个灵魂问题
- Node 事件循环 6 个阶段顺序?
- 微任务和宏任务执行顺序?process.nextTick 优先级?
- setTimeout 0 和 setImmediate 谁先?为什么不确定?
- 业务里你用 callback / Promise /async/await 怎么选?
- Promise.all、race、allSettled 区别与实际场景?
内存 & 性能 & 安全
一、内存 & 性能
1)常见内存泄漏场景 + 怎么排查
(1)常见泄漏点(候选人必须能说出)
全局变量滥用
- 意外挂载到
global/window、this指向错乱- 引用一直存在,GC 无法回收
闭包持有意外引用
- 闭包引用了大对象、request、response、数据库连接
- 函数退出了,但引用还在
定时器 / 异步任务不清理
setInterval、setTimeout没clear- 里面持有大引用,整个作用域无法回收
事件监听不销毁
EventEmitter、socket、stream持续on不off- 每次请求加监听,越积越多
缓存不设上限 / 不淘汰
- 简单对象缓存无限增长
- 未用 LRU、TTL
数据库连接 / 文件句柄未释放
- 每次请求新建连接,不释放
- 造成内存 + 句柄双泄漏
(2)怎么排查(面试官必问)
标准流程:
- 复现:压测 / 流量上涨 → 内存持续涨不回落
- 抓堆快照
--expose-gcchrome://inspect调试- 取 2~3 次堆快照对比
- 看几点
- 对象数量持续增长
- 哪个构造器 / 闭包持有引用
- GC 无法回收的路径
- 工具
Chrome DevTools Memory
clinic.js
memwatch-next
process.memoryUsage()合格回答:知道抓快照、看引用链、定位闭包 / 全局 / 定时器。
2)Stream 是什么?大文件为什么必须用流?
(1)Stream 本质
- 把数据切成一小块一小块(chunk) 流动处理
- 不一次性加载到内存
- 四种流:
- Readable 可读
- Writable 可写
- Duplex 双工
- Transform 转换(压缩、加密)
(2)为什么大文件要用 Stream?
- 不用流:
fs.readFile会把整个文件丢进内存
- 文件 1GB → 直接爆内存、OOM
- 用流:边读边处理边发,内存只占一个 chunk 大小
- 适用:
- 大文件上传 / 下载
- 日志切割
- 视频 / 音频处理
- 接口大数据导出
(3)经典坑
- 背压问题(backpressure) 读得快、写得慢 → 内存积压解决:
pipe()自动处理背压,不要自己手动on('data')3)Node 常见安全漏洞 & 防御
(1)XSS
- 场景:用户输入直接渲染到页面
- 防御:
- 对输出转义(&<>"')
- 使用模板引擎自带转义(ejs/nunjucks 等)
- 接口返回 JSON 不直接拼接 HTML
(2)SQL 注入
- 场景:直接拼接 SQL
- 防御:
- 禁止字符串拼接 SQL
- 使用 参数化查询 / 预处理语句
- ORM(Sequelize、Mongoose、Prisma)
(3)参数篡改 & 越权
- 场景:前端传 id=1,后端直接用,导致查别人数据
- 防御:
- 所有接口必须鉴权
- 数据权限:只能操作
userId = 当前登录ID- 关键参数(userId、role、admin)不从前端传,从 Token / 会话里取
(4)接口鉴权绕过
- 防御:
- JWT 校验过期、签名
- 中间件统一鉴权,不要每个接口手写
- 敏感接口(删除、修改)加二次验证
(5)其他
- 目录遍历:
../穿透 → 规范化路径、限制访问目录- 拒绝服务:大 body 攻击 → 限制 body 大小
- 敏感信息:不要把栈错误返回前端
二、cluster 多进程 & PM2 优化 CPU
1)为什么要用多进程?
- Node JS 主线程单线程,只能跑满 1 核
- 服务器多核 CPU 浪费
- 用 cluster 启动多实例 = 利用全部核心
2)cluster 原理
- master 主进程:管理、负载均衡
- worker 子进程:真正执行业务
- 端口可以共享(内核转发)
3)PM2 怎么配置
标准用法:
bash
运行
pm2 start app.js -i max
-i max:自动使用 CPU 全部核心-i 2:指定 2 个进程4)优化要点
- 进程数 = CPU 核心数(不要超,否则上下文切换损耗)
- 不要在 master 做业务,只做调度
- 进程间不要共享内存,用 Redis 共享状态
- PM2 开启:
- 日志切割
- 内存超限自动重启
- 开机自启
5)误区
- 开过多进程 = 更慢
- 单进程里开多线程意义不大(CPU 密集才需要)
你作为面试官可以直接问的 8 个问题(非常准)
- 你在项目中遇到过内存泄漏吗?哪些场景最容易漏?
- 内存泄漏排查步骤是什么?用什么工具?
- 大文件上传为什么不能用 readFile?Stream 好在哪?
- 什么是背压?怎么解决?
- SQL 注入怎么防?你写 SQL 用拼接还是参数化?
- 接口越权怎么发生?怎么避免?
- 为什么 Node 要开多进程?单进程为什么用不满多核?
- PM2 启动参数 -i max 是什么意思?
三、网络 & 接口(高频必问)
1)HTTP 1.1 / HTTP 2 区别 + Keep‑Alive
Keep-Alive 作用(HTTP 1.1 默认开启)
- 复用 TCP 连接,不用每次请求重新握手
- 减少 TCP 三次握手、四次挥手开销
- 降低延迟,提升并发性能
面试官追问: 没有 Keep‑Alive 会怎样?→ 每个请求都新建连接,大量 TIME_WAIT,服务器压力大,速度慢。
HTTP 1.1 缺点
- 串行请求(队头阻塞)
- 同一域名最多 6~8 个连接
- 纯文本,体积大
- 无优先级
HTTP 2 优点(必须记住 4 点)
- 多路复用:一个连接并行多个请求 - 响应,解决队头阻塞
- 二进制分帧:不再是文本协议,解析更快
- 头部压缩 HPACK:减少体积
- 流优先级:重要资源优先加载
**一句话区分:**1.1:多个 TCP 连接,串行排队2:一个 TCP 连接,并行乱序传输
2)跨域 CORS 底层原理 + Node 配置
什么是跨域?
协议、域名、端口任意一个不同,浏览器就拦截。
CORS 底层(面试官最爱问)
- 浏览器先判断是否跨域
- 简单请求 :直接发,带
Origin- 非简单请求 (PUT/DELETE/Content-Type: application/json、自定义头):→ 先发 OPTIONS 预检请求→ 服务器返回允许的源、头、方法→ 浏览器通过才发真实请求
服务器关键响应头
Access-Control-Allow-Origin: *或指定域名
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Credentials: true(允许带 cookie)Node 端怎么配(Koa/Express 通用思路)
1)手写中间件(最能体现水平)
js
app.use(async (ctx, next) => { ctx.set('Access-Control-Allow-Origin', ctx.header.origin || '*') ctx.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS') ctx.set('Access-Control-Allow-Headers', 'Content-Type,Authorization') ctx.set('Access-Control-Allow-Credentials', 'true') if (ctx.method === 'OPTIONS') { ctx.status = 204 return } await next() })2)用库
Express:
corsKoa:@koa/cors高频坑
带 Cookie 时,
Allow-Origin不能为*,必须具体域名没处理 OPTIONS,预检直接报错 404
3)JWT vs Session 鉴权区别 + 过期刷新
1)Session(基于 Cookie)
- 存在 服务器内存 / Redis
- 客户端只存
sessionId在 Cookie- 优点:安全、可主动踢人、修改密码立即失效
- 缺点:服务器有状态、分布式需要共享存储(Redis)
2)JWT(JSON Web Token)
- 信息存在 客户端
- 服务器只用 验签,不用存状态
- 结构:Header.Payload.Signature
- 优点:无状态、易扩展、多域名 / 微服务友好
- 缺点:
- 无法主动作废(除非放黑名单)
- 不能存敏感信息(base64 可解码)
什么时候用 JWT?
- 前后端分离
- 微服务、多域名
- APP、小程序、第三方授权
- 不想依赖 Redis 共享 Session
3)JWT 过期 & 刷新方案(标准做法)
- Access Token:短有效期(15min~2h),每次请求带
- Refresh Token:长有效期(7d~30d),只用于刷新
流程:
- 登录 → 返回 accessToken + refreshToken
- accessToken 过期 → 前端 401 拦截
- 请求
/refresh接口,用 refreshToken 换新 accessToken- refreshToken 过期 → 重新登录
安全要点
- 密钥足够复杂
- Payload 不要放密码、敏感信息
- 开启
exp过期时间- 支持刷新但不永久有效
- 可在 Redis 存黑名单实现注销
面试官可直接问的 8 道题
- Keep‑Alive 解决什么问题?
- HTTP 1.1 队头阻塞是什么?HTTP2 怎么解决?
- 什么是预检 OPTIONS?什么时候触发?
- 带 Cookie 跨域要配置哪几个头?
- Session 和 JWT 本质区别是什么?
- JWT 存在客户端,怎么保证不被篡改?
- JWT 过期了怎么办?刷新机制怎么设计?
- JWT 怎么实现用户退出登录 / 强制下线?