一、通过DeepSeek辅助定位问题
笔者问:
假如你是一名资深的Java开发工程师,并对MySQL很熟悉。根据MySQL事物隔离级别,我现在有个情况。有张需求表,本来商品a的s尺码是有需求5件。现在来了3笔订单,每笔都包含商品a的s尺码5件。相当于并发调用订单与需求匹配的方法,该方法使用了spring事务注解,逻辑是查需求表,判断是否还有需求未满足,有未满足就满足它,并最后修改需求表为已经满足。请问这种情况是否会导致5件需求满足15件订单?
DeepSeek答:
MySQL 支持四种事务隔离级别,分别是 READ UNCOMMITTED 、READ COMMITTED 、REPEATABLE READ 和 SERIALIZABLE。每种隔离级别定义了事务在并发操作时的可见性和一致性行为。隔离级别越高,数据一致性越强,但并发性能越低。
1. READ UNCOMMITTED(读未提交)
- 定义:事务可以读取其他事务未提交的数据(即"脏读")。
- 问题:可能导致脏读、不可重复读和幻读。
- 适用场景:对数据一致性要求极低的场景,如统计分析的近似值。
例子:
事务 A 和事务 B 同时运行:
- 事务 A 更新了一条记录,将
quantity
从 5 改为 3,但尚未提交。 - 事务 B 读取了这条记录,看到
quantity
是 3(事务 A 未提交的数据)。 - 如果事务 A 回滚,事务 B 读取到的数据就是无效的(脏读)。
2. READ COMMITTED(读已提交)
- 定义:事务只能读取其他事务已提交的数据,避免了脏读。
- 问题:可能导致不可重复读和幻读。
- 适用场景:对数据一致性有一定要求,但允许不可重复读的场景。
例子:
事务 A 和事务 B 同时运行:
- 事务 A 读取
quantity
,值为 5。 - 事务 B 更新
quantity
为 3 并提交。 - 事务 A 再次读取
quantity
,发现值变为 3(与第一次读取的值不一致,即不可重复读)。
3. REPEATABLE READ(可重复读,MySQL 默认隔离级别)
- 定义:事务在执行期间多次读取同一数据时,结果是一致的(即使其他事务修改了数据并提交)。
- 问题:可能导致幻读(插入或删除操作)。
- 适用场景:对数据一致性要求较高的场景。
例子:
事务 A 和事务 B 同时运行:
- 事务 A 读取
quantity
,值为 5。 - 事务 B 更新
quantity
为 3 并提交。 - 事务 A 再次读取
quantity
,值仍然是 5(可重复读)。 - 但如果事务 B 插入了一条新记录,事务 A 可能会看到新记录(幻读)。
4. SERIALIZABLE(串行化)
- 定义:事务完全串行执行,避免了脏读、不可重复读和幻读。
- 问题:并发性能极低,因为所有操作都需要加锁。
- 适用场景:对数据一致性要求极高的场景,如金融交易。
例子:
事务 A 和事务 B 同时运行:
- 事务 A 读取
quantity
,值为 5。 - 事务 B 尝试更新
quantity
,但会被阻塞,直到事务 A 提交或回滚。 - 事务 A 提交后,事务 B 才能继续执行。
隔离级别对比
隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 |
---|---|---|---|---|
READ UNCOMMITTED | ✔️ | ✔️ | ✔️ | 最高 |
READ COMMITTED | ✖️ | ✔️ | ✔️ | 较高 |
REPEATABLE READ | ✖️ | ✖️ | ✔️ | 中等 |
SERIALIZABLE | ✖️ | ✖️ | ✖️ | 最低 |
如何设置隔离级别
在 MySQL 中,可以通过以下语句设置事务隔离级别:
sql
-- 设置会话级别隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局级别隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 查看当前隔离级别
SELECT @@tx_isolation;
总结
- READ UNCOMMITTED:性能最高,但数据一致性最差。
- READ COMMITTED:避免了脏读,但可能存在不可重复读和幻读。
- REPEATABLE READ:MySQL 默认级别,避免了脏读和不可重复读,但可能存在幻读。
- SERIALIZABLE:数据一致性最强,但性能最差。
根据业务需求选择合适的隔离级别,平衡数据一致性和并发性能。
总体来说,回答还是令我比较满意的,毕竟我的描述个人感觉也比较笼统的。但是继续追问DeepSeek然后就是&@&*#¥......(可能是最近太热门了,资源有限)。只能转而询问chatGPT了。
读者注意,其实到这里我已经能够定位好问题了,继续追问是对知识点的查漏补缺
二,对MVCC和幻读的查漏补缺
笔者问1:
假如你是一名资深的java开发工程师,并对mysql的事务隔离级别很熟悉。请问mysql的可重复读是如何实现的,mvcc?那为什么这仍然不能避免幻读。注,我理解的是可重复读避免的读到已提交的修改数据,但仍然不能避免的读到提交的删除或新增数据。
chatGPT答1:
在MySQL中,InnoDB存储引擎支持多种事务隔离级别,其中"可重复读"(Repeatable Read)是默认的隔离级别。这一级别的主要目的是确保在一个事务内多次执行相同的查询时,返回的结果集是一致的,即不会看到其他事务已经提交的修改。
可重复读的实现
可重复读的实现依赖于多版本并发控制(MVCC, Multi-Version Concurrency Control)。MVCC允许数据库系统在同一时间点维护多个数据版本,从而减少锁的竞争,提高并发性能。具体来说,InnoDB通过以下机制实现可重复读:
-
快照读(Snapshot Read):当一个事务开始时,InnoDB会为该事务创建一个一致性的读视图(consistent read view)。这个读视图包含了事务开始时所有已提交的数据版本。事务在执行查询时,只会看到这个读视图中的数据版本,而不会看到其他事务在此之后提交的数据。
-
事务ID和数据版本 :每个事务在开始时都会被分配一个唯一的事务ID(transaction ID)。每行数据都有两个隐藏的字段:
DB_TRX_ID
和DB_ROLL_PTR
。DB_TRX_ID
记录了最后一次插入或更新该行数据的事务ID,DB_ROLL_PTR
是一个回滚指针,指向该行数据的旧版本。 -
数据版本链 :每行数据的旧版本会形成一个版本链,通过
DB_ROLL_PTR
连接。事务在读取数据时,会根据其读视图检查数据版本链,找到符合读视图条件的版本。
幻读问题
尽管可重复读可以确保在一个事务内多次查询同一数据时结果一致,但它仍然不能完全避免幻读(Phantom Read)。幻读是指在一个事务中,两次执行相同的查询,第二次查询的结果集中出现了第一次查询时不存在的记录。
原因分析
-
范围查询:可重复读只保证单个记录的一致性读取,而不保证范围查询的一致性。例如,事务A在一个范围内进行查询,然后另一个事务B在这个范围内插入了一条新记录并提交,事务A再次查询时会看到这条新记录,这就是幻读。
-
锁定读 :为了防止幻读,可以使用锁定读(如
SELECT ... FOR UPDATE
或SELECT ... LOCK IN SHARE MODE
),这些操作会在查询时加锁,阻止其他事务在这段时间内插入新的记录。
解决方案
为了避免幻读,可以使用更高的隔离级别,如"串行化"(Serializable)。在串行化隔离级别下,事务会被强制按顺序执行,确保在一个事务内的所有查询都是完全隔离的,从而彻底避免幻读。然而,这种方式会大大降低系统的并发性能,因此在实际应用中需要权衡利弊。
总结
可重复读通过MVCC机制确保在一个事务内多次查询同一数据时结果一致,但不保证范围查询的一致性,因此仍可能存在幻读问题。解决幻读问题的方法包括使用锁定读或提高隔离级别到串行化。
笔者问2:
新增会产生幻读,那么修改呢?为什么mvcc不能解决幻读问题?
chatGPT答2:
在可重复读隔离级别下,MVCC主要解决了"不可重复读"和"脏读"的问题,但并未完全解决"幻读"的问题。让我们来详细讨论一下。
修改操作与可重复读
在可重复读隔离级别下,MVCC通过读视图确保事务在多次读取同一行数据时看到的是事务开始时的数据版本,从而避免了不可重复读和脏读的问题。对于修改操作,MVCC确保事务读取到的是自己所期望的数据版本,而不会看到其他事务未提交的修改。
具体来说,当一个事务修改了一行数据,这个修改仅对其自身可见,直到事务提交。其他事务在可重复读隔离级别下,仍然会看到事务开始时的数据版本,不会看到未提交的修改。
幻读问题
幻读主要与插入和删除操作相关,而不是修改操作。幻读是指在一个事务内,多次执行相同的范围查询,但结果集中的行数不同,因为其他事务在该范围内插入或删除了数据。
MVCC通过维护数据的多个版本来处理读操作,但对于插入和删除操作,MVCC的处理方式使得可重复读隔离级别下仍然可能产生幻读。
为什么MVCC不能解决幻读问题?
在可重复读隔离级别下,MVCC通过读视图来决定事务可见的数据版本。具体来说:
-
插入操作:当其他事务在当前事务的读视图创建之后插入新数据时,这些新插入的数据对当前事务不可见,因为它们的事务ID在当前事务的读视图创建之后。
-
删除操作:类似地,当其他事务删除数据时,被删除的数据对于当前事务仍然可见,因为它们的事务ID在读视图创建之前。
然而,在范围查询的情况下,如果其他事务插入了新的数据,这些新数据可能在当前事务的后续查询中出现,导致幻读。
解决方案
为了防止幻读,可以使用更严格的隔离级别,如串行化(Serializable),或者在查询时使用共享锁(SELECT ... LOCK IN SHARE MODE)或排他锁(SELECT ... FOR UPDATE),以阻止其他事务在锁定期间插入新数据。
结论
MVCC在可重复读隔离级别下解决了不可重复读和脏读的问题,但对于幻读问题,由于涉及范围查询中新数据的插入,MVCC本身无法完全解决。因此,需要更高的隔离级别或显式的锁机制来防止幻读。
接着我去项目里翻了翻代码,并找了下reids锁相关的代码。
三、辅助判断代码逻辑准确性
笔者问:
假如你是一名资深的java开发工程师,并对redis锁很熟悉。请讲解一下,以下方法的加锁和释放锁的实际redis数据变化情况。是如何实现数据锁的应用的。
java
/**
* redis锁模板方法
* @author yzc
* @version 2.0.0
* @since 2021/7/15
*/
public class LockTemplate {
private static final Logger log = LoggerFactory.getLogger(LockTemplate.class);
private final RedisLockExecutor lockExecutor;
public LockTemplate(RedisLockExecutor lockExecutor) {
this.lockExecutor = lockExecutor;
}
public LockInfo lock(String key) {
return lock(key, 0, -1);
}
/**
* 加锁方法
*
* @param key 锁key 同一个key只能被一个客户端持有
* @param expire 过期时间(ms) 防止死锁
* @param acquireTimeout 尝试获取锁超时时间(ms)
* @return 加锁成功返回锁信息 失败返回null
*/
public LockInfo lock(String key, long expire, long acquireTimeout) {
expire = expire <= 0 ? LockConstant.expire : expire;
acquireTimeout = acquireTimeout < 0 ? LockConstant.acquireTimeout : acquireTimeout;
long retryInterval = LockConstant.retryInterval;
int acquireCount = 0;
String value = UUIDUtil.randomUUID();
long start = System.currentTimeMillis();
try {
do {
acquireCount++;
String lockInstance = lockExecutor.acquire(key, value, expire, acquireTimeout);
if (null != lockInstance) {
return new LockInfo(key, value, expire, acquireTimeout, acquireCount, lockInstance,
lockExecutor);
}
TimeUnit.MILLISECONDS.sleep(retryInterval);
} while (System.currentTimeMillis() - start < acquireTimeout);
} catch (InterruptedException e) {
log.error("lock error", e);
throw new LockException();
}
return null;
}
/**
* 释放锁方法
* @param lockInfo 锁信息
* @return 成功true, 失败false
*/
public boolean releaseLock(LockInfo lockInfo) {
if (null == lockInfo) {
return false;
}
return lockExecutor.releaseLock(lockInfo.getLockKey(), lockInfo.getLockValue(),
lockInfo.getLockInstance());
}
}
java
public class RedisLockExecutor {
private static final RedisScript<String> SCRIPT_LOCK = new DefaultRedisScript<>("return redis.call('set',KEYS[1]," +
"ARGV[1],'NX','PX',ARGV[2])", String.class);
private static final RedisScript<String> SCRIPT_UNLOCK = new DefaultRedisScript<>("if redis.call('get',KEYS[1]) " +
"== ARGV[1] then return tostring(redis.call('del', KEYS[1])==1) else return 'false' end", String.class);
private static final String LOCK_SUCCESS = "OK";
private final StringRedisTemplate stringRedisTemplate;
public RedisLockExecutor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public String acquire(String lockKey, String lockValue, long expire, long acquireTimeout) {
String lock = stringRedisTemplate.execute(SCRIPT_LOCK,
stringRedisTemplate.getStringSerializer(),
stringRedisTemplate.getStringSerializer(),
Collections.singletonList(lockKey),
lockValue, String.valueOf(expire));
final boolean locked = LOCK_SUCCESS.equals(lock);
return locked ? lock : null;
}
public boolean releaseLock(String key, String value, String lockInstance) {
String releaseResult = stringRedisTemplate.execute(SCRIPT_UNLOCK,
stringRedisTemplate.getStringSerializer(),
stringRedisTemplate.getStringSerializer(),
Collections.singletonList(key), value);
return Boolean.parseBoolean(releaseResult);
}
}
chatGPT答:
好的,让我们详细讲解一下 LockTemplate
和 RedisLockExecutor
类中的加锁和释放锁方法在 Redis 中的实际数据变化情况,以及它们是如何实现数据锁的应用的。
1. 加锁方法
LockTemplate.lock
方法
java
public LockInfo lock(String key, long expire, long acquireTimeout) {
expire = expire <= 0 ? LockConstant.expire : expire;
acquireTimeout = acquireTimeout < 0 ? LockConstant.acquireTimeout : acquireTimeout;
long retryInterval = LockConstant.retryInterval;
int acquireCount = 0;
String value = UUIDUtil.randomUUID();
long start = System.currentTimeMillis();
try {
do {
acquireCount++;
String lockInstance = lockExecutor.acquire(key, value, expire, acquireTimeout);
if (null != lockInstance) {
return new LockInfo(key, value, expire, acquireTimeout, acquireCount, lockInstance,
lockExecutor);
}
TimeUnit.MILLISECONDS.sleep(retryInterval);
} while (System.currentTimeMillis() - start < acquireTimeout);
} catch (InterruptedException e) {
log.error("lock error", e);
throw new LockException();
}
return null;
}
RedisLockExecutor.acquire
方法
java
public String acquire(String lockKey, String lockValue, long expire, long acquireTimeout) {
String lock = stringRedisTemplate.execute(SCRIPT_LOCK,
stringRedisTemplate.getStringSerializer(),
stringRedisTemplate.getStringSerializer(),
Collections.singletonList(lockKey),
lockValue, String.valueOf(expire));
final boolean locked = LOCK_SUCCESS.equals(lock);
return locked ? lock : null;
}
实现加锁的 Redis 脚本
lua
return redis.call('set', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])
数据变化过程
-
尝试加锁:
- 调用
stringRedisTemplate.execute(SCRIPT_LOCK, ...)
方法时,Redis 会执行上述 Lua 脚本。 - 脚本尝试将
lockKey
设置为lockValue
,并设置过期时间expire
。 NX
参数表示如果lockKey
不存在,则设置成功;如果lockKey
已存在,则设置失败。PX
参数表示设置的过期时间以毫秒为单位。
- 调用
-
加锁成功:
- 如果
lockKey
不存在, Redis 会成功设置lockKey
,返回OK
。 RedisLockExecutor.acquire
方法返回OK
,表示加锁成功。
- 如果
-
加锁失败:
- 如果
lockKey
已存在, Redis 会返回null
。 RedisLockExecutor.acquire
方法返回null
,表示加锁失败。
- 如果
-
重试机制:
- 如果加锁失败,
LockTemplate.lock
方法会根据retryInterval
等待一段时间后重新尝试加锁。 - 这个过程会持续到
acquireTimeout
时间到达,或者加锁成功为止。
- 如果加锁失败,
2. 释放锁方法
LockTemplate.releaseLock
方法
java
public boolean releaseLock(LockInfo lockInfo) {
if (null == lockInfo) {
return false;
}
return lockExecutor.releaseLock(lockInfo.getLockKey(), lockInfo.getLockValue(),
lockInfo.getLockInstance());
}
RedisLockExecutor.releaseLock
方法
java
public boolean releaseLock(String key, String value, String lockInstance) {
String releaseResult = stringRedisTemplate.execute(SCRIPT_UNLOCK,
stringRedisTemplate.getStringSerializer(),
stringRedisTemplate.getStringSerializer(),
Collections.singletonList(key), value);
return Boolean.parseBoolean(releaseResult);
}
实现释放锁的 Redis 脚本
lua
if redis.call('get', KEYS[1]) == ARGV[1] then
return tostring(redis.call('del', KEYS[1]) == 1)
else
return 'false'
end
数据变化过程
-
检查锁归属:
- 调用
stringRedisTemplate.execute(SCRIPT_UNLOCK, ...)
方法时,Redis 会执行上述 Lua 脚本。 - 脚本首先检查
lockKey
的当前值是否等于lockValue
。
- 调用
-
释放锁:
- 如果
lockKey
的值等于lockValue
,脚本会删除lockKey
,并返回true
。 - 如果
lockKey
的值不等于lockValue
,脚本返回false
。
- 如果
-
返回结果:
RedisLockExecutor.releaseLock
方法根据返回值true
或false
决定是否释放锁成功。
总结
- 加锁 :通过 Redis 的
SET
命令及其NX
和PX
参数,确保只有在lockKey
不存在时才能成功设置锁,并设置了过期时间以防止死锁。 - 释放锁 :通过 Lua 脚本确保只有持有锁的客户端才能删除
lockKey
,防止误删其他客户端的锁。 - 重试机制 :在加锁失败时,通过
retryInterval
和acquireTimeout
控制重试次数和超时时间,确保在合理的时间范围内尝试获取锁。
通过这些机制,LockTemplate
和 RedisLockExecutor
实现了可靠的分布式锁功能,确保在分布式系统中能够安全地进行资源锁定和释放。
四、最终在我实际代码中应用redisTemplate如下
java
@Transactional(rollbackFor = Exception.class)
@Override
public Boolean allotBatchReleaseBySupplierOrderNos(List<String> supplierOrderNos) {
// 查询订单数据
List<DemOrderFinalSyncSupplyConfirmDO> confirmList = syncSupplyConfirmRepository.findByOrderNoInAndBusinessTypeAndIsAllot(supplierOrderNos, DemOrderBusinessScenesEnum.BATCH_RELEASE.getCode(),DemOrderConstants.ALLOT_FALSE);
if (CollectionUtils.isEmpty(confirmList)) {
throw new BusinessException("找不到需要上新分货的数据");
}
// 根据需求订单循环分货
List<String> errorConfirmSkcIds = new ArrayList<>();
Map<String, List<DemOrderFinalSyncSupplyConfirmDO>> collect = confirmList.stream().collect(Collectors.groupingBy(DemOrderFinalSyncSupplyConfirmDO::getDemandNo));
for (Map.Entry<String, List<DemOrderFinalSyncSupplyConfirmDO>> entry : collect.entrySet()) {
String demandOrderNo = entry.getKey();
LockInfo lock = null;
try {
String demandOrderNoKey = RedisConstant.REDIS_KEY_DEMAND_BATCH_RELEASE_ALLOT_PREFIX + ":" + demandOrderNo;
// 获取锁,expire过期时间1min,acquireTimeOut尝试获取锁超时时间30s
lock = lockTemplate.lock(demandOrderNoKey, 60000L, 30000L);
if (lock != null) {
errorConfirmSkcIds.addAll(doAllotProcess(entry.getKey(), entry.getValue()));
}
} catch (LockException le) {
log.error("需求订单{},上新分货加锁异常:",demandOrderNo, le);
} catch (Exception e) {
log.error("需求订单{},处理上新分货异常:",demandOrderNo, e);
} finally {
boolean result = lockTemplate.releaseLock(lock);
if (!result && lock != null) {
log.warn("需求订单{},上新分货解锁失败",demandOrderNo);
}
}
}
// 更新同步表数据状态
syncSupplyConfirmRepository.updateAllocatedByOrderNos(supplierOrderNos,errorConfirmSkcIds);
syncSupplyConfirmRepository.updateAllotFailByOrderNos(supplierOrderNos,errorConfirmSkcIds);
// 打印日志
errorConfirmSkcIds.sort(Comparator.naturalOrder());
log.info("错码数据如下:{}", errorConfirmSkcIds);
return Boolean.TRUE;
}