SQL并发更新冲突是数据库多用户场景下常见的问题,当多个事务同时读取并修改同一条数据记录时,后提交的事务会覆盖先提交事务的修改结果,导致数据不一致。悲观锁定机制的核心思想是假设并发冲突一定会发生,在事务操作数据前就先对目标数据加锁,阻止其他事务同时修改该数据,直到当前事务提交或回滚后锁才会释放。利用数据库触发器可以在数据更新操作执行前自动触发锁定逻辑,不需要修改上层业务代码就能实现悲观锁定效果。

并发更新冲突的场景示例
假设有一张用户账户表user_account,结构如下:
CREATE TABLE user_account (
id INT PRIMARY KEY AUTO_INCREMENT,
user_name VARCHAR(50) NOT NULL,
balance DECIMAL(10,2) NOT NULL,
version INT NOT NULL DEFAULT 0
);
两个事务同时执行扣减用户余额的操作,事务A查询到用户余额为100,扣减20后准备更新为80;同时事务B也查询到余额为100,扣减30后准备更新为70。如果事务A先提交,事务B后提交,最终余额会被更新为70,事务A的扣减操作就被覆盖了,这就是典型的并发更新冲突。
触发器实现悲观锁定的原理
悲观锁定的实现通常依赖数据库的锁机制,比如行级锁。我们可以在更新操作对应的触发器中,通过SELECT ... FOR UPDATE语句对目标行加行级锁,该语句会锁定查询到的行,直到当前事务结束才会释放锁。如果其他事务同时尝试更新同一行数据,就会被阻塞,直到锁释放,从而避免并发冲突。
具体实现步骤
1. 创建测试数据
先向user_account表中插入一条测试数据:
INSERT INTO user_account (user_name, balance) VALUES ('张三', 100.00);
2. 创建更新前触发器
创建在user_account表更新前触发的触发器,在触发器中执行加锁逻辑:
DELIMITER //
CREATE TRIGGER trg_user_account_update_before
BEFORE UPDATE ON user_account
FOR EACH ROW
BEGIN
-- 对要更新的行加行级锁,阻塞其他事务的并发更新
SELECT balance INTO @current_balance
FROM user_account
WHERE id = NEW.id
FOR UPDATE;
-- 可选:可以增加版本号校验逻辑,进一步保证数据一致性
IF OLD.version != NEW.version THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '数据已被其他事务修改,请重试';
END IF;
END //
DELIMITER ;
这里FOR UPDATE会对id等于NEW.id的行加行级排他锁,其他事务如果尝试更新同一行,执行到SELECT ... FOR UPDATE时就会被阻塞,直到当前事务提交或回滚。
3. 测试并发更新效果
打开两个数据库连接会话模拟并发场景:
会话1执行以下操作:
-- 开启事务 START TRANSACTION; -- 查询并加锁,模拟业务中的查询操作 SELECT balance FROM user_account WHERE id = 1 FOR UPDATE; -- 执行更新操作,触发触发器再次加锁 UPDATE user_account SET balance = balance - 20, version = version + 1 WHERE id = 1; -- 此时不提交事务,保持锁持有状态
会话2同时执行以下操作:
-- 开启事务 START TRANSACTION; -- 尝试更新同一行,会被会话1的锁阻塞 UPDATE user_account SET balance = balance - 30, version = version + 1 WHERE id = 1;
此时会话2的更新操作会一直阻塞,直到会话1执行COMMIT提交事务后,会话2的更新才会继续执行,这样就避免了两个事务同时修改同一行数据导致的冲突。
注意事项
- 触发器中加锁的范围要和实际更新操作的范围一致,避免锁范围过大影响数据库性能。
SELECT ... FOR UPDATE的锁是行级锁,需要对应表的索引支持,否则会升级为表级锁,影响并发性能。- 事务持有锁的时间要尽量短,避免长事务导致大量请求阻塞,影响数据库整体性能。
- 如果数据库不支持触发器或者触发器逻辑复杂,也可以考虑在业务层直接使用
SELECT ... FOR UPDATE实现悲观锁定,逻辑更可控。