MySQL死锁与锁等待排查:入库上架和库存流水案例

死锁不是 MySQL 的异常行为,而是并发系统的正常风险。只要多个事务以不同顺序持有资源并等待对方,就可能死锁。InnoDB 会自动检测死锁,并回滚其中一个事务。业务系统需要做的是降低死锁概率,并在发生死锁时能快速定位和恢复。

供应链系统里,入库上架、库存转移、订单扣减都容易出现死锁,因为这些流程通常会同时更新库存主表、库存流水表、批次表和单据状态。

死锁排查流程

MySQL 死锁和锁等待排查流程

入库上架死锁案例

入库上架时,系统要把待上架数量转成可用库存:

1
2
3
4
5
6
7
8
9
10
UPDATE scm_inventory
SET available_qty = available_qty + 10
WHERE warehouse_id = 8 AND sku_id = 1001;

INSERT INTO scm_inventory_log(...)
VALUES (...);

UPDATE scm_receipt_detail
SET putaway_qty = putaway_qty + 10
WHERE receipt_id = 5001 AND sku_id = 1001;

另一个任务可能先更新入库明细,再更新库存:

1
2
3
4
5
6
7
UPDATE scm_receipt_detail
SET checked_qty = checked_qty + 10
WHERE receipt_id = 5001 AND sku_id = 1001;

UPDATE scm_inventory
SET available_qty = available_qty + 10
WHERE warehouse_id = 8 AND sku_id = 1001;

事务 A 持有库存记录,等待入库明细。事务 B 持有入库明细,等待库存记录。两边互相等待,就形成死锁。

死锁的四个条件

死锁通常满足四个条件:

  • 互斥:资源一次只能被一个事务持有。
  • 持有并等待:事务持有一个锁,同时等待另一个锁。
  • 不可抢占:锁只能由持有者提交或回滚后释放。
  • 循环等待:多个事务形成等待环。

业务上最容易控制的是循环等待。只要固定加锁顺序,就能显著降低死锁概率。

固定加锁顺序

对库存转移来说,要从 A 库位转到 B 库位。两个事务方向相反时容易死锁:

1
2
事务 1:锁 A,再锁 B
事务 2:锁 B,再锁 A

解决办法是按稳定规则排序,比如按库存记录 ID 升序加锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
public void transfer(long fromInventoryId, long toInventoryId, int qty) {
List<Long> ids = Stream.of(fromInventoryId, toInventoryId)
.sorted()
.toList();

inventoryMapper.selectForUpdate(ids.get(0));
inventoryMapper.selectForUpdate(ids.get(1));

inventoryMapper.decrease(fromInventoryId, qty);
inventoryMapper.increase(toInventoryId, qty);
inventoryLogMapper.insertTransferLog(fromInventoryId, toInventoryId, qty);
}

SQL:

1
2
3
4
SELECT id
FROM scm_inventory
WHERE id = #{id}
FOR UPDATE;

即使业务方向不同,底层锁顺序一致,死锁概率会低很多。

缩短事务范围

错误做法:

1
2
3
4
5
6
7
@Transactional
public void putaway(PutawayCommand command) {
inventoryMapper.lockInventory(command.skuId());
supplierClient.checkQuality(command.supplierId());
wmsClient.notifyPutaway(command.taskId());
inventoryMapper.increase(command.skuId(), command.qty());
}

这段代码在事务里做远程调用。远程调用期间数据库锁一直不释放,任何并发库存操作都可能等待。

改进方式是把远程调用放在事务外,事务里只做核心数据变更:

1
2
3
4
5
6
7
8
9
10
11
12
public void putaway(PutawayCommand command) {
supplierClient.checkQuality(command.supplierId());
doPutawayInTransaction(command);
eventPublisher.publishPutawayFinished(command.taskId());
}

@Transactional
public void doPutawayInTransaction(PutawayCommand command) {
inventoryMapper.increase(command.skuId(), command.qty());
receiptMapper.markPutaway(command.receiptDetailId(), command.qty());
inventoryLogMapper.insertPutawayLog(command);
}

如何排查锁等待

第一步看当前连接:

1
SHOW PROCESSLIST;

第二步看 InnoDB 状态:

1
SHOW ENGINE INNODB STATUS;

重点找:

  • LATEST DETECTED DEADLOCK
  • 等待的锁类型。
  • 等待的索引。
  • 涉及的 SQL。
  • 哪个事务被回滚。

MySQL 8 可以查询:

1
2
3
4
5
SELECT *
FROM performance_schema.data_locks;

SELECT *
FROM performance_schema.data_lock_waits;

结合事务表可以定位阻塞链:

1
2
3
4
5
6
7
8
9
SELECT r.trx_id waiting_trx,
r.trx_mysql_thread_id waiting_thread,
b.trx_id blocking_trx,
b.trx_mysql_thread_id blocking_thread
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b
ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r
ON r.trx_id = w.requesting_trx_id;

不同版本系统表略有差异,核心思路是找到等待者、阻塞者和对应 SQL。

业务层重试

死锁发生时,InnoDB 会回滚一个事务。对于库存扣减、上架、状态流转这类短事务,可以做有限重试:

1
2
3
4
5
6
7
8
9
10
11
public void putawayWithRetry(PutawayCommand command) {
for (int i = 0; i < 3; i++) {
try {
putawayService.doPutawayInTransaction(command);
return;
} catch (DeadlockLoserDataAccessException e) {
sleep(50L * (i + 1));
}
}
throw new BizException("上架繁忙,请稍后重试");
}

重试必须满足幂等。比如上架流水要有业务唯一键,避免第一次事务部分成功但应用误判后重复写入。通常建议给业务流水加唯一键:

1
UNIQUE KEY uk_biz_event (biz_type, biz_id)

小结

死锁排查不能只看异常栈,要回到事务里的 SQL 顺序、索引和持锁时间。供应链系统降低死锁的关键措施是:固定加锁顺序,缩短事务范围,避免事务内远程调用,保证 SQL 命中索引,对可重试短事务做有限幂等重试。

Java ReentrantLock和Condition:波次拣货调度队列设计

ReentrantLock 是 Java 显式锁。相比 synchronized,它提供了更丰富的控制能力:可以尝试加锁、可以响应中断、可以设置公平锁,还可以通过多个 Condition 管理不同等待条件。

在供应链仓储系统里,波次拣货是一个很适合解释 ReentrantLock 的场景。订单进入仓库后,系统会把多个订单合并成拣货波次。波次生成线程负责投放任务,拣货线程负责消费任务。如果没有合适的等待和唤醒机制,要么线程空转浪费 CPU,要么任务延迟处理。

波次队列协作流程

ReentrantLock 和 Condition 波次队列流程

一个波次队列

需求如下:

  • 波次生成器把 PickWave 放入队列。
  • 拣货工作线程从队列获取波次。
  • 队列满时,生成器等待。
  • 队列空时,拣货线程等待。

这正是 Condition 的典型使用场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class PickWaveQueue {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();

private final Deque<PickWave> queue = new ArrayDeque<>();
private final int capacity;

public PickWaveQueue(int capacity) {
this.capacity = capacity;
}

public void put(PickWave wave) throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.addLast(wave);
notEmpty.signal();
} finally {
lock.unlock();
}
}

public PickWave take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
PickWave wave = queue.removeFirst();
notFull.signal();
return wave;
} finally {
lock.unlock();
}
}
}

这个例子和 BlockingQueue 很像。真实项目中优先使用 JDK 的 ArrayBlockingQueueLinkedBlockingQueue。这里手写是为了说明 ReentrantLockCondition 的工作方式。

为什么用 while 而不是 if

等待条件必须用 while

1
2
3
while (queue.isEmpty()) {
notEmpty.await();
}

原因有两个。第一,线程可能虚假唤醒。第二,线程被唤醒后不代表条件仍然成立。比如多个拣货线程都被唤醒,只有一个线程拿到了波次,其他线程再次检查时队列又空了。

供应链任务调度里,如果这里用 if,可能出现空队列取任务、重复消费或异常退出。

tryLock 适合避免线程池耗尽

有些业务不适合无限等待锁。比如波次重算任务需要锁住某个仓库的计划上下文,如果拿不到锁,可以返回“系统正在计算,请稍后重试”。

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean rebuildWavePlan(long warehouseId) throws InterruptedException {
if (!lock.tryLock(3, TimeUnit.SECONDS)) {
return false;
}
try {
loadOrders(warehouseId);
allocateBins(warehouseId);
savePlan(warehouseId);
return true;
} finally {
lock.unlock();
}
}

tryLock 的价值是给系统退路。线程池里的线程不应该因为等待一把锁全部卡死。

公平锁和非公平锁

ReentrantLock 默认是非公平锁。新来的线程可能插队获取锁,吞吐通常更高。

1
private final ReentrantLock lock = new ReentrantLock();

公平锁按等待顺序获取锁:

1
private final ReentrantLock fairLock = new ReentrantLock(true);

供应链后台任务多数更关注吞吐,非公平锁更常用。只有在明确存在饥饿问题,比如某类低优先级补货任务长期拿不到锁,才需要评估公平锁。但公平锁会增加调度成本,不应该默认使用。

和数据库事务配合

Java 锁保护的是内存队列,不保护数据库里的波次状态。真正创建波次时仍然要用数据库状态条件防重:

1
2
3
4
UPDATE scm_pick_wave
SET status = 'PROCESSING'
WHERE id = #{waveId}
AND status = 'WAIT_PROCESS';

Java 工作线程拿到波次后:

1
2
3
4
5
6
7
8
9
@Transactional
public void process(PickWave wave) {
int affected = waveMapper.markProcessing(wave.id());
if (affected != 1) {
return;
}
allocatePickTasks(wave);
waveMapper.markReady(wave.id());
}

即使同一个波次因为重试被投递两次,数据库状态条件也能保证只有一个线程真正处理成功。

小结

ReentrantLock 适合需要超时、可中断、多个等待条件的并发控制。供应链系统里的波次队列、任务调度、仓库计划重算都能用它解释。但真实项目中,能用成熟并发容器就先用并发容器;数据库状态变更必须继续依赖条件更新,不能只靠 JVM 锁。

Java volatile与JMM:库存同步任务的可见性问题

volatile 是 Java 并发里经常被误用的关键字。它能保证变量修改对其他线程可见,并禁止相关指令重排,但它不是互斥锁,也不能保证复合操作的原子性。

供应链系统中,volatile 最适合的场景是任务开关、配置引用、状态标记。比如库存同步任务需要能被后台管理页面停止,或者定时任务需要感知配置已刷新。

volatile 发布流程

volatile 可见性发布流程

JMM 要解决什么

Java 内存模型规定了线程如何从主内存读取变量、如何把修改写回主内存,以及什么情况下一个线程的写入对另一个线程可见。

如果没有同步手段,一个线程修改变量,另一个线程不一定马上看到。CPU 缓存、编译器优化、指令重排都会让多线程程序表现得不符合直觉。

库存同步任务的停止标志是典型例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InventorySyncTask implements Runnable {
private boolean running = true;

public void stop() {
running = false;
}

@Override
public void run() {
while (running) {
syncOnce();
}
}
}

管理线程调用 stop() 后,工作线程可能仍然看不到 running = false,导致任务停不下来。

使用 volatile 修复可见性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InventorySyncTask implements Runnable {
private volatile boolean running = true;

public void stop() {
running = false;
}

@Override
public void run() {
while (running) {
syncOnce();
}
}
}

volatile 写入会把修改刷新出去,volatile 读取会从可见位置重新读取。这样管理线程修改停止标志后,工作线程能及时观察到。

这类代码在供应链系统中很常见:库存同步、价格同步、承运商轨迹拉取、供应商主数据刷新,都可能需要停止标志。

volatile 不保证 count++ 安全

错误示例:

1
2
3
4
5
6
7
public class SyncMetrics {
private volatile int successCount = 0;

public void incrementSuccess() {
successCount++;
}
}

successCount++ 不是一个原子操作,它包含读取、加一、写回。多个线程同时执行时会丢失更新。

正确写法可以用 AtomicInteger

1
2
3
4
5
6
7
public class SyncMetrics {
private final AtomicInteger successCount = new AtomicInteger();

public void incrementSuccess() {
successCount.incrementAndGet();
}
}

如果是高并发统计,比如每秒记录库存同步成功数,用 LongAdder 更合适:

1
2
3
4
5
6
7
8
9
10
11
public class SyncMetrics {
private final LongAdder successCount = new LongAdder();

public void incrementSuccess() {
successCount.increment();
}

public long successCount() {
return successCount.sum();
}
}

配置引用的安全发布

volatile 还适合发布不可变配置对象。比如供应链系统里有库存分配策略:

1
2
3
4
5
6
7
8
9
10
11
public class AllocationPolicyHolder {
private volatile AllocationPolicy policy = AllocationPolicy.defaultPolicy();

public AllocationPolicy current() {
return policy;
}

public void refresh(AllocationPolicy newPolicy) {
this.policy = newPolicy;
}
}

只要 AllocationPolicy 本身是不可变对象,刷新时整体替换引用,读线程就能看到完整的新配置。

不要这样做:

1
policy.getRules().add(newRule);

如果对象内部可变,即使引用是 volatile,内部集合的并发修改仍然不安全。配置对象应设计成不可变:

1
2
3
4
5
6
7
8
9
10
11
public final class AllocationPolicy {
private final List<Rule> rules;

public AllocationPolicy(List<Rule> rules) {
this.rules = List.copyOf(rules);
}

public List<Rule> rules() {
return rules;
}
}

happens-before 关系

volatile 写 happens-before 后续对同一变量的读。简单理解:线程 A 在写 volatile 变量之前做的普通写入,线程 B 读到这个 volatile 变量后,也能看到这些普通写入。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private Map<String, Integer> latestStock;
private volatile boolean ready;

public void load() {
latestStock = loadFromDatabase();
ready = true;
}

public int query(String sku) {
if (!ready) {
return 0;
}
return latestStock.getOrDefault(sku, 0);
}

当查询线程读到 ready = true 后,可以看到 latestStock 的赋值。但这个写法仍要求 latestStock 后续不被并发修改。更稳的做法是用不可变 Map 或整体替换引用。

小结

volatile 适合表达“状态变化要被其他线程看到”。它不适合保护多个变量的一致性,也不适合做计数器自增。供应链系统里,任务停止标志、配置引用、只写一次的发布状态可以使用 volatile;库存扣减、单据状态变更、计数统计要用锁、原子类或数据库条件更新。

Java synchronized与Monitor:仓库作业状态机的互斥控制

synchronized 是 Java 最基础的互斥机制。它的语义直接:进入同步代码前获取对象监视器,退出时释放监视器。对供应链系统来说,它适合保护 JVM 内部的小范围共享状态,比如本地缓存、状态机对象、批处理任务上下文。

需要先明确边界:synchronized 只在当前 JVM 内有效。如果系统部署了多个实例,它不能保护数据库里的订单或库存。跨实例一致性仍然要依赖数据库锁、唯一约束、分布式锁或消息幂等。

Monitor 互斥流程

synchronized 与 Monitor 互斥流程

仓库作业状态机例子

仓库作业单可能有这些状态:

1
CREATED -> PICKING -> PACKED -> SHIPPED

如果状态机对象在内存里被多个线程访问,就要保证状态转换互斥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class WarehouseTaskStateMachine {
private String status = "CREATED";

public synchronized void startPicking() {
if (!"CREATED".equals(status)) {
throw new IllegalStateException("只有已创建任务才能开始拣货");
}
status = "PICKING";
}

public synchronized void pack() {
if (!"PICKING".equals(status)) {
throw new IllegalStateException("只有拣货中任务才能打包");
}
status = "PACKED";
}

public synchronized String currentStatus() {
return status;
}
}

这里锁对象是 this。同一个状态机实例的三个同步方法互斥,避免一个线程正在从 CREATED 改为 PICKING 时,另一个线程同时执行 pack()

synchronized 锁住的到底是谁

实例方法:

1
public synchronized void method() {}

等价于:

1
2
3
4
5
public void method() {
synchronized (this) {
// ...
}
}

静态方法锁住的是 Class 对象:

1
public static synchronized void reloadGlobalConfig() {}

等价于:

1
2
3
synchronized (ConfigCenter.class) {
// ...
}

代码块可以指定任意锁对象:

1
2
3
4
5
6
7
private final Object lock = new Object();

public void changeStatus() {
synchronized (lock) {
// 修改共享状态
}
}

工程上更推荐使用私有 final 锁对象,避免外部代码拿到 this 后参与锁竞争。

可重入性

synchronized 是可重入锁。同一个线程已经持有某对象锁时,可以再次进入同一把锁保护的代码。

1
2
3
4
5
6
7
8
9
10
public synchronized void finishPicking() {
validatePicking();
status = "PACKED";
}

private synchronized void validatePicking() {
if (!"PICKING".equals(status)) {
throw new IllegalStateException("状态错误");
}
}

finishPicking() 调用 validatePicking() 不会死锁,因为持锁线程可以重入。可重入性让同步方法之间的组合更自然,但也要避免同步方法调用链过深,导致锁持有时间不可控。

wait 和 notify 的条件协作

如果仓库打包线程要等拣货完成,可以使用 wait/notify,但要严格放在同步块里,并用 while 判断条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PackingGate {
private boolean pickingFinished = false;

public synchronized void finishPicking() {
pickingFinished = true;
notifyAll();
}

public synchronized void waitForPicking() throws InterruptedException {
while (!pickingFinished) {
wait();
}
}
}

使用 while 而不是 if,是为了防止虚假唤醒或被唤醒后条件已经被其他线程改变。实际项目中,如果等待条件复杂,更建议使用 BlockingQueueCountDownLatchCondition 等工具。

不要锁住慢操作

下面是错误示例:

1
2
3
4
5
public synchronized void ship(long taskId) {
updateLocalStatus(taskId, "SHIPPING");
wmsClient.notifyShipment(taskId);
updateLocalStatus(taskId, "SHIPPED");
}

远程调用 wmsClient.notifyShipment 可能耗时几百毫秒甚至超时。它在同步方法里执行,会导致其他线程长时间无法进入同一对象的同步方法。

更合理的做法是缩小锁范围:

1
2
3
4
5
6
7
8
9
10
11
public void ship(long taskId) {
synchronized (lock) {
updateLocalStatus(taskId, "SHIPPING");
}

wmsClient.notifyShipment(taskId);

synchronized (lock) {
updateLocalStatus(taskId, "SHIPPED");
}
}

如果本地状态必须和远程调用严格一致,就不应该靠一个 JVM 锁解决,而应该用数据库状态机、消息表和补偿任务。

和数据库锁的边界

如果状态存在数据库里,Java 本地锁不能防止另一个应用实例修改同一记录。供应链系统通常是多实例部署,所以最终状态变更应落到数据库条件更新:

1
2
3
4
UPDATE scm_warehouse_task
SET status = 'PICKING'
WHERE id = #{taskId}
AND status = 'CREATED';

Java 的 synchronized 只适合保护当前实例内的辅助状态,比如本地缓存、批处理内存队列、状态机对象。核心业务一致性必须由数据库约束兜底。

小结

synchronized 的优点是简单、语义清晰、异常退出自动释放锁。它适合小范围、短时间、单 JVM 内的互斥。供应链系统使用它时,必须控制锁对象、缩小锁范围、避免锁住远程调用,并明确它不能替代数据库并发控制。

MySQL事务隔离级别和MVCC:订单可见性怎么保证

事务隔离级别决定了一个事务能看到哪些数据。MVCC 则是 InnoDB 在不阻塞普通读的情况下实现一致性读的重要机制。供应链系统里,订单、库存、采购、结算这些流程都依赖事务隔离:用户不能看到半提交的数据,后台任务不能基于不一致快照生成错误计划。

理解 MVCC 的关键是区分快照读和当前读。普通 SELECT 多数情况下是快照读,SELECT FOR UPDATEUPDATEDELETE 是当前读。很多锁问题来自把这两类读混在一起理解。

MVCC 和当前读流程

MySQL MVCC 和当前读流程

隔离级别解决什么问题

常见并发异常包括:

  • 脏读:读到其他事务尚未提交的数据。
  • 不可重复读:同一事务内两次读取同一行,结果不同。
  • 幻读:同一事务内两次范围查询,出现新增或消失的记录。

供应链例子:计划系统正在生成某仓库的补货建议,它先读取库存,再读取未完成订单。如果读取过程中其他事务提交了大量订单,计划结果就可能基于前后不一致的数据。

MySQL InnoDB 常用隔离级别是 READ COMMITTEDREPEATABLE READ。很多项目默认使用 REPEATABLE READ

MVCC 的核心概念

MVCC 可以理解为多版本并发控制。每行数据背后有事务版本信息,历史版本保存在 undo log 里。事务执行快照读时,会根据 ReadView 判断哪个版本对当前事务可见。

普通查询:

1
2
3
SELECT available_qty
FROM scm_inventory
WHERE warehouse_id = 8 AND sku_id = 1001;

通常不会加行锁,而是读取一个对当前事务可见的版本。这就是为什么大量查询不会互相阻塞更新。

当前读:

1
2
3
4
SELECT available_qty
FROM scm_inventory
WHERE warehouse_id = 8 AND sku_id = 1001
FOR UPDATE;

它读取最新已提交版本,并尝试加排他锁。当前读要参与并发修改,所以必须面对锁等待。

READ COMMITTED 和 REPEATABLE READ 的差异

READ COMMITTED 下,每条语句都会生成新的 ReadView。一个事务内两次普通查询,可能看到其他事务刚提交的新结果。

REPEATABLE READ 下,事务第一次快照读时生成 ReadView,后续普通查询沿用这个 ReadView。因此同一事务内多次查询结果更稳定。

供应链报表生成适合使用快照一致性。比如生成某一天的库存余额报表,如果事务内多次查询库存和流水,希望读到同一个时间点的视图。

但订单审核、库存扣减不能只依赖快照读。它们要修改最新状态,应该使用当前读或条件更新。

Demo:订单审核不能只用快照读

错误示例:

1
2
3
4
5
6
7
8
@Transactional
public void approveOrder(long orderId) {
Order order = orderMapper.selectById(orderId); // 普通快照读
if (!"WAIT_APPROVE".equals(order.getStatus())) {
throw new BizException("状态不允许审核");
}
orderMapper.updateStatus(orderId, "APPROVED");
}

如果两个审核线程同时进入,都可能读到 WAIT_APPROVE。虽然最终数据库更新有锁,但如果 updateStatus 没有带状态条件,就可能出现重复审核日志、重复发消息。

改进方式:

1
2
3
4
5
6
7
8
@Transactional
public void approveOrder(long orderId, long userId) {
int affected = orderMapper.approve(orderId, userId);
if (affected != 1) {
throw new BizException("订单状态已变化,不能重复审核");
}
auditLogMapper.insert(orderId, userId, "APPROVE");
}

SQL:

1
2
3
4
5
6
UPDATE scm_sales_order
SET status = 'APPROVED',
approved_by = #{userId},
approved_at = NOW()
WHERE id = #{orderId}
AND status = 'WAIT_APPROVE';

这里用当前读的更新语义和状态条件一起保证并发安全。即使两个事务同时执行,也只有一个事务能更新成功。

MVCC 和锁的关系

MVCC 让普通读不阻塞写,写也不阻塞普通读。但它不意味着没有锁。更新、删除、当前读仍然需要加锁。

库存扣减:

1
2
3
4
5
UPDATE scm_inventory
SET available_qty = available_qty - 3
WHERE warehouse_id = 8
AND sku_id = 1001
AND available_qty >= 3;

这不是快照读。InnoDB 要读取最新可更新版本并加排他锁。如果另一个事务正在修改同一库存记录,当前事务会等待。

供应链系统的使用建议

报表、看板、查询列表主要依赖快照读。它们要求读性能和一致视图,不应该随便加 FOR UPDATE

审核、扣减、占用、释放、结算过账属于状态变更,要用条件更新或当前读。业务状态必须放在 SQL 条件里。

跨多张表的一致性流程要控制事务边界。比如创建出库单并扣库存,应该在一个事务里完成核心数据修改,但不要把 WMS 远程通知放进事务。

对于长报表,不要长时间开启事务读取大量数据,否则会影响 undo log 清理。可以用离线快照表、分批读取或数据仓库承接。

小结

MVCC 解决的是读写并发下的一致性读,不是所有并发修改的万能保护。供应链系统要区分快照读和当前读:查询列表依赖 MVCC,状态变更依赖锁和条件更新。把业务状态写进 SQL 条件,是比单纯依赖 Java 判断更可靠的并发控制方式。