唯一索引防重复投票最可靠:在(user_id, option_id)建联合唯一索引,MySQL直接报错拦截并发重复插入,比应用层“先查后插”或INSERT IGNORE更安全、明确。

用唯一索引防止重复投票最可靠
MySQL 里防重复投票,别靠应用层“先查再插”——那会漏掉并发写入的脏数据。直接在数据库加 UNIQUE 约束才是硬保障。
假设用户投票表是 votes,字段有 user_id、option_id、created_at,要限制一个用户对同一选项只能投一次,就在 (user_id, option_id) 上建唯一联合索引:
ALTER TABLE votes ADD UNIQUE INDEX uk_user_option (user_id, option_id);
这样当重复插入时,MySQL 直接报错:ERROR 1062 (23000): Duplicate entry '123-45' for key 'uk_user_option',应用只需捕获这个错误并提示“已投过票”即可。
- 别用单列
user_id索引——用户可以投多个不同选项 - 别依赖
INSERT IGNORE或ON DUPLICATE KEY UPDATE来“静默处理”,它们掩盖了业务意图;明确失败比假装成功更安全 - 如果业务允许“改票”,那就不用唯一索引,改用带时间戳的更新逻辑,但得额外处理旧记录(比如置为无效)
用 INSERT ... SELECT + ROW_COUNT() 做原子性校验
有些场景不能加唯一索引(比如历史表结构冻结、或需兼容老数据),就得靠 SQL 原子操作兜底。
核心思路:用一条 INSERT ... SELECT 语句,只在满足“未投过”条件时才插入,然后靠 ROW_COUNT() 判断是否成功:
INSERT INTO votes (user_id, option_id, created_at) SELECT 123, 45, NOW() FROM DUAL WHERE NOT EXISTS ( SELECT 1 FROM votes WHERE user_id = 123 AND option_id = 45 );
执行后立刻查 ROW_COUNT(),返回 1 表示插入成功,0 表示已被投过。
- 必须用
NOT EXISTS,别用LEFT JOIN ... IS NULL—— 后者在空表或大表时容易误判 -
DUAL是 MySQL 的占位虚表,不能省;少写会报语法错误 - 这个写法在高并发下依然安全,因为整个语句是原子的,不需要显式事务(除非你还想顺带更新统计数)
时间窗口内防刷票要加业务级限流
唯一索引和原子插入只能防“同选项重复”,拦不住“一小时投十个不同选项”的刷票行为。这类控制必须在应用层+数据库协同做。
常见做法是:每次投票前,先查该用户最近 N 分钟内的投票总数,超限就拒绝。
SELECT COUNT(*) FROM votes WHERE user_id = 123 AND created_at > DATE_SUB(NOW(), INTERVAL 60 MINUTE);
注意这里不能只靠索引加速——user_id + created_at 联合索引才有效,单列索引效果差。
- 别把时间窗口逻辑全压给 MySQL 计算,高频场景下建议用 Redis 缓存计数(如
INCRBY+EXPIRE),数据库只做最终落盘 - 如果用 MySQL 做实时校验,记得在
votes表上建复合索引:INDEX idx_user_time (user_id, created_at) - “N 分钟”这个值不是越小越好——太小会导致正常用户手滑重试失败;建议从 5 分钟起步,根据实际日志调优
事务隔离级别影响并发判断结果
如果你坚持用“先查后插”的老套路(不推荐),那 READ COMMITTED 和 REPEATABLE READ 的表现完全不同——后者可能让你误以为没投过。
比如两个请求同时查 SELECT * FROM votes WHERE user_id=123 AND option_id=45,在 REPEATABLE READ 下,两次查询看到的是同一个快照,哪怕中间有人已插入并提交,第二个请求仍查不到,接着也插入,就重复了。
- 唯一索引不受隔离级别影响,所以它是兜底首选
- 如果非要用“查+插”,至少设成
READ COMMITTED,并手动加SELECT ... FOR UPDATE锁住范围(但性能差,易死锁) - 别在代码里写
if (!exists) insert这种裸逻辑,不管什么隔离级别,都扛不住并发
事情说清了就结束。真正难的不是写哪条 SQL,而是想清楚“重复”的定义:是同一选项?同一投票活动?还是同一 IP + 设备指纹?数据库只管它能管住的那一层。










