14. Sequelize 错误、数据库约束与数据竞争
数据库操作失败时,最危险的做法是把所有错误显示成“保存失败”,或者把 error.message 原样返回客户端。本章建立清晰边界:请求校验改善体验,Service 表达业务规则,数据库约束守住数据底线,错误中间件决定什么可以公开。
1. 四层校验各自负责什么
| 层级 | 例子 | 价值 | 不能替代什么 |
|---|---|---|---|
| 请求参数校验 | username 长度、email 格式 | 快速给出友好反馈 | 数据库约束 |
| Service 业务校验 | 只能在管理员表为空时创建初始管理员 | 表达跨字段/跨记录规则 | 并发控制 |
| Sequelize validate | isIn、len |
Model 写入前的辅助检查 | 其他客户端的写入保护 |
| MySQL 约束 | NOT NULL、UNIQUE、FK、CHECK | 所有写入入口的数据底线 | 友好的业务说明 |
同一规则出现在多层不一定重复。邮箱在路由层校验格式,在数据库层建立唯一索引,两者解决的问题不同。
2. 为什么“先查再插”不能保证唯一
const existing = await AdminUser.findOne({ where: { username } });
if (existing !== null) {
throw new AppError(ErrorCodes.CONFLICT, "用户名已被注册");
}
await AdminUser.create(input);
并发时可能发生:
请求 A:SELECT,没有记录
请求 B:SELECT,没有记录
请求 A:INSERT 成功
请求 B:INSERT
若数据库没有唯一索引,两个请求都会成功。正确底线是:
UNIQUE KEY uk_admin_user_username (username)
先查可以保留,用于更早返回友好信息;但数据库唯一约束才是并发下的最终裁判。
3. 常见 Sequelize 错误类型
import {
ConnectionError,
ForeignKeyConstraintError,
UniqueConstraintError,
ValidationError,
} from "sequelize";
ValidationError
Model 的 allowNull 或 validate 未通过时可能抛出。它适合定位模型输入问题,但不要无条件公开所有 message,因为自定义 validator 或库版本可能产生内部信息。
if (error instanceof ValidationError) {
for (const item of error.errors) {
console.error(item.path, item.validatorKey, item.message);
}
}
UniqueConstraintError
MySQL 唯一索引冲突会被 Sequelize 包装为这个类型。捕获后仍要识别是 username、email,还是未来新增的内部约束。
ForeignKeyConstraintError
插入不存在的外键、删除仍被引用的主记录等情况会出现。是否转换成业务错误,要看约束的业务含义,而不是看到这个类名就一律公开。
连接和数据库错误
连接失败、SQL 语法错误、死锁、锁等待超时通常属于内部错误。它们应记录完整上下文,对客户端返回通用服务器错误,不能公开主机、SQL 或驱动消息。
4. 精确识别唯一约束
当前 admin_user 的 username 和 email 都唯一。更好的处理是根据字段或稳定约束名做白名单映射:
function mapAdminUniqueError(error: UniqueConstraintError): AppError | null {
const fields = Object.keys(error.fields ?? {});
if (fields.includes("username")) {
return new AppError(ErrorCodes.CONFLICT, "用户名已被注册");
}
if (fields.includes("email")) {
return new AppError(ErrorCodes.CONFLICT, "邮箱已被注册");
}
return null;
}
try {
return await AdminUser.create(input);
} catch (error: unknown) {
if (error instanceof UniqueConstraintError) {
const app_error = mapAdminUniqueError(error);
if (app_error !== null) throw app_error;
}
throw error;
}
未知映射返回 null 很重要:未来可能新增内部唯一约束,无法确认消息安全时,不应把陌生错误伪装成熟悉的业务错误。
error.fields 的形态可能受 dialect/驱动版本影响。生产项目还常根据数据库约束名映射,因此 Migration 中约束名应稳定、明确,并用当前 mysql2 + MySQL 8 集成测试锁定真实错误结构。
5. 审计表为什么使用可空外键
综合案例采用:
admin_user_id BIGINT UNSIGNED NULL,
attempted_username VARCHAR(50) NOT NULL,
CONSTRAINT fk_admin_login_audit_admin
FOREIGN KEY (admin_user_id) REFERENCES admin_user (id)
ON DELETE SET NULL
登录失败时用户名可能根本不存在,所以 admin_user_id 合理地为 NULL;管理员后来删除时,SET NULL 保留历史审计。attempted_username 是当时输入的快照,不能只依赖会变化或消失的管理员记录。
这也说明 NULL 有明确业务语义:“本次尝试没有可引用的管理员”,不是偷懒不填。
6. 数据库错误怎样进入当前工程错误机制
AdminUser.create() rejected
→ Service 的 async 函数 rejected
→ Controller 的 async 函数 rejected
→ Express 5 进入错误处理中间件
→ 已映射 AppError 返回安全业务消息
→ 未知 Sequelize/Error 被记录,对外返回通用内部错误
Service 只捕获它能处理或补充上下文的错误。无法处理时继续 throw error,不要吞掉。
7. 事务中的错误与回滚
托管事务 callback 抛错会自动回滚:
await db.transaction(async (transaction) => {
await AdminUser.update(data, { where, transaction });
await AdminLoginAudit.create(audit, { transaction });
});
危险写法:
await db.transaction(async (transaction) => {
try {
await AdminLoginAudit.create(audit, { transaction });
} catch (error: unknown) {
console.error(error);
// 错误被吞掉,callback 正常结束,事务会提交!
}
});
如果只想记录后再回滚,必须重新抛出。
8. 死锁和锁等待超时
InnoDB 发现循环等待时会主动回滚其中一个事务。实践原则:
- 日志记录错误代码、事务场景和 request ID;
- 事务尽量短,多行更新保持一致锁顺序;
- 只有整个操作可安全重试时才有限次数重试;
- 重试带退避,且事务不能包含邮件、HTTP 等不可回滚副作用。
不要通过字符串匹配 message 判断死锁,应检查 dialect 暴露的错误码,并用当前 mysql2 + MySQL 8 做集成测试。
9. 日志需要什么,不应有什么
未知数据库错误建议记录事件名、request ID、操作名称、安全的实体 ID、错误对象、耗时和重试次数。不要记录明文密码、password_hash、JWT、Secret、完整请求体或含敏感参数的完整 SQL。
AppError 代表预期业务失败,通常不必每次作为 error 日志;重复密码失败可进入专门安全审计或指标,避免普通错误日志被淹没。
10. 类型检查通过,数据仍可能失败
TypeScript 只能说明值的形状正确,不知道数据库里是否已有相同邮箱,也不知道某个外键在执行瞬间是否存在。数据库约束验证的是整个持久化状态。
11. 初学者高频错误
- 只做
findOne()检查唯一,不建唯一索引。 - 所有
UniqueConstraintError都映射成同一字段文案。 - 将
error.message、SQL 或数据库地址原样返回客户端。 - 捕获错误后不重新抛出,导致事务提交。
- 把任何 Sequelize 错误都包装成可公开的
AppError。 - 依赖 TypeScript 保证唯一性和外键完整性。
- 对死锁无限重试,或重试包含外部副作用的事务。
12. 一个实用的错误决策流程
处理一次数据库异常时,可以按顺序提问:
我能明确识别错误类型和具体约束吗?
├─ 否 → 保留原错误,交给统一错误机制记录
└─ 是
↓
这是用户可以修正的预期冲突吗?
├─ 否 → 作为内部错误记录
└─ 是
↓
准备返回的 code/message 是否稳定且确认安全?
├─ 否 → 返回通用错误
└─ 是 → 映射为 AppError
例如 uk_admin_user_username 冲突满足全部条件,可以映射成“用户名已被注册”。数据库连接失败虽能识别,却不是用户能修正的业务冲突,不能把驱动消息包装成公开文案。
为底层错误增加上下文
若需要说明“在哪个操作失败”,可保留普通 Error 和 cause:
try {
await AdminLoginAudit.create(input);
} catch (error: unknown) {
if (error instanceof UniqueConstraintError) {
// 处理明确、公开安全的约束。
}
throw new Error("管理员登录审计写入失败", { cause: error });
}
这段消息用于内部诊断,不应因为更易读就自动公开。日志器还要配置为能序列化 cause;只记录 error.message 会丢失底层堆栈。
13. 动手练习
- 制造 username 与 email 冲突,在内部观察
fields、errors和底层错误代码。 - 给映射函数写集成测试,确认两个约束返回不同安全文案。
- 在托管事务中插入审计后故意抛错,确认记录未提交。
- 捕获但不重新抛出一次,观察记录为何提交,然后修复。
- 分别测试未知用户名审计和管理员删除后审计保留。
14. 复盘问题
- 为什么“先查再插”存在并发窗口?
- 哪些 Sequelize 错误可以映射成
AppError? - 为什么未知唯一约束不能随便公开?
- 托管事务中吞掉异常会有什么后果?
- 审计表为何选择
ON DELETE SET NULL并保留attempted_username?