在业务系统的数据库操作中,当多个线程或请求同时修改同一条数据记录时,很容易出现后提交的更新覆盖先提交的更新内容,最终导致数据丢失的问题,这就是SQL并发更新丢数据的典型场景。这类问题在库存扣减、账户余额修改等高频更新场景中尤为常见,会直接影响业务数据的准确性。

并发更新丢数据的产生原因
并发更新丢数据的核心原因是多个更新操作没有感知到其他操作的存在,各自基于旧的数据版本执行更新。我们可以通过一个库存扣减的场景来理解:
- 初始库存为10,请求A和请求B同时查询到当前库存为10
- 请求A执行扣减1的操作,更新后库存为9,提交事务
- 请求B同样执行扣减1的操作,基于自己查询到的库存10计算后更新为9,提交事务
- 最终库存为9,而实际应该是8,出现了1个库存的丢失
这类问题的本质是更新操作没有做并发控制,多个操作基于相同的旧数据执行更新,最终互相覆盖。
乐观锁的核心原理
乐观锁是一种并发控制方案,它假设并发冲突的概率较低,因此在操作数据时不会立刻加锁,而是在提交更新时判断数据是否被其他操作修改过。如果数据没有被修改,就执行更新;如果数据已经被修改,就放弃当前操作或者重试。
在数据库场景中,乐观锁最常用的实现方式是版本号机制,具体逻辑如下:
- 在数据表中添加一个版本号字段,比如
version,初始值为0 - 查询数据时,同时获取当前的版本号
- 更新数据时,在WHERE条件中加上版本号等于查询到的版本号,同时将版本号加1
- 如果更新语句影响的行数为1,说明更新成功;如果影响行数为0,说明数据已经被其他操作修改,当前更新失败
基于版本号的乐观锁SQL实现
首先我们需要在业务表中添加版本号字段,以MySQL为例,建表语句如下:
-- 创建商品库存表,添加version版本号字段 CREATE TABLE `product_stock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `product_id` bigint(20) NOT NULL COMMENT '商品ID', `stock_num` int(11) NOT NULL COMMENT '库存数量', `version` int(11) NOT NULL DEFAULT '0' COMMENT '版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_product_id` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';
接下来是查询和更新操作的SQL示例,假设我们要扣减商品ID为1001的库存1个:
-- 1. 查询商品库存和当前版本号 SELECT stock_num, version FROM product_stock WHERE product_id = 1001; -- 2. 假设查询到的stock_num为10,version为0,执行更新操作 UPDATE product_stock SET stock_num = stock_num - 1, version = version + 1 WHERE product_id = 1001 AND version = 0; -- 3. 判断更新结果,如果返回的影响行数为1,说明更新成功;如果为0,说明并发冲突,更新失败
当多个请求同时执行上述更新SQL时,只有一个请求能匹配到version=0的条件,更新成功;其他请求的version条件不匹配,更新影响行数为0,也就不会出现数据覆盖的问题。
Java业务层整合乐观锁实现
在实际的业务代码中,我们需要结合ORM框架来处理乐观锁逻辑,以下是基于MyBatis的实现示例:
实体类定义
public class ProductStock {
private Long id;
private Long productId;
private Integer stockNum;
private Integer version;
// 省略getter、setter方法
}
Mapper接口定义
public interface ProductStockMapper {
// 根据商品ID查询库存信息
ProductStock selectByProductId(Long productId);
// 乐观锁更新库存,返回影响行数
int updateStockWithOptimisticLock(@Param("productId") Long productId,
@Param("deductNum") Integer deductNum,
@Param("oldVersion") Integer oldVersion);
}
Mapper 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.ProductStockMapper">
<select id="selectByProductId" resultType="com.example.entity.ProductStock">
SELECT id, product_id, stock_num, version
FROM product_stock
WHERE product_id = #{productId}
</select>
<update id="updateStockWithOptimisticLock">
UPDATE product_stock
SET stock_num = stock_num - #{deductNum},
version = version + 1
WHERE product_id = #{productId}
AND version = #{oldVersion}
</update>
</mapper>
业务层实现逻辑
@Service
public class ProductStockService {
@Autowired
private ProductStockMapper productStockMapper;
/**
* 扣减商品库存,使用乐观锁处理并发
* @param productId 商品ID
* @param deductNum 扣减数量
* @return 是否扣减成功
*/
public boolean deductStock(Long productId, Integer deductNum) {
// 查询当前库存和版本号
ProductStock stock = productStockMapper.selectByProductId(productId);
if (stock == null) {
throw new RuntimeException("商品库存不存在");
}
if (stock.getStockNum() < deductNum) {
throw new RuntimeException("库存不足");
}
// 执行乐观锁更新
int affectRows = productStockMapper.updateStockWithOptimisticLock(
productId, deductNum, stock.getVersion()
);
// 更新成功返回true,失败说明并发冲突,可重试或返回失败
if (affectRows == 1) {
return true;
} else {
// 这里可以添加重试逻辑,比如重试3次后返回失败
throw new RuntimeException("扣减库存失败,可能存在并发冲突");
}
}
}
乐观锁的适用场景和注意事项
乐观锁并不是所有场景都适用,它更适合并发冲突概率较低的场景,比如大部分时间是读取操作,更新操作较少的场景。如果并发冲突非常频繁,乐观锁会导致大量的更新失败和重试,反而会降低性能,这种场景更适合使用悲观锁。
使用乐观锁时需要注意以下几点:
- 版本号字段建议设置为整型,每次更新加1即可,不需要使用时间戳等其他方式,避免时间戳精度问题导致的判断错误
- 更新语句的WHERE条件必须包含版本号判断,否则乐观锁会失效
- 如果更新失败,需要根据业务需求决定是重试还是直接返回失败,重试时要注意避免死循环
- 乐观锁只适用于单条记录的更新场景,如果是多条记录的批量更新,需要额外设计并发控制逻辑