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 发现循环等待时会主动回滚其中一个事务。实践原则:

不要通过字符串匹配 message 判断死锁,应检查 dialect 暴露的错误码,并用当前 mysql2 + MySQL 8 做集成测试。

9. 日志需要什么,不应有什么

未知数据库错误建议记录事件名、request ID、操作名称、安全的实体 ID、错误对象、耗时和重试次数。不要记录明文密码、password_hash、JWT、Secret、完整请求体或含敏感参数的完整 SQL。

AppError 代表预期业务失败,通常不必每次作为 error 日志;重复密码失败可进入专门安全审计或指标,避免普通错误日志被淹没。

10. 类型检查通过,数据仍可能失败

TypeScript 只能说明值的形状正确,不知道数据库里是否已有相同邮箱,也不知道某个外键在执行瞬间是否存在。数据库约束验证的是整个持久化状态。

11. 初学者高频错误

  1. 只做 findOne() 检查唯一,不建唯一索引。
  2. 所有 UniqueConstraintError 都映射成同一字段文案。
  3. 将 error.message、SQL 或数据库地址原样返回客户端。
  4. 捕获错误后不重新抛出,导致事务提交。
  5. 把任何 Sequelize 错误都包装成可公开的 AppError。
  6. 依赖 TypeScript 保证唯一性和外键完整性。
  7. 对死锁无限重试,或重试包含外部副作用的事务。

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. 动手练习

  1. 制造 username 与 email 冲突,在内部观察 fields、errors 和底层错误代码。
  2. 给映射函数写集成测试,确认两个约束返回不同安全文案。
  3. 在托管事务中插入审计后故意抛错,确认记录未提交。
  4. 捕获但不重新抛出一次,观察记录为何提交,然后修复。
  5. 分别测试未知用户名审计和管理员删除后审计保留。

14. 复盘问题