15 安全、性能与并发
接口“能返回正确结果”只是第一步。本章通过故障注入暴露新人常见问题:错误验证 JWT、无限制查询、N+1、重复计数、双重出租、金额精度和时间边界。
学习目标
- 理解登录系统的主要攻击面;
- 区分
jwt.decode()与jwt.verify(); - 避免无限制
findAll()和 N+1; - 理解“先查询再写入”的并发窗口;
- 正确处理 DECIMAL、日期、事务和锁;
- 用重复实验而不是单次结果判断接口可靠性。
1. 登录安全检查
密码
必须满足:
只保存 Argon2id hash
登录使用 argon2.verify(hash, plainPassword)
不记录明文密码和 hash
修改密码后 token_version 递增
密码规则在服务端校验
不要自制加盐逻辑。Argon2 编码后的摘要已经携带算法、参数和 salt 信息。
登录失败信息
用户不存在与密码错误统一返回:
INVALID_CREDENTIALS
否则攻击者可以批量确认哪些用户名存在。
限流
登录接口是高风险入口。设计限流策略时思考:
- 按 IP、username 还是两者组合;
- Nginx 与 Express 限流如何分工;
- 代理环境下如何获取可信客户端 IP;
- 大量不同用户名是否能绕过单账号限制;
- 限流错误仍为 HTTP 200 时如何被日志和监控识别。
如果项目引入第三方限流中间件,应说明依赖用途,不要在本练习中手写一个只能单进程工作的 Map 就当成生产限流器。
2. JWT 安全检查
错误:只 decode
const payload = jwt.decode(token);
decode 只解析文本,不验证签名。攻击者可以自己构造 Payload。
练习必须使用:
jwt.verify(token, secret, {
// TODO:algorithms、issuer、audience
});
Payload 最小化
建议只包含:
sub
role
tokenVersion
iat / exp(jsonwebtoken 自动维护)
禁止放入:
password
passwordHash
数据库密码
完整管理员对象
敏感个人信息
JWT 通常只是 Base64URL 编码并签名,不是加密容器。
Secret
必须来自环境变量,并满足足够随机性:
JWT_ACCESS_SECRET=生产环境随机密钥
不得:
- 提交到 Git;
- 使用
secret、123456等示例值上线; - 在异常日志中打印;
- 开发、测试和生产共用同一个 Secret。
Token 撤销
验证 Token 后查库比较:
payload.tokenVersion === adminUser.tokenVersion
同时检查:
账号存在
账号启用
锁定状态符合规则
3. 前端存储 Token 的权衡
作为前端工程师,需要理解两种常见方式:
Authorization Bearer
Authorization: Bearer <token>
优点:API 和移动端使用直观。风险:如果放在 localStorage,XSS 可以读取。
httpOnly Cookie
JavaScript 不能直接读取,降低 Token 被脚本窃取的风险,但需要考虑:
- CSRF;
- SameSite;
- Secure;
- CORS credentials;
- Cookie 域和路径。
本练习第一阶段使用 Bearer,不代表它在所有场景都优于 Cookie。
4. 不要无限制 findAll()
危险写法:
const rentals = await Rental.findAll();
问题:
- 数据会持续增长;
- Sequelize 创建大量 Model Instance;
- JSON 序列化占用额外内存;
- 单个慢请求影响其他请求;
- 响应体可能非常大。
要求列表接口具备:
pageSize 默认值
pageSize 最大值
明确 attributes
稳定 ORDER BY
必要 WHERE 条件
5. N+1 查询实验
故意实现错误版本:
查询 20 部电影 → 1 条 SQL
循环查询每部电影的演员 → 20 条 SQL
总计 → 21 条 SQL
打开 Sequelize logging,记录一次请求的 SQL 数量,再使用:
- Association include;
- 或一次批量
WHERE film_id IN (...);
改写。
不要把“所有关系一次性巨大 JOIN”当成唯一修复方式,它可能产生乘法膨胀。
6. findAndCountAll 重复计数
客户列表 include 一对多租赁后,可能出现:
rows 是客户列表
count 却是 JOIN 后的租赁行数
练习比较:
findAndCountAll({ include })
findAndCountAll({ include, distinct: true })
还要理解:
distinct针对哪个主键;- required include 如何影响 count;
- 聚合查询是否应把 list SQL 和 count SQL 分开;
- total 表示客户数还是租赁记录数。
7. 深分页与稳定排序
错误分页:
LIMIT 100000, 20
数据库可能扫描并丢弃大量记录。
第一阶段先正确实现 offset 分页,进阶再设计游标分页。例如:
ORDER BY rental_date DESC, rental_id DESC
游标必须同时携带:
rentalDate
rentalId
只携带时间会在相同时间值下产生重复或遗漏。
8. 并发租赁故障
两个管理员同时出租同一库存:
请求 A 查询:可用
请求 B 查询:可用
请求 A 插入 rental
请求 B 也插入 rental
单纯的:
先 SELECT
再 INSERT
不能消除并发窗口。
练习需要分析:
- 事务隔离级别;
- 对 inventory 行加锁;
- 查询当前未归还 rental;
- 锁的读取和写入是否使用同一 transaction;
- 事务持有时间;
- 数据库是否存在能够表达“每个库存最多一条未归还记录”的约束。
不要只在 Node.js 内使用全局变量锁:PM2 多进程或多台服务器时它不会共享。
9. 幂等归还
连续两次调用:
PATCH /api/sakila/rentals/123/return
第二次应该:
- 返回第一次的成功结果;
- 还是返回
RENTAL_ALREADY_RETURNED?
两种策略都可以,但接口契约必须明确。不能让第二次请求悄悄覆盖第一次 return_date。
10. 重复创建和幂等键
客户端因网络超时重试“租赁并支付”时,可能产生两次写入。进阶题设计:
Idempotency-Key: <客户端生成的唯一值>
思考:
- 幂等键保存在哪里;
- 唯一约束如何设置;
- 相同 Key、不同 Body 怎么处理;
- 幂等记录和业务事务是否一致提交。
本题只要求设计,不要求第一版立即新增通用框架。
11. DECIMAL 与金额
Sakila payment.amount 是 DECIMAL。Sequelize 为避免精度丢失,通常把 DECIMAL 返回为字符串:
interface PaymentDto {
amount: string;
}
不要无条件:
Number(payment.amount)
然后使用浮点数累计财务金额。练习需要明确:
- API 是否始终返回金额字符串;
- 显示格式由前端还是后端负责;
- 汇总尽量交给 MySQL DECIMAL;
- Raw Query 的
SUM(amount)返回类型。
12. 时间与时区
需要统一:
MySQL 保存时区策略
Sequelize timezone 配置
Node.js Date
API ISO 8601 输出
日志时间
日期范围使用左闭右开:
payment_date >= :startAt
AND payment_date < :endAt
不要使用:
DATE(payment_date) = :date
作为高频索引过滤条件,也不要把“2026-01-01 到 2026-01-02”直接解释成两个午夜之间而遗漏第二天。
13. Raw SQL 注入测试
对报表参数尝试:
storeId=1 OR 1=1
sort=payment_date DESC; DROP TABLE payment
要求:
- 值使用 replacements 或 bind;
- 排序字段使用代码白名单映射;
- 不直接拼接用户输入;
- LIMIT 先转换为有范围的整数;
- 数据库账号遵循最小权限。
14. EXPLAIN 练习
对以下接口的核心 SQL 执行:
EXPLAIN
SELECT ...;
至少记录:
type
possible_keys
key
rows
Extra
建议场景:
- 时间范围支付查询;
- Actor 姓名前缀查询;
- 可出租库存查询;
- 客户消费排行;
- 深分页。
不要看到 Using filesort 就机械添加索引。先确认查询模式、数据量、过滤选择性和写入成本。
15. 内存观察
不要用一次请求前后的 PM2 RSS 直接判断泄漏。记录:
启动基线
第一次请求后
连续 20 次请求后
空闲 5 分钟后
区分:
- V8 预热与堆扩容;
- JavaScript heapUsed;
- Buffer/external;
- RSS;
- 未关闭 Stream/事务;
- 文件未删除造成的磁盘泄漏。
16. 故障注入清单
[ ] 错误 JWT 签名
[ ] 过期 JWT
[ ] 已禁用管理员使用旧 JWT
[ ] pageSize=1000000
[ ] 两个请求同时租同一库存
[ ] 同一归还接口调用两次
[ ] MySQL 查询中途失败
[ ] 事务内第二次写入失败
[ ] Raw SQL 注入字符串
[ ] 金额包含小数
[ ] 日期跨月、跨年和时区边界
17. 本章练习题
- 为什么
jwt.decode()不能用于鉴权? - 为什么验证 JWT 后仍要查询
admin_user? - 为什么 PM2 cluster 下内存 Map 不能作为可靠的全局锁和限流器?
findAndCountAll在一对多 include 下为什么可能 count 过大?- 为什么“先查可用,再创建租赁”存在竞态?
- 为什么金额 DTO 推荐使用 string?
- 为什么游标分页需要稳定的第二排序字段?
- HTTP 始终为 200 时,监控如何发现业务错误?
完成标准
[ ] JWT 使用 verify 并限制算法、issuer、audience
[ ] 登录接口有失败策略和限流设计
[ ] 所有列表有上限和稳定排序
[ ] 能发现并修复一个 N+1
[ ] 能解释 count 重复原因
[ ] 租赁写入考虑事务和并发
[ ] 金额与时间有统一策略
[ ] Raw SQL 不拼接用户输入
[ ] 能用 EXPLAIN 和重复实验给出证据