[{"data":1,"prerenderedAt":67},["ShallowReactive",2],{"article-141":3},{"code":4,"msg":5,"data":6,"count":14},200,"查询成功",{"id":7,"title":8,"keywords":9,"description":10,"category_id":11,"content":12,"body_html":13,"thumb_up":14,"clicks":15,"sort":14,"remark":13,"status":11,"is_open":11,"is_deleted":14,"is_top":11,"is_recommend":11,"create_time":16,"update_time":16,"image_id":17,"url":13,"member_id":14,"cate_name":18,"prev":19,"next":22,"tags":25,"words":38,"read_time":39,"comments":14,"cover":40,"relevant":41},141,"软删除场景下的数据库唯一约束实现方案","mysql,数据库","在业务开发中，软删除（Soft Delete）是一种常见的数据保留策略。当记录被\"删除\"时，我们通常不会真正从数据库中移除，而是通过 `deleted_at` 字段标记删除时间。",1,"## 背景\n\n在业务开发中，软删除（Soft Delete）是一种常见的数据保留策略。当记录被\"删除\"时，我们通常不会真正从数据库中移除，而是通过 `deleted_at` 字段标记删除时间。\n\n一个典型的业务需求是：**未删除的记录之间保持唯一约束，而已删除的记录允许重复**。\n\n例如，同一个邮箱可以出现在多条已删除记录中，但在所有未删除记录中必须唯一。\n\n```sql\nCREATE TABLE users (\n    id BIGINT PRIMARY KEY AUTO_INCREMENT,\n    email VARCHAR(255) NOT NULL,\n    deleted_at DATETIME NULL,      -- NULL 表示未删除\n    ...\n);\n```\n\n---\n\n## 一、PostgreSQL 方案：部分唯一索引\n\nPostgreSQL 原生支持**部分唯一索引**（Partial Unique Index），这是最优雅的解决方案。\n\n### 基础用法\n\n```sql\nCREATE UNIQUE INDEX uq_users_email_active\n    ON users (email)\n    WHERE deleted_at IS NULL;\n```\n\n索引只包含未删除的行，未删除记录的 email 唯一，已删除记录则不受约束限制。\n\n### 多字段场景\n\n```sql\n-- 按租户隔离的单字段唯一\nCREATE UNIQUE INDEX uq_users_tenant_email_active\n    ON users (tenant_id, email)\n    WHERE deleted_at IS NULL;\n\n-- 多字段组合唯一（用户名+手机号仅在租户内活跃记录唯一）\nCREATE UNIQUE INDEX uq_users_tenant_username_phone_active\n    ON users (tenant_id, username, phone)\n    WHERE deleted_at IS NULL;\n```\n\n**优点**：声明式语法、无冗余列、性能最优、索引体积最小。\n\n---\n\n## 二、MySQL 方案\n\nMySQL 本身不支持 `WHERE` 条件的部分索引，但我们可以利用**唯一索引对 `NULL` 值不强制唯一**这一特性。\n\n> 在 UNIQUE 索引中，`NULL` 被认为不等于任何值，包括另一个 `NULL`，因此允许多个 `NULL` 共存。\n\n核心思路是：**将未删除记录的索引键映射为真实业务值，将已删除记录的索引键映射为 `NULL`**。\n\n### 2.1 MySQL 8.0.13+：函数索引\n\n这是最接近 PostgreSQL 语义的方案。\n\n```sql\n-- 单字段唯一\nCREATE UNIQUE INDEX uq_users_email_active\n    ON users ((IF(deleted_at IS NULL, email, NULL)));\n\n-- 或使用 CASE 写法\nCREATE UNIQUE INDEX uq_users_email_active\n    ON users ((CASE WHEN deleted_at IS NULL THEN email END));\n```\n\n多字段组合：\n\n```sql\nCREATE UNIQUE INDEX uq_users_tenant_email_active\n    ON users (tenant_id, (IF(deleted_at IS NULL, email, NULL)));\n```\n\n**工作原理**：当 `deleted_at IS NULL` 时返回 `email`，否则返回 `NULL`。未删除记录的索引键有具体的 `email` 值，受唯一约束保护；已删除记录的索引键全是 `NULL`，可以重复。\n\n> ⚠️ 注意事项：表达式索引在 MySQL 中采用虚拟实现，不额外占用存储。但查询时必须与原表达式完全一致才能使用该索引。\n\n### 2.2 MySQL 5.7：虚拟生成列 + 唯一索引\n\nMySQL 5.7 支持虚拟生成列（VIRTUAL），可以在其上建立唯一索引。\n\n```sql\nALTER TABLE users ADD COLUMN email_active VARCHAR(255)\n    GENERATED ALWAYS AS (IF(deleted_at IS NULL, email, NULL)) VIRTUAL;\n\nCREATE UNIQUE INDEX uq_users_email_active ON users (email_active);\n```\n\n多租户场景需要综合多个字段：\n\n```sql\nALTER TABLE users ADD COLUMN tenant_email_active VARCHAR(512)\n    GENERATED ALWAYS AS (\n        IF(deleted_at IS NULL, CONCAT(tenant_id, ':', email), NULL)\n    ) VIRTUAL;\n\nCREATE UNIQUE INDEX uq_users_tenant_email_active ON users (tenant_email_active);\n```\n\n**优点**：方案稳定可靠，索引与普通列无异，是 MySQL 5.7 的最佳实践。\n\n> ⚠️ 注意：虚拟列不占用存储空间，但 `VIRTUAL` 列不能作为主键的一部分，也不能用于外键（唯一索引本身通常不需要作为外键目标，问题不大）。如果使用 `STORED` 模式则会占用额外磁盘空间。\n\n### 2.3 MySQL 5.6 及更早版本：魔法默认值\n\n旧版本不支持生成列或函数索引，只能利用**唯一组合索引允许重复 NULL**的特性，将 `NULL` 替换为一个非 NULL 的默认值。\n\n```sql\nALTER TABLE users\n    ADD COLUMN deleted_at DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';\n\nCREATE UNIQUE INDEX uq_users_email_undeleted\n    ON users (email, deleted_at);\n```\n\n**局限性**：\n\n- 语义不直观，需要放弃 `NULL` 表示\"未删除\"的惯例\n- 若两次删除同一邮箱的时间完全相同（如批量删除），唯一索引会冲突\n- 所有查询都需要使用魔法值 `WHERE deleted_at = '1970-01-01 00:00:00'`\n\n**不推荐**，仅在无法升级时作为最后手段。\n\n---\n\n## 三、进阶场景\n\n### 3.1 多字段唯一约束\n\n**PostgreSQL**：\n\n```sql\nCREATE UNIQUE INDEX uq_users_tenant_email_phone_active\n    ON users (tenant_id, email, phone)\n    WHERE deleted_at IS NULL;\n```\n\n**MySQL 8.0.13+**：\n\n```sql\nCREATE UNIQUE INDEX uq_users_tenant_email_phone_active\n    ON users (\n        tenant_id,\n        (IF(deleted_at IS NULL, email, NULL)),\n        (IF(deleted_at IS NULL, phone, NULL))\n    );\n```\n\n> ⚠️ 需要注意的唯一性\"泄露\"风险：在 MySQL 唯一索引中，**键值中只要有一个列为 NULL，整行就可以重复**。例如 `(1, \"a\", NULL)` 和 `(1, \"a\", NULL)` 在 MySQL 唯一索引中被视为不同值。如果业务允许唯一键中的某列为 NULL，唯一性约束就会\"失效\"。PostgreSQL 的部分索引同样存在这个问题——只有未删除行才在索引里，如果允许其中一列为 NULL，则认为键值不同。**因此，业务上通常应确保唯一键中的所有列在未删除时都不为 NULL。**\n\n### 3.2 复杂删除条件\n\n当软删除有多种状态（如 `status = 'deleted'`）时：\n\n```sql\n-- PostgreSQL\nCREATE UNIQUE INDEX ... WHERE status \u003C> 'deleted';\n\n-- MySQL\nCREATE UNIQUE INDEX ... ON users ((IF(status \u003C> 'deleted', email, NULL)));\n```\n\n---\n\n## 四、性能与维护对比\n\n| 特性 | PostgreSQL 部分唯一索引 | MySQL 8.0+ 函数索引 | MySQL 5.7 虚拟列 |\n|------|------------------------|---------------------|------------------|\n| 实现复杂度 | 极低 | 低 | 中（需添加列） |\n| 索引大小 | 仅含未删除行，最小 | 同左 | 同左 |\n| 写入性能 | 极轻微开销 | 表达式计算开销极小 | 虚拟列计算极小 |\n| 查询匹配 | 优化器自动使用 | 需与原表达式完全一致 | 用列名即可，更直观 |\n| 多字段组合 | 原生支持 | 需要多个表达式 | 需要拼接为字符串 |\n| 最低版本要求 | 所有版本 | 8.0.13+ | 5.7+ |\n\n---\n\n## 五、总结与选型建议\n\n| 数据库版本 | 推荐方案 |\n|-----------|----------|\n| PostgreSQL（任意版本） | **部分唯一索引**——语义清晰、性能好、代码干净 |\n| MySQL 8.0.13+ | **函数索引**——一行 DDL，与 PostgreSQL 最为接近 |\n| MySQL 5.7 | **虚拟生成列 + 唯一索引**——生产成熟，唯一缺点是多了辅助列 |\n| MySQL 5.6 及更早 | 考虑升级；无法升级时使用\"魔法默认值\"方案 |\n\n### 生产环境注意事项\n\n1. **正确选择索引类型**：避免因索引配置不当导致隐藏的全表扫描\n2. **异常捕获**：MySQL 的函数索引或虚拟列方案在插入冲突时返回标准的 `Duplicate entry` 错误，与 PostgreSQL 行为一致，应用代码中的异常处理逻辑可以统一处理\n\n---\n\n## 延伸话题\n\n- **ORM 集成**：如 GORM、SQLAlchemy 的软删除插件如何配合这些索引使用\n- **大表在线加索引**：使用 `pt-online-schema-change` 避免锁表风险\n\n如有需要，可以进一步探讨这些话题。",null,0,33,"2026-04-28 22:58:21",130,"技术杂谈",{"id":20,"title":21},140,"一文讲透 Agent、RAG、Skill 与 MCP 的区别与联动",{"id":23,"title":24},142,"PHP 内存限制：配置、优化与最佳实践",[26,29,32,35],{"id":27,"name":28},26,"MySQL",{"id":30,"name":31},124,"软删除",{"id":33,"name":34},28,"数据库",{"id":36,"name":37},113,"数据库优化",3127,7,"https:\u002F\u002Ftp.myong.top\u002Fstorage\u002Farticle\u002F83\u002F255da489c04d6be60af683b87dd2cd.jpg",[42,47,52,57,62],{"id":43,"title":44,"create_time":45,"description":46},89,"获取腾讯“邮我”的链接","2020-06-16 10:47:34","腾讯QQ邮箱提供了“邮我”组件，可以放在自己的网站上，让别人点击提供的图片或者链接就可以发Email过来，该文章分享的是如何获取到其中的链接。",{"id":48,"title":49,"create_time":50,"description":51},98,"本站点JavaScript相关特效使用方法整理","2021-04-22 18:38:53","本站的页面用的特效有，粒子线canvas-nest，动态彩带canvas-ribbon，鼠标点击特效以及音乐播放器，本文整理了特效的使用方法",{"id":53,"title":54,"create_time":55,"description":56},106,"阿里开源数据同步组件Canal","2023-10-26 22:43:32","最开始听说canal是从mysql与redis双写一致性解决方案，当时并没有太在意，最近由于需要实时同步数据，如果在代码对insert\u002Fupdate\u002Fdelete做拦截也可以实现，但对代码侵入性太大了，并且后期更改时容易有遗漏，风险太高，这时就又想到了canal，canal的好处在于对业务代码没有侵入，因为是基于监听binlog日志去进行同步数据，这个真的是太爽爽爽了。并且实时性也能做到准实时，这也是canal为什么这么流行，因为确实很多企业会用来做数据同步的方案。",{"id":58,"title":59,"create_time":60,"description":61},62,"内网使用Composer","2019-10-08 23:29:23","最近本地部署Laravel开发环境时，遇到公司的内网无法正常使用Composer下载Laravel，后来发现是要设置公司内网代理",{"id":63,"title":64,"create_time":65,"description":66},75,"Hexo-SEO优化开启静态文件压缩功能","2019-12-30 21:34:32","个人对HEXO搭建博客的SEO优化方案进行总结，从本地的文章结构到定期推送，再到SEO关键词优化做一个全面体系的汇总，如果有更好的方法可以私聊我。\n\n",1783431647052]