1. 当数据库说"这个数据我见过"时该怎么办?
第一次看到"Duplicate entry"错误时,我正坐在凌晨三点的办公室里,盯着屏幕上那个刺眼的1062错误码发呆。当时我们的用户注册系统刚上线,就遇到了大量注册失败的情况。后来才发现,原来这就是数据库在告诉我们:"嘿,这条数据我已经有了,别重复给我!"
这个错误本质上是个"数据身份证冲突"。想象你去酒店办理入住,前台发现你的身份证号已经登记过了——要么是系统搞错了,要么是有人冒用了你的身份。数据库里的主键和唯一索引就像数据的身份证号,当出现重复时,就会触发这个错误。
在实际项目中,我见过最常见的三种翻车场景:
- 用户注册时手机号重复
- 商品入库时条形码重复
- 订单生成时流水号重复
2. 从数据库设计开始防患于未然
2.1 主键设计的艺术
很多新手会直接用自增ID当主键就完事了,但在高并发系统中这远远不够。我曾经参与过一个电商项目,他们用商品名称当主键,结果不同地区的方言写法导致大量冲突。好的主键设计应该遵循:
- 绝对唯一性:像UUID或者雪花算法生成的ID
- 无业务含义:避免使用可能重复的业务字段
- 类型精简:尽量用整型而非字符串
-- 不推荐的写法 CREATE TABLE products ( product_name VARCHAR(255) PRIMARY KEY, ... ); -- 推荐的写法 CREATE TABLE products ( id BIGINT UNSIGNED PRIMARY KEY, sku_code VARCHAR(32) UNIQUE, ... );2.2 唯一索引的正确打开方式
唯一索引是把双刃剑。有次我们给用户邮箱加了唯一索引,结果发现很多用户会用"邮箱+后缀"的方式注册多个账号。后来我们改成了组合索引:
-- 识别真正唯一的用户 CREATE UNIQUE INDEX idx_user_identity ON users ( email_domain, email_local_part, phone_prefix, phone_number );2.3 字符集和排序规则的坑
你可能想不到,字符集也能导致重复键错误。我们有个国际化的项目,发现'café'和'café'在utf8mb4下被认为是相同的。解决方案是明确指定排序规则:
CREATE TABLE restaurants ( name VARCHAR(100) COLLATE utf8mb4_bin UNIQUE, ... );3. 应用层的防御性编程
3.1 先查后插的经典模式
我见过太多人直接怼INSERT语句然后捕获异常,这不是个好习惯。正确的做法应该是:
def create_user(user_data): with transaction.atomic(): if User.objects.filter(email=user_data['email']).exists(): return {'error': 'Email already registered'} user = User.objects.create(**user_data) return {'success': True, 'user_id': user.id}3.2 高并发下的解决方案
在秒杀场景下,"先查后插"可能失效,因为查询和插入不是原子操作。这时候需要上组合拳:
- 数据库层面:使用SELECT FOR UPDATE加锁
- 缓存层面:用Redis的SETNX做分布式锁
- 应用层面:实现请求排队机制
// 使用分布式锁的例子 public boolean registerUser(User user) { String lockKey = "user:register:" + user.getEmail(); try { // 尝试获取锁 boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("操作太频繁,请稍后再试"); } // 真正的注册逻辑 return userRepository.createUser(user); } finally { redisTemplate.delete(lockKey); } }3.3 批量插入的处理技巧
处理CSV文件导入时,我推荐使用INSERT IGNORE或者ON DUPLICATE KEY UPDATE:
-- 方式一:跳过重复记录 INSERT IGNORE INTO products (sku, name) VALUES ('1001', 'iPhone 13'), ('1002', 'iPad Pro'); -- 方式二:更新重复记录 INSERT INTO inventory (product_id, stock) VALUES (1, 100), (2, 50) ON DUPLICATE KEY UPDATE stock = VALUES(stock);4. 异常处理和优雅降级
4.1 精准捕获异常
不同编程语言捕获重复键异常的方式不同:
# Python + Django from django.db.utils import IntegrityError try: user.save() except IntegrityError as e: if 'Duplicate entry' in str(e): # 处理重复逻辑// Java + Spring try { userRepository.save(user); } catch(DataIntegrityViolationException e) { if(e.getRootCause() instanceof MySQLIntegrityConstraintViolationException) { // 处理重复逻辑 } }4.2 给用户友好的反馈
千万别直接把数据库错误扔给用户。我们有个血泪教训:早期系统直接返回"1062错误",客服被用户骂惨了。后来我们做了错误码映射:
| 错误类型 | 用户提示 |
|---|---|
| 邮箱重复 | "该邮箱已注册,请直接登录或使用找回密码" |
| 手机号重复 | "该手机号已绑定其他账号" |
| 用户名重复 | "这个昵称太受欢迎了,换一个试试?" |
4.3 数据修复流程
真的出现重复数据怎么办?我们设计了一套修复流程:
- 将异常数据移入待审核表
- 触发人工审核流程
- 提供数据合并工具
- 记录完整操作日志
-- 数据修复示例 BEGIN; INSERT INTO user_backup SELECT * FROM users WHERE email = 'duplicate@example.com'; DELETE FROM users WHERE email = 'duplicate@example.com' AND id NOT IN ( SELECT MIN(id) FROM users WHERE email = 'duplicate@example.com' ); COMMIT;5. 监控与持续优化
5.1 搭建监控体系
我们在Prometheus中配置了这些关键指标:
- duplicate_errors_total:重复错误计数
- recovery_time_seconds:自动恢复耗时
- manual_fix_required:需要人工干预的次数
5.2 压力测试中的观察
做负载测试时,要特别关注:
- 唯一索引的写入性能
- 锁竞争情况
- 错误恢复耗时
5.3 长期优化策略
经过多个项目积累,我们总结出这些经验:
- 高峰期临时放宽某些唯一性检查
- 实现客户端本地去重
- 采用最终一致性替代强一致性
- 定期清理僵尸数据释放唯一键
有一次我们处理了200万用户的数据合并,最终形成了这套完整的防重体系。记住,好的系统不是不犯错,而是知道如何优雅地处理错误。