MySQL存款与取款表如何防止重复录入?解决重复API请求问题
嘿,这个问题我在做支付类系统的时候踩过不少坑,刚好有几个经过实践验证的方案分享给你,完全能解决重复插入的问题,而且性能比表锁好太多:
方案一:业务唯一键 + INSERT ... ON DUPLICATE KEY UPDATE(最推荐)
这是最常用也最省心的方案,核心思路是给取款/存款记录表设置一个能唯一标识单次请求的联合唯一键,利用MySQL的唯一键约束拦截重复插入,同时配合ON DUPLICATE KEY UPDATE避免报错。
怎么设置唯一键?
对于取款记录来说,唯一键可以由「用户ID + 全局唯一请求ID」组成:
- 用户ID:标识操作所属的用户;
- 请求ID:由你的API层生成(比如UUID、雪花ID,甚至前端生成的唯一标识),同一个用户的同一次取款请求,不管触发多少次,请求ID都是相同的。
举个建表示例:
CREATE TABLE withdrawal_records ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键', user_id BIGINT NOT NULL COMMENT '用户ID', request_id VARCHAR(64) NOT NULL COMMENT 'API请求唯一标识', amount DECIMAL(10,2) NOT NULL COMMENT '取款金额', status TINYINT DEFAULT 1 COMMENT '记录状态:1-成功,2-失败', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', -- 设置联合唯一键 UNIQUE KEY uk_user_request (user_id, request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
插入逻辑
使用INSERT ... ON DUPLICATE KEY UPDATE语句,当重复插入时,不会新增记录,而是更新指定字段(比如更新最后操作时间),避免报错:
INSERT INTO withdrawal_records (user_id, request_id, amount) VALUES (1001, 'a1b2c3d4-5678-90ef-ghij-klmnopqrstuv', 500.00) ON DUPLICATE KEY UPDATE create_time = CURRENT_TIMESTAMP;
这个方案的优点:
- 完全依赖MySQL的原生约束,代码逻辑简单;
- 性能高,InnoDB会对唯一键进行行级锁,不会影响其他用户的操作;
- 天然支持幂等,重复请求不会生成重复记录。
方案二:分布式锁 + 事务(适合无请求ID的场景)
如果你的业务场景没办法生成全局唯一的请求ID,可以用分布式锁来控制同一用户的并发取款请求,确保同一时间只有一个请求能执行插入操作。
实现思路
比如用Redis的SETNX命令,或者MySQL自带的GET_LOCK函数来加锁,锁的粒度建议按「用户ID」来设置(不要用全局锁,否则会影响所有用户的性能):
举个MySQL自带锁的例子:
-- 1. 获取针对用户1001的取款锁,超时时间10秒(防止死锁) SELECT GET_LOCK('withdrawal_lock_1001', 10) INTO @lock_result; -- 2. 检查是否获取到锁 IF @lock_result = 1 THEN START TRANSACTION; -- 执行取款逻辑:比如扣减用户余额 + 插入取款记录 UPDATE users SET balance = balance - 500.00 WHERE user_id = 1001; INSERT INTO withdrawal_records (user_id, amount) VALUES (1001, 500.00); COMMIT; -- 3. 释放锁 SELECT RELEASE_LOCK('withdrawal_lock_1001'); ELSE -- 未获取到锁,说明当前有并发请求,返回“操作中,请稍后再试” SELECT '操作中,请稍后再试' AS msg; END IF;
注意事项
- 一定要设置锁的超时时间,防止服务宕机导致锁无法释放;
- 锁的粒度要尽量细,避免影响其他用户的操作;
- 如果用Redis锁,要注意锁的续期问题(比如Redisson的看门狗机制)。
方案三:乐观锁 + 事务(兼顾余额一致性)
如果你的取款操作需要同时保证用户余额的一致性(避免超支),可以给用户表加一个版本号字段,利用乐观锁来控制并发,同时间接避免重复插入记录。
实现步骤
- 给用户表添加版本号字段:
ALTER TABLE users ADD COLUMN version INT DEFAULT 1 COMMENT '乐观锁版本号';
- 事务包裹取款逻辑:
START TRANSACTION; -- 1. 查询用户当前余额和版本号(加行锁,避免其他事务修改) SELECT balance, version FROM users WHERE user_id = 1001 FOR UPDATE INTO @current_balance, @current_version; -- 2. 判断余额是否足够 IF @current_balance >= 500.00 THEN -- 3. 更新余额,同时版本号+1(只有版本号匹配才会更新成功) UPDATE users SET balance = balance - 500.00, version = version + 1 WHERE user_id = 1001 AND version = @current_version; -- 4. 检查更新行数,如果是0说明并发冲突,回滚 IF ROW_COUNT() > 0 THEN INSERT INTO withdrawal_records (user_id, amount) VALUES (1001, 500.00); COMMIT; SELECT '取款成功' AS msg; ELSE ROLLBACK; SELECT '并发冲突,请重试' AS msg; END IF; ELSE ROLLBACK; SELECT '余额不足' AS msg; END IF;
这个方案的优点是同时保证了余额的一致性和记录的唯一性,重复请求会因为版本号不匹配而更新失败,自然不会插入重复记录。
总结建议
- 优先用方案一,只要能生成全局唯一的请求ID,这是最简单高效的方案;
- 如果你不需要单独的请求ID,或者需要更严格的并发控制,可以考虑方案二;
- 如果需要兼顾余额一致性,方案三是不错的选择;
- 绝对不要用MyISAM的表锁,性能差还不支持事务,直接用InnoDB引擎就行。
内容的提问来源于stack exchange,提问作者bbnn
相关产品推荐
相关产品推荐

