在业务系统中,当多个用户或线程同时对同一条数据进行修改时,如果没有做并发控制,就可能出现后提交的更新覆盖先提交的更新的情况,也就是并发覆盖问题。比如库存扣减、账户余额修改这类场景,一旦出现并发覆盖,会造成严重的业务损失。要解决这个问题,需要结合MySQL的数据库特性和Java的代码逻辑共同实现。

并发覆盖的产生原因
并发覆盖通常发生在两个及以上的更新操作同时读取同一条数据,然后基于读取到的旧值进行计算和更新,后提交的更新会直接覆盖先提交的更新的结果。举个简单的例子,假设某商品库存初始为10,线程A读取到库存10,准备扣减2,同时线程B也读取到库存10,准备扣减3,两个线程先后提交更新,最终库存可能是8或者7,而不是正确的5,这就是典型的并发覆盖问题。
MySQL层面的并发控制方案
悲观锁方案
悲观锁的核心思想是假设并发冲突一定会发生,所以在操作数据之前先加锁,阻止其他事务同时操作同一条数据。在MySQL中,可以使用SELECT ... FOR UPDATE语句对读取的数据加行级排他锁,只有当前事务提交后,锁才会释放,其他事务需要等待锁释放才能操作这条数据。
使用悲观锁需要注意,查询条件必须命中索引,否则会从行锁升级为表锁,影响并发性能。以下是使用悲观锁的更新示例:
-- 开启事务 START TRANSACTION; -- 查询并加行锁,假设id是主键索引 SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- 基于查询到的库存计算结果后更新 UPDATE product SET stock = 8 WHERE id = 1; -- 提交事务,释放锁 COMMIT;
乐观锁方案
乐观锁的核心思想是假设并发冲突不会经常发生,所以不在读取数据时加锁,而是在更新数据时判断数据是否被其他事务修改过。常见的实现方式是给表增加一个版本号字段或者更新时间字段,更新时带上版本号条件,只有版本号匹配时才执行更新,同时版本号加1。
以下是使用版本号实现乐观锁的更新示例:
-- 更新时判断版本号是否匹配,匹配则更新并递增版本号 UPDATE product SET stock = 8, version = version + 1 WHERE id = 1 AND version = 1;
如果执行后返回的影响行数是0,说明版本号不匹配,数据已经被其他事务修改过,当前更新失败,需要重试或者提示用户。
Java代码中配合实现安全更新
悲观锁对应的Java实现
在Java中,我们可以使用Spring的声明式事务来管理事务,结合MyBatis执行加锁查询和更新操作。首先定义Mapper接口:
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Map;
@Mapper
public interface ProductMapper {
// 加锁查询商品库存
Map<String, Object> selectStockForUpdate(@Param("id") Integer id);
// 更新商品库存
int updateStock(@Param("id") Integer id, @Param("stock") Integer stock);
}
对应的MyBatis XML映射文件内容:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.ProductMapper">
<select id="selectStockForUpdate" resultType="java.util.Map">
SELECT stock FROM product WHERE id = #{id} FOR UPDATE
</select>
<update id="updateStock">
UPDATE product SET stock = #{stock} WHERE id = #{id}
</update>
</mapper>
然后编写Service层逻辑,在事务中执行操作:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Transactional(rollbackFor = Exception.class)
public void deductStockWithPessimisticLock(Integer productId, Integer deductCount) {
// 加锁查询库存
Map<String, Object> productMap = productMapper.selectStockForUpdate(productId);
Integer currentStock = (Integer) productMap.get("stock");
// 判断库存是否充足
if (currentStock < deductCount) {
throw new RuntimeException("库存不足");
}
// 计算新库存并更新
Integer newStock = currentStock - deductCount;
productMapper.updateStock(productId, newStock);
}
}
乐观锁对应的Java实现
乐观锁的Java实现需要处理更新失败的重试逻辑,同样先定义Mapper接口:
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface ProductOptimisticMapper {
// 根据id和版本号更新库存,返回影响行数
int updateStockWithVersion(@Param("id") Integer id,
@Param("deductCount") Integer deductCount,
@Param("oldVersion") Integer oldVersion);
}
对应的MyBatis XML映射文件内容:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.ProductOptimisticMapper">
<update id="updateStockWithVersion">
UPDATE product
SET stock = stock - #{deductCount}, version = version + 1
WHERE id = #{id} AND version = #{oldVersion}
</update>
</mapper>
Service层实现重试逻辑,这里设置最多重试3次:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductOptimisticService {
@Autowired
private ProductOptimisticMapper productOptimisticMapper;
public void deductStockWithOptimisticLock(Integer productId, Integer deductCount) {
int retryCount = 0;
int maxRetry = 3;
while (retryCount < maxRetry) {
// 先查询当前版本号和库存,这里可以单独写查询方法,省略具体实现
Integer currentVersion = 1; // 假设查询到的版本号是1
Integer currentStock = 10; // 假设查询到的库存是10
if (currentStock < deductCount) {
throw new RuntimeException("库存不足");
}
// 执行乐观锁更新
int affectedRows = productOptimisticMapper.updateStockWithVersion(productId, deductCount, currentVersion);
if (affectedRows > 0) {
// 更新成功,退出重试
return;
}
// 更新失败,重试次数加1
retryCount++;
if (retryCount == maxRetry) {
throw new RuntimeException("更新失败,请稍后重试");
}
}
}
}
两种方案的适用场景
悲观锁适合并发冲突比较频繁的场景,比如秒杀、库存高频扣减场景,因为加锁可以保证数据操作的串行化,避免冲突,但会增加锁等待的开销,降低并发性能。
乐观锁适合并发冲突比较少的场景,比如用户修改个人信息、低频的数据更新操作,因为没有加锁,并发性能更好,但需要处理重试逻辑,更新失败时需要给用户合理的提示。
注意事项
- 使用悲观锁时,事务要尽量小,避免长时间持有锁,影响其他事务的执行。
- 乐观锁的重试次数不要设置太多,避免重试过多导致接口响应时间过长。
- 无论是哪种方案,更新操作都要尽量基于数据库层面的计算,比如直接使用
stock = stock - 1,而不是先查询出来在Java代码中计算再更新,减少并发窗口。 - 如果更新涉及多张表,要保证所有操作在同一个事务中,避免部分更新成功导致的数据不一致。