在并发业务场景下,多个线程同时操作MySQL同一行数据是极为常见的需求,比如电商系统中多个用户同时抢购同一件商品,库存字段的更新就涉及多线程操作同一行的问题,处理不当会导致库存超扣、数据更新丢失等异常。

多线程更新同一行数据的常见问题
当多个线程同时执行更新同一行数据的操作时,如果不做并发控制,会出现两类典型问题。第一类是更新丢失,比如线程A读取到某行数据的字段值为10,线程B也读取到该值为10,线程A将其更新为8,线程B随后将其更新为9,最终结果为9,线程A的更新被覆盖。第二类是数据不一致,比如更新涉及多个字段,不同线程交叉更新不同字段,最终得到不符合业务预期的数据组合。
CAS无锁编程的核心思想
CAS即Compare And Swap,是无锁编程中常用的一种并发控制思路,核心逻辑可以概括为三个操作数:内存地址V、旧预期值A、新值B。当且仅当内存地址V的当前值等于旧预期值A时,才将V的值修改为B,否则不做任何操作,整个过程是原子性的。在Java等编程语言中,很多原子类的实现都基于CAS思路,不需要加锁就能实现并发安全的数据更新。
MySQL中借鉴CAS思路的实现方式
MySQL本身没有原生的CAS指令,但是可以通过SQL语句的组合来模拟CAS的逻辑,核心是利用更新语句的条件判断实现比较和交换的原子性。MySQL的UPDATE语句本身是原子操作,我们可以在UPDATE语句的WHERE条件中加上旧值的判断,只有当当前行的值和预期旧值一致时才执行更新。
基于UPDATE条件判断的CAS式实现
假设我们有一张商品表,结构如下:
CREATE TABLE `product` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '商品名称', `stock` int(11) NOT NULL COMMENT '商品库存', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
现在多个线程要同时扣减id为1的商品库存,每个线程扣减1件,传统的不安全更新语句如下:
-- 先查询当前库存 SELECT stock FROM product WHERE id = 1; -- 假设查到库存为10,再执行更新 UPDATE product SET stock = 9 WHERE id = 1;
这种写法查询和更新是两个独立的操作,不是原子的,就会出现前面提到的更新丢失问题。我们可以借鉴CAS的思路,把查询和更新的逻辑合并到一个原子操作中:
-- 假设线程读取到的旧库存是10,要扣减1件,新库存是9 UPDATE product SET stock = 9 WHERE id = 1 AND stock = 10;
这条UPDATE语句会先判断id为1的行的stock值是否等于10,如果等于才更新为9,否则不执行更新。我们可以通过判断语句的受影响行数来确定是否更新成功,如果受影响行数为1,说明更新成功;如果为0,说明当前行的stock已经不是10了,更新失败,需要重试。
重试机制的实现
CAS操作失败后通常需要重试,我们可以结合业务代码实现重试逻辑,以下是Java中使用JDBC实现的示例:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class ProductStockUpdater {
private static final String URL = "jdbc:mysql://127.0.0.1:3306/test_db?useSSL=false";
private static final String USER = "root";
private static final String PASSWORD = "123456";
// 最大重试次数
private static final int MAX_RETRY = 3;
public boolean deductStock(int productId, int deductNum) throws SQLException {
int retryCount = 0;
while (retryCount < MAX_RETRY) {
Connection conn = null;
PreparedStatement selectPs = null;
PreparedStatement updatePs = null;
ResultSet rs = null;
try {
conn = DriverManager.getConnection(URL, USER, PASSWORD);
// 1. 查询当前库存作为旧预期值
String selectSql = "SELECT stock FROM product WHERE id = ?";
selectPs = conn.prepareStatement(selectSql);
selectPs.setInt(1, productId);
rs = selectPs.executeQuery();
if (!rs.next()) {
// 商品不存在
return false;
}
int oldStock = rs.getInt("stock");
// 库存不足
if (oldStock < deductNum) {
return false;
}
int newStock = oldStock - deductNum;
// 2. 执行CAS式更新
String updateSql = "UPDATE product SET stock = ? WHERE id = ? AND stock = ?";
updatePs = conn.prepareStatement(updateSql);
updatePs.setInt(1, newStock);
updatePs.setInt(2, productId);
updatePs.setInt(3, oldStock);
int affectedRows = updatePs.executeUpdate();
if (affectedRows > 0) {
// 更新成功
return true;
}
// 更新失败,重试
retryCount++;
// 重试前短暂休眠,避免频繁重试
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
} finally {
// 关闭资源
if (rs != null) {
rs.close();
}
if (selectPs != null) {
selectPs.close();
}
if (updatePs != null) {
updatePs.close();
}
if (conn != null) {
conn.close();
}
}
}
// 重试多次仍失败
return false;
}
}
不同方案的对比与适用场景
除了这种CAS式的实现,MySQL中处理多线程更新同一行数据还有其他常见方案,我们可以通过表格对比不同方案的特点:
| 方案 | 实现思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 行锁(FOR UPDATE) | 查询时加排他行锁,锁定该行直到事务提交 | 实现简单,并发安全有保障 | 锁持有时间长会影响并发性能,可能导致死锁 | 更新逻辑复杂,需要同时更新多个字段的场景 |
| 乐观锁(版本号) | 表中加版本号字段,更新时判断版本号是否匹配 | 读多写少场景下性能较好,无死锁风险 | 需要额外维护版本号字段,写冲突多时重试次数多 | 读多写少,并发冲突不频繁的场景 |
| CAS式更新 | 利用UPDATE条件判断实现比较交换,配合重试 | 无需额外字段,实现轻量,原子性由数据库保证 | 写冲突多时重试开销大,仅适用于单字段或简单更新逻辑 | 简单的单字段更新,并发冲突频率中等的场景 |
注意事项
- 使用CAS式更新时,要确保UPDATE语句的条件能够准确匹配旧预期值,避免条件遗漏导致更新异常。
- 重试次数需要设置上限,避免无限重试导致线程阻塞,重试间隔可以根据业务场景调整。
- 如果更新涉及多个字段或者复杂的业务逻辑,CAS式更新可能不够灵活,此时可以考虑行锁或者乐观锁方案。
- MySQL的UPDATE语句在InnoDB引擎下默认是行级锁,只要WHERE条件命中索引,就不会锁表,性能有保障。
总的来说,MySQL中处理多线程更新同一行数据时,借鉴CAS无锁编程的思维是可行的,通过UPDATE语句的条件判断配合重试机制,就能实现轻量级的并发控制,开发者可以根据实际的业务场景选择合适的方案。