15 安全、性能与并发

接口“能返回正确结果”只是第一步。本章通过故障注入暴露新人常见问题:错误验证 JWT、无限制查询、N+1、重复计数、双重出租、金额精度和时间边界。

学习目标

1. 登录安全检查

密码

必须满足:

只保存 Argon2id hash
登录使用 argon2.verify(hash, plainPassword)
不记录明文密码和 hash
修改密码后 token_version 递增
密码规则在服务端校验

不要自制加盐逻辑。Argon2 编码后的摘要已经携带算法、参数和 salt 信息。

登录失败信息

用户不存在与密码错误统一返回:

INVALID_CREDENTIALS

否则攻击者可以批量确认哪些用户名存在。

限流

登录接口是高风险入口。设计限流策略时思考:

如果项目引入第三方限流中间件,应说明依赖用途,不要在本练习中手写一个只能单进程工作的 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=生产环境随机密钥

不得:

Token 撤销

验证 Token 后查库比较:

payload.tokenVersion === adminUser.tokenVersion

同时检查:

账号存在
账号启用
锁定状态符合规则

3. 前端存储 Token 的权衡

作为前端工程师,需要理解两种常见方式:

Authorization Bearer

Authorization: Bearer <token>

优点:API 和移动端使用直观。风险:如果放在 localStorage,XSS 可以读取。

httpOnly Cookie

JavaScript 不能直接读取,降低 Token 被脚本窃取的风险,但需要考虑:

本练习第一阶段使用 Bearer,不代表它在所有场景都优于 Cookie。

4. 不要无限制 findAll()

危险写法:

const rentals = await Rental.findAll();

问题:

要求列表接口具备:

pageSize 默认值
pageSize 最大值
明确 attributes
稳定 ORDER BY
必要 WHERE 条件

5. N+1 查询实验

故意实现错误版本:

查询 20 部电影            → 1 条 SQL
循环查询每部电影的演员    → 20 条 SQL
总计                       → 21 条 SQL

打开 Sequelize logging,记录一次请求的 SQL 数量,再使用:

改写。

不要把“所有关系一次性巨大 JOIN”当成唯一修复方式,它可能产生乘法膨胀。

6. findAndCountAll 重复计数

客户列表 include 一对多租赁后,可能出现:

rows 是客户列表
count 却是 JOIN 后的租赁行数

练习比较:

findAndCountAll({ include })
findAndCountAll({ include, distinct: true })

还要理解:

7. 深分页与稳定排序

错误分页:

LIMIT 100000, 20

数据库可能扫描并丢弃大量记录。

第一阶段先正确实现 offset 分页,进阶再设计游标分页。例如:

ORDER BY rental_date DESC, rental_id DESC

游标必须同时携带:

rentalDate
rentalId

只携带时间会在相同时间值下产生重复或遗漏。

8. 并发租赁故障

两个管理员同时出租同一库存:

请求 A 查询:可用
请求 B 查询:可用
请求 A 插入 rental
请求 B 也插入 rental

单纯的:

先 SELECT
再 INSERT

不能消除并发窗口。

练习需要分析:

不要只在 Node.js 内使用全局变量锁:PM2 多进程或多台服务器时它不会共享。

9. 幂等归还

连续两次调用:

PATCH /api/sakila/rentals/123/return

第二次应该:

两种策略都可以,但接口契约必须明确。不能让第二次请求悄悄覆盖第一次 return_date

10. 重复创建和幂等键

客户端因网络超时重试“租赁并支付”时,可能产生两次写入。进阶题设计:

Idempotency-Key: <客户端生成的唯一值>

思考:

本题只要求设计,不要求第一版立即新增通用框架。

11. DECIMAL 与金额

Sakila payment.amount 是 DECIMAL。Sequelize 为避免精度丢失,通常把 DECIMAL 返回为字符串:

interface PaymentDto {
  amount: string;
}

不要无条件:

Number(payment.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

要求:

14. EXPLAIN 练习

对以下接口的核心 SQL 执行:

EXPLAIN
SELECT ...;

至少记录:

type
possible_keys
key
rows
Extra

建议场景:

  1. 时间范围支付查询;
  2. Actor 姓名前缀查询;
  3. 可出租库存查询;
  4. 客户消费排行;
  5. 深分页。

不要看到 Using filesort 就机械添加索引。先确认查询模式、数据量、过滤选择性和写入成本。

15. 内存观察

不要用一次请求前后的 PM2 RSS 直接判断泄漏。记录:

启动基线
第一次请求后
连续 20 次请求后
空闲 5 分钟后

区分:

16. 故障注入清单

[ ] 错误 JWT 签名
[ ] 过期 JWT
[ ] 已禁用管理员使用旧 JWT
[ ] pageSize=1000000
[ ] 两个请求同时租同一库存
[ ] 同一归还接口调用两次
[ ] MySQL 查询中途失败
[ ] 事务内第二次写入失败
[ ] Raw SQL 注入字符串
[ ] 金额包含小数
[ ] 日期跨月、跨年和时区边界

17. 本章练习题

  1. 为什么 jwt.decode() 不能用于鉴权?
  2. 为什么验证 JWT 后仍要查询 admin_user
  3. 为什么 PM2 cluster 下内存 Map 不能作为可靠的全局锁和限流器?
  4. findAndCountAll 在一对多 include 下为什么可能 count 过大?
  5. 为什么“先查可用,再创建租赁”存在竞态?
  6. 为什么金额 DTO 推荐使用 string?
  7. 为什么游标分页需要稳定的第二排序字段?
  8. HTTP 始终为 200 时,监控如何发现业务错误?

完成标准

[ ] JWT 使用 verify 并限制算法、issuer、audience
[ ] 登录接口有失败策略和限流设计
[ ] 所有列表有上限和稳定排序
[ ] 能发现并修复一个 N+1
[ ] 能解释 count 重复原因
[ ] 租赁写入考虑事务和并发
[ ] 金额与时间有统一策略
[ ] Raw SQL 不拼接用户输入
[ ] 能用 EXPLAIN 和重复实验给出证据