MySQL 索引你真的用对了吗?

从一次 MySQL UNIQUE KEY 引起的 Bug,完善对 MySQL 索引的认识

在日常开发中,我们常常依赖数据库的 唯一约束(UNIQUE KEY) 来防止重复数据。然而,当涉及 多字段(复合)唯一索引 且字段值可能为 NULL 时,MySQL 的行为可能与直觉不符,甚至引发"看似重复却能插入"的诡异 Bug。

本文将围绕这一典型问题展开,深入探讨:

  • 复合唯一索引在 NULL 场景下的行为
  • 如何避免因 NULL 导致的逻辑重复
  • 如何设计高效的复合索引
  • 常见导致复合索引失效的陷阱及规避方法

帮助你真正掌握 MySQL 索引的正确使用方式。


一、Bug 起源:复合唯一索引 + NULL = 重复数据?

1.1 问题复现

sql 复制代码
CREATE TABLE user_coupon (
    user_id INT,
    coupon_code VARCHAR(50),
    used_at DATETIME,
    UNIQUE KEY uniq_user_coupon (user_id, coupon_code)
);

INSERT INTO user_coupon VALUES (1001, NULL, NOW());
INSERT INTO user_coupon VALUES (1001, NULL, NOW()); -- ✅ 成功!未报错

预期 :同一个用户不能有两条"无优惠券"记录。
现实 :MySQL 允许插入多条 (1001, NULL)

1.2 原因分析(InnoDB 引擎)

  • 在 MySQL(InnoDB/MyISAM)中,NULL 被视为"未知值",不等于任何值,包括它自己
  • 因此,(1001, NULL)(1001, NULL) 不被认为是相等的,唯一约束不触发。
  • 这是 MySQL 对 ANSI SQL 的非标准实现,容易引发业务逻辑漏洞。

结论

复合唯一索引中若包含可为 NULL 的字段,可能产生"逻辑重复"数据。


二、如何避免复合唯一索引的潜在问题?

2.1 方案一:禁止 NULL(首选)

sql 复制代码
CREATE TABLE user_coupon (
    user_id INT NOT NULL,
    coupon_code VARCHAR(50) NOT NULL DEFAULT '',
    UNIQUE KEY uniq_user_coupon (user_id, coupon_code)
);
  • 用空字符串 '' 表示"未使用优惠券"
  • 确保所有字段 NOT NULL,彻底规避 NULL 问题

2.2 方案二:使用生成列(Generated Column)

适用于必须保留 NULL 语义,但希望多个 NULL 被视为"相同值"的场景:

sql 复制代码
CREATE TABLE user_coupon (
    user_id INT NOT NULL,
    coupon_code VARCHAR(50) NULL,
    -- 将 NULL 映射为固定值
    coupon_key VARCHAR(50) GENERATED ALWAYS AS (COALESCE(coupon_code, '')) STORED,
    UNIQUE KEY uniq_user_key (user_id, coupon_key)
);
  • (1001, NULL)(1001, '')
  • 第二次插入相同组合将报 Duplicate entry 错误

2.3 方案三:应用层校验(兜底)

  • 在写入前查询是否存在"逻辑重复"记录
  • 注意:高并发下需配合事务或分布式锁,不能完全替代数据库约束

2.4 不推荐方案

  • 触发器:复杂、难维护、性能差
  • 依赖 MyISAM:已过时,不支持事务

三、如何优化复合唯一索引的查询性能?

即使解决了唯一性问题,若索引设计不合理,查询性能仍可能低下。

3.1 遵循最左前缀原则

复合索引 (A, B, C) 仅支持以下查询模式:

  • WHERE A = ?
  • WHERE A = ? AND B = ?
  • WHERE A = ? AND B = ? AND C = ?

❌ 不支持:

  • WHERE B = ?
  • WHERE C = ?
  • WHERE B = ? AND C = ?

3.2 字段顺序设计原则

  • 高频查询字段靠左
  • 高选择性(区分度高)字段靠左
    (如 user_idstatus 更适合放前面)

3.3 利用覆盖索引减少回表

sql 复制代码
-- 索引 (user_id, product_id)
SELECT user_id, product_id FROM user_products WHERE user_id = 100;
-- ✅ Extra: Using index(无需回表)

若需返回其他字段,可扩展索引(权衡写性能):

sql 复制代码
CREATE UNIQUE INDEX idx_cover ON t (a, b, extra_col);

3.4 避免隐式转换与函数操作

sql 复制代码
-- ❌ 索引失效
SELECT * FROM users WHERE user_id = '123';      -- user_id 是 INT
SELECT * FROM orders WHERE YEAR(create_time) = 2024;

-- ✅ 正确写法
SELECT * FROM users WHERE user_id = 123;
SELECT * FROM orders 
WHERE create_time >= '2024-01-01' 
  AND create_time < '2025-01-01';

3.5 使用 EXPLAIN 验证执行计划

sql 复制代码
EXPLAIN SELECT * FROM user_coupon 
WHERE user_id = 1001 AND coupon_code = '';

关注:

  • key:是否命中你的索引?
  • type:应为 ref / eq_ref / const
  • Extra:是否有 Using index

四、复合索引失效的常见场景与规避方法

失效场景 示例 规避方法
跳过最左列 WHERE b = 1(索引 (a,b) 查询必须包含最左连续列
列上使用函数 WHERE UPPER(name) = 'ALICE' 改写 SQL,保持列"裸露"
隐式类型转换 WHERE user_id = '123'(INT 字段) 参数类型与字段一致
前导通配符 LIKE WHERE name LIKE '%alice' 改用后缀匹配或全文索引
OR 条件含无索引列 WHERE a=1 OR c=2(c 无索引) 为每列建索引 或 改写为 UNION
范围查询后还有条件 WHERE a=1 AND b>10 AND c=5(索引 (a,b,c) c 无法用索引;考虑调整顺序为 (a,c,b)
否定查询 WHERE status != 'paid' 改用正向枚举:status IN ('pending', 'failed')

💡 黄金法则 :让索引列在 WHERE 中以 原始、无修饰、类型匹配 的形式出现。


五、最佳实践总结

✅ 设计阶段

  • 复合唯一索引尽量 避免可为 NULL 的字段
  • 若必须用 NULL,优先考虑 生成列 + COALESCE 方案
  • 字段顺序:高频 + 高选择性 → 靠左

✅ 开发阶段

  • 所有查询参数 类型与字段严格一致
  • 避免在索引列上使用 函数、计算、隐式转换
  • EXPLAIN 验证每一条关键 SQL

✅ 运维阶段

  • 定期 ANALYZE TABLE 更新统计信息
  • 监控慢查询日志,发现索引未命中情况

六、结语

MySQL 的索引机制强大而微妙。一个看似简单的 UNIQUE KEY,在 NULL、复合字段、查询模式等多重因素影响下,可能带来意想不到的问题。

真正的稳定性,来自于对底层机制的理解,而非表面的"能跑就行"。

📌 记住

  • NULL ≠ NULL
  • 最左前缀是生命线
  • 索引列要"干净"
  • 性能靠验证,不靠猜测

作者:Beata - 后端服务架构自由人

最后更新:2025 年 11 月 29 日 版权声明:本文可自由转载,注明出处即可。

相关推荐
TDengine (老段)2 小时前
MySQL/PG/TDengine 时间范围 SQL 表达式
sql·mysql·tdengine
大白的编程日记.3 小时前
【计算网络学习笔记】MySql的多版本控制MVCC和Read View
网络·笔记·学习·mysql
gAlAxy...6 小时前
SpringMVC 响应数据和结果视图:从环境搭建到实战全解析
大数据·数据库·mysql
vx_dmxq2117 小时前
【微信小程序学习交流平台】(免费领源码+演示录像)|可做计算机毕设Java、Python、PHP、小程序APP、C#、爬虫大数据、单片机、文案
java·spring boot·python·mysql·微信小程序·小程序·idea
m***92388 小时前
【SQL】MySQL中的字符串处理函数:concat 函数拼接字符串,COALESCE函数处理NULL字符串
数据库·sql·mysql
TracyCoder1238 小时前
MySQL 实战宝典(八):Java后端MySQL分库分表工具解析与选型秘籍
java·开发语言·mysql
q***816417 小时前
MySQL:数据查询-limit
数据库·mysql
p***924817 小时前
DBeaver连接本地MySQL、创建数据库表的基础操作
数据库·mysql
I***t71620 小时前
一条sql 在MySQL中是如何执行的
数据库·sql·mysql