MySQL间隙锁和Next-Key Lock:采购单号防重与幻读

间隙锁是 MySQL 锁体系里最容易让人困惑的一类锁。它锁的不是已经存在的记录,而是索引记录之间的“空隙”。在 InnoDB 的可重复读隔离级别下,间隙锁和 Next-Key Lock 用来阻止其他事务在某个范围内插入新记录,从而避免当前读场景下的幻读。

供应链系统里,采购单号、入库批次号、结算期间号这类数据经常要求“范围内唯一”或“按规则递增”。如果只检查已有记录,不理解间隙锁,就很容易在高并发下出现重复单号或范围判断不一致。

间隙锁和防重流程

MySQL 间隙锁和 Next-Key Lock 流程

什么是幻读

假设采购系统要求同一个供应商在同一天不能创建重复外部单号:

1
2
3
4
5
6
7
8
CREATE TABLE scm_purchase_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
supplier_id BIGINT NOT NULL,
external_no VARCHAR(64) NOT NULL,
order_date DATE NOT NULL,
status VARCHAR(32) NOT NULL,
KEY idx_supplier_date_no (supplier_id, order_date, external_no)
) ENGINE=InnoDB;

事务 A 先检查不存在:

1
2
3
4
5
6
7
8
START TRANSACTION;

SELECT *
FROM scm_purchase_order
WHERE supplier_id = 2001
AND order_date = '2025-04-10'
AND external_no = 'PO-7788'
FOR UPDATE;

如果此时没有记录,事务 A 准备插入。事务 B 如果也执行同样检查,也可能看到不存在。两边都插入,就会重复。这就是典型的“先查再插”竞态。

用唯一约束解决第一层问题

防重复的第一选择不是依赖锁,而是唯一约束:

1
2
ALTER TABLE scm_purchase_order
ADD UNIQUE KEY uk_supplier_date_no (supplier_id, order_date, external_no);

然后直接插入:

1
2
3
4
5
INSERT INTO scm_purchase_order (
supplier_id, order_date, external_no, status
) VALUES (
2001, '2025-04-10', 'PO-7788', 'DRAFT'
);

如果并发重复插入,数据库会让一个成功,另一个失败。业务层捕获唯一键冲突,返回“采购单已存在”。这是最可靠、最容易维护的防重方案。

间隙锁适合什么场景

有些规则不是单点唯一,而是范围约束。比如一个供应商每天最多只能有 1000 张待审核采购单。检查时需要锁住某个范围,避免检查后别人插入新记录导致数量超过限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
START TRANSACTION;

SELECT COUNT(*)
FROM scm_purchase_order
WHERE supplier_id = 2001
AND order_date = '2025-04-10'
AND status = 'WAIT_APPROVE'
FOR UPDATE;

-- 如果数量小于 1000,再插入
INSERT INTO scm_purchase_order (...);

COMMIT;

但这里有一个现实问题:COUNT(*) FOR UPDATE 在不同 MySQL 版本和执行计划下不一定按你想象的方式锁住范围。更稳的做法是把“当天供应商配额”抽成一条独立记录:

1
2
3
4
5
6
7
CREATE TABLE scm_supplier_daily_quota (
supplier_id BIGINT NOT NULL,
biz_date DATE NOT NULL,
used_count INT NOT NULL,
limit_count INT NOT NULL,
PRIMARY KEY (supplier_id, biz_date)
) ENGINE=InnoDB;

然后更新这一行:

1
2
3
4
5
UPDATE scm_supplier_daily_quota
SET used_count = used_count + 1
WHERE supplier_id = 2001
AND biz_date = '2025-04-10'
AND used_count < limit_count;

影响行数为 1 才允许创建采购单。这个模型比依赖复杂范围锁更清晰。

Next-Key Lock 是什么

Next-Key Lock 可以理解为记录锁加间隙锁。它既锁住已有索引记录,也锁住记录前面的间隙。范围查询当前读时,InnoDB 可能使用 Next-Key Lock 防止其他事务在范围内插入新记录。

例如:

1
2
3
4
5
6
SELECT *
FROM scm_purchase_order
WHERE supplier_id = 2001
AND order_date >= '2025-04-01'
AND order_date < '2025-05-01'
FOR UPDATE;

在可重复读隔离级别下,这类范围当前读可能锁住 4 月份相关索引范围,阻止其他事务插入该范围内的新采购单。锁范围取决于索引设计和执行计划。

如果索引是:

1
KEY idx_supplier_date (supplier_id, order_date)

锁范围会比没有该索引时更可控。没有合适索引时,范围锁可能扩大,甚至影响无关供应商。

供应链业务里的实践建议

第一,唯一性问题优先用唯一索引。比如采购外部单号、入库批次号、销售订单号。

第二,配额和计数问题优先抽象成计数行,用条件更新控制并发。不要用大范围 COUNT FOR UPDATE 扛高并发。

第三,必须范围锁时,必须设计和范围条件一致的联合索引。范围条件没有索引,间隙锁会变成线上阻塞源。

第四,不要把用户交互放在事务里。比如创建采购单时弹窗确认、调用供应商接口、远程校验合同,不能在持有范围锁期间执行。

Demo:安全生成供应商日序号

如果采购单号规则是 供应商 + 日期 + 序号,可以用序号表控制:

1
2
3
4
5
6
CREATE TABLE scm_doc_sequence (
doc_type VARCHAR(32) NOT NULL,
biz_key VARCHAR(64) NOT NULL,
current_value INT NOT NULL,
PRIMARY KEY (doc_type, biz_key)
) ENGINE=InnoDB;

Java 代码:

1
2
3
4
5
6
7
8
9
10
11
@Transactional
public String nextPurchaseNo(long supplierId, LocalDate date) {
String bizKey = supplierId + ":" + date;
int affected = sequenceMapper.increase("PURCHASE_ORDER", bizKey);
if (affected == 0) {
sequenceMapper.insert("PURCHASE_ORDER", bizKey, 1);
return formatNo(supplierId, date, 1);
}
int value = sequenceMapper.current("PURCHASE_ORDER", bizKey);
return formatNo(supplierId, date, value);
}

对应 SQL:

1
2
3
4
UPDATE scm_doc_sequence
SET current_value = current_value + 1
WHERE doc_type = #{docType}
AND biz_key = #{bizKey};

真实项目里要处理并发首次插入的唯一键冲突。也可以先插入初始行,再统一更新。核心思想是把复杂范围竞争收敛到一条序号记录上。

小结

间隙锁和 Next-Key Lock 是 InnoDB 为范围一致性提供的机制,但业务系统不应该把所有防重和计数规则都寄托在隐式范围锁上。供应链系统更稳的建模是:唯一性用唯一索引,额度用计数行,序号用序号表,范围当前读必须配合明确索引。这样锁行为才可解释、可压测、可排查。

MySQL行锁与索引:库存预占为什么必须命中索引

InnoDB 的行锁不是直接锁住“表里那一行数据”的抽象概念,而是锁住索引记录。这个细节决定了很多线上锁问题的根因:SQL 看起来只改一条业务数据,但因为没有命中合适索引,实际扫描和加锁范围远大于预期。

供应链系统里最容易踩坑的是库存预占。库存表通常按仓库、SKU、批次、库位维度管理,如果查询条件和索引设计不一致,一个订单扣库存可能阻塞另一批无关 SKU 的入库、移库或盘点。

行锁和索引命中流程

MySQL 行锁和索引命中流程

库存表的索引设计

假设库存按仓库和 SKU 汇总:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE scm_inventory (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
available_qty INT NOT NULL,
locked_qty INT NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_wh_sku (warehouse_id, sku_id),
KEY idx_sku (sku_id)
) ENGINE=InnoDB;

订单预占库存时,最理想的 SQL 是:

1
2
3
4
5
6
7
UPDATE scm_inventory
SET available_qty = available_qty - 5,
locked_qty = locked_qty + 5,
updated_at = NOW()
WHERE warehouse_id = 8
AND sku_id = 1001
AND available_qty >= 5;

这里能命中唯一索引 uk_wh_sku。InnoDB 可以快速定位到一条索引记录,然后对这条记录加排他锁。其他事务更新同仓不同 SKU,或者不同仓同 SKU,通常不会被它阻塞。

没有命中索引会发生什么

如果业务代码写成这样:

1
2
3
4
5
UPDATE scm_inventory
SET available_qty = available_qty - 5,
locked_qty = locked_qty + 5
WHERE sku_id = 1001
AND available_qty >= 5;

这条 SQL 少了 warehouse_id。如果只命中 idx_sku,它可能扫描所有仓库里 SKU 1001 的库存记录。对于全国仓、多渠道库存系统,这个范围可能很大。

更差的是用函数或隐式转换破坏索引:

1
2
3
4
SELECT *
FROM scm_inventory
WHERE CAST(sku_id AS CHAR) = '1001'
FOR UPDATE;

当索引失效时,数据库需要扫描更多记录。扫描过程中,当前读和更新语句可能对更多索引记录加锁,导致锁等待范围扩大。线上表现通常是:明明两个订单不是同一个仓库,却互相等待。

用 EXPLAIN 验证锁范围的前提

锁问题先看 SQL 是否按预期走索引:

1
2
3
4
5
6
7
EXPLAIN
UPDATE scm_inventory
SET available_qty = available_qty - 5,
locked_qty = locked_qty + 5
WHERE warehouse_id = 8
AND sku_id = 1001
AND available_qty >= 5;

重点看:

  • key 是否是预期索引。
  • type 是否是 constrefrange
  • rows 是否接近业务预期。

如果 rows 远大于 1,就要警惕锁范围已经被放大。

Java 侧的正确调用方式

库存预占接口不要先查库存再在 Java 里判断。推荐直接执行条件更新:

1
2
3
4
5
6
7
8
@Transactional
public void reserveStock(long warehouseId, long skuId, int qty) {
int affected = inventoryMapper.reserve(warehouseId, skuId, qty);
if (affected != 1) {
throw new BizException("库存不足,无法预占");
}
inventoryLogMapper.insertReserveLog(warehouseId, skuId, qty);
}

对应 Mapper SQL:

1
2
3
4
5
6
7
UPDATE scm_inventory
SET available_qty = available_qty - #{qty},
locked_qty = locked_qty + #{qty},
updated_at = NOW()
WHERE warehouse_id = #{warehouseId}
AND sku_id = #{skuId}
AND available_qty >= #{qty}

这个写法有两个优点。第一,库存判断和扣减是一个原子更新。第二,锁范围由唯一索引控制,容易解释和排查。

批量库存预占的加锁顺序

批量预占时,要先把 SKU 或库存记录按稳定顺序排序,再依次更新。这样两个订单即使包含相同 SKU,也会以相同顺序申请锁,降低死锁概率。

1
2
3
4
5
6
7
List<OrderItem> sortedItems = order.items().stream()
.sorted(Comparator.comparing(OrderItem::skuId))
.toList();

for (OrderItem item : sortedItems) {
inventoryMapper.reserve(order.warehouseId(), item.skuId(), item.qty());
}

如果订单明细非常多,不建议在一个长事务里处理全部库存。可以按业务规则拆单、拆仓或先做可用性预检查,再进入短事务执行核心扣减。

一个订单可能包含多个 SKU。批量扣减时,如果两个事务以不同顺序锁 SKU,就容易死锁。

事务 A:

1
2
先锁 SKU 1001
再锁 SKU 1002

事务 B:

1
2
先锁 SKU 1002
再锁 SKU 1001

两个事务互相等待,就会死锁。解决办法是固定加锁顺序,比如按 sku_id 升序处理:

1
2
3
items.stream()
.sorted(Comparator.comparing(OrderItem::getSkuId))
.forEach(item -> reserveStock(order.getWarehouseId(), item.getSkuId(), item.getQty()));

数据库仍然可能检测到死锁并回滚其中一个事务,所以业务层还要对死锁异常做有限重试。但固定顺序能显著降低死锁概率。

索引设计原则

库存锁相关 SQL 的索引设计应该从业务唯一性出发。

如果库存粒度是仓库 + SKU:

1
UNIQUE KEY uk_wh_sku (warehouse_id, sku_id)

如果库存粒度是仓库 + SKU + 批次 + 库位:

1
UNIQUE KEY uk_wh_sku_batch_bin (warehouse_id, sku_id, batch_no, bin_code)

业务 SQL 必须尽量带齐唯一维度。缺一个维度,锁范围和业务语义都会变模糊。

小结

InnoDB 行锁的关键是索引。供应链系统里,库存、批次、库位、单据明细这些表的数据量大、并发高,锁范围稍微变大就会影响吞吐。写库存类 SQL 时,必须用业务唯一索引定位记录,用条件更新完成判断和修改,并在批量处理时固定加锁顺序。

Java锁总览:从synchronized到AQS保护供应链单据

Java 锁解决的是 JVM 内多个线程同时访问共享对象的问题。供应链系统虽然最终数据在数据库里,但 Java 应用层也有大量共享状态:本地缓存、任务队列、单据处理器、库存同步批次、报表聚合结果。如果这些状态没有并发控制,数据库锁也救不了应用层的混乱。

Java 锁的学习路线可以从四层理解:synchronizedvolatilejava.util.concurrent.locks、AQS/CAS。业务上要回答的不是“哪个锁更高级”,而是“这个共享数据是否必须互斥,是否需要等待条件,是否读多写少,是否可以用无锁原子变量”。

Java 锁选择流程

Java 锁选择流程

一个供应链单据处理例子

假设系统里有一个本地任务分发器,把待审核采购单放到内存队列里,由多个工作线程消费:

1
2
3
4
5
6
7
8
9
10
11
public class PurchaseApproveWorker {
private final Queue<Long> queue = new ArrayDeque<>();

public void submit(Long purchaseOrderId) {
queue.add(purchaseOrderId);
}

public Long poll() {
return queue.poll();
}
}

这段代码在单线程下没问题,在多线程下有问题。ArrayDeque 不是线程安全的,多个线程同时 addpoll 可能导致数据结构状态损坏、任务丢失或重复消费。

最直接的修复方式是用 synchronized

1
2
3
4
5
6
7
8
9
10
11
public class PurchaseApproveWorker {
private final Queue<Long> queue = new ArrayDeque<>();

public synchronized void submit(Long purchaseOrderId) {
queue.add(purchaseOrderId);
}

public synchronized Long poll() {
return queue.poll();
}
}

这保证了同一时间只有一个线程能访问 queue。但它还不完整:消费者如果发现队列为空,只能不断轮询。更好的方式是使用 BlockingQueue,让成熟并发容器处理等待和唤醒:

1
2
3
4
5
6
7
8
9
10
11
public class PurchaseApproveWorker {
private final BlockingQueue<Long> queue = new LinkedBlockingQueue<>();

public void submit(Long purchaseOrderId) {
queue.offer(purchaseOrderId);
}

public Long take() throws InterruptedException {
return queue.take();
}
}

工程上优先使用 JDK 已经验证过的并发容器,而不是手写锁。手写锁只有在业务控制非常明确时才值得做。

synchronized 解决互斥

synchronized 是 Java 最基础的互斥锁。它可以修饰实例方法、静态方法和代码块。

1
2
3
4
5
6
7
8
9
10
11
public class InventoryLocalCache {
private final Map<String, Integer> stock = new HashMap<>();

public synchronized void put(String skuKey, int qty) {
stock.put(skuKey, qty);
}

public synchronized int get(String skuKey) {
return stock.getOrDefault(skuKey, 0);
}
}

实例方法上的锁对象是当前实例 this。静态方法上的锁对象是 Class 对象。代码块可以显式指定锁对象:

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

public void refresh(String skuKey, int qty) {
synchronized (lock) {
stock.put(skuKey, qty);
}
}

供应链系统里如果多个方法保护的是同一份共享状态,必须使用同一把锁。否则看似加锁,实际互斥范围不一致。

volatile 解决可见性

volatile 不是互斥锁,它解决的是可见性和指令重排问题。

库存同步任务常见一个停止标志:

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

public void stop() {
running = false;
}

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

如果没有 volatile,一个线程调用 stop() 后,工作线程可能长时间看不到 running = false。加上 volatile 后,写入对其他线程可见。

volatile 不能保证复合操作原子性:

1
2
volatile int count = 0;
count++;

count++ 包含读、加、写三个步骤,多线程下仍然会丢失更新。供应链系统里统计已处理单据数,不应该用 volatile int++,可以使用 AtomicIntegerLongAdder

ReentrantLock 解决更复杂的锁控制

ReentrantLock 提供了比 synchronized 更明确的能力:可中断锁、超时尝试、公平锁、多个条件队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ShipmentAllocator {
private final ReentrantLock lock = new ReentrantLock();

public boolean allocate(Long shipmentId) throws InterruptedException {
if (!lock.tryLock(2, TimeUnit.SECONDS)) {
return false;
}
try {
return doAllocate(shipmentId);
} finally {
lock.unlock();
}
}
}

在波次分配、批量调度、任务抢占这类场景里,tryLock 比无期限等待更实用。拿不到锁就返回失败或稍后重试,避免线程池被全部阻塞。

AQS 是很多并发工具的基础

AQS 是 AbstractQueuedSynchronizer,它不是业务代码每天直接使用的 API,但理解它有助于理解 ReentrantLockSemaphoreCountDownLatch 等工具。

AQS 的核心是一个 state 状态值和一个等待队列。线程获取锁失败后进入队列,等待前驱节点释放后被唤醒。

供应链批处理里常见 CountDownLatch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CountDownLatch latch = new CountDownLatch(3);

executor.submit(() -> {
loadInventory();
latch.countDown();
});
executor.submit(() -> {
loadPurchaseOrders();
latch.countDown();
});
executor.submit(() -> {
loadSupplierConfig();
latch.countDown();
});

latch.await();
generatePlanningResult();

它不是锁业务对象,而是协调多个线程阶段。理解 AQS 后,可以清楚地区分互斥锁和线程协作工具。

选择建议

能用无共享状态,就不要加锁。比如每个请求创建自己的上下文对象。

能用数据库条件更新,就不要在应用层用本地锁保护跨节点数据。多实例部署时,本地 Java 锁只在单个 JVM 内有效,不能保护整个集群。

能用并发容器,就不要手写锁。比如 ConcurrentHashMapBlockingQueueLongAdder

确实需要保护对象内部状态时,简单互斥用 synchronized。需要超时、可中断、多个条件队列时,用 ReentrantLock。读多写少时考虑读写锁。计数器和状态 CAS 更新考虑原子类。

小结

Java 锁和 MySQL 锁保护的范围不同。Java 锁保护 JVM 内存对象,MySQL 锁保护数据库记录。供应链系统里的正确做法通常是两者配合:应用层锁保护本地状态,数据库锁保护最终数据一致性。不要用 Java 本地锁代替数据库并发控制,也不要把所有并发问题都推给数据库。

MySQL锁总览:供应链系统里的并发一致性

供应链系统里的并发问题通常不是抽象的“线程安全”,而是非常具体的业务错误:库存被扣成负数、同一张采购单被重复审核、同一个批次被两个上架任务同时占用、同一笔应付单被重复生成。MySQL 锁的价值就在这里,它让多个事务在修改同一批业务数据时有确定的顺序。

理解 MySQL 锁,不能只背表锁、行锁、共享锁、排他锁这些名词。更重要的是搞清楚四个问题:锁的对象是什么,锁什么时候加上,锁什么时候释放,不同锁之间是否兼容。

MySQL 锁设计流程

MySQL 锁设计流程

供应链里的典型并发场景

假设有一张库存表:

1
2
3
4
5
6
7
8
9
CREATE TABLE scm_inventory (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
available_qty INT NOT NULL,
locked_qty INT NOT NULL,
version INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_wh_sku (warehouse_id, sku_id)
) ENGINE=InnoDB;

两个订单同时预占同一个仓库的同一个 SKU。每个订单都要锁定 8 件库存,当前可用库存只有 10。如果两个事务都先读到 available_qty = 10,然后各自扣减,就会超卖。

错误写法通常是先查再改:

1
2
3
4
5
6
7
8
SELECT available_qty
FROM scm_inventory
WHERE warehouse_id = 1 AND sku_id = 1001;

UPDATE scm_inventory
SET available_qty = available_qty - 8,
locked_qty = locked_qty + 8
WHERE warehouse_id = 1 AND sku_id = 1001;

这两条 SQL 分开执行时,如果没有事务和锁保护,中间状态会被其他事务插入。更稳的做法是让条件判断和扣减在一条更新语句里完成:

1
2
3
4
5
6
7
UPDATE scm_inventory
SET available_qty = available_qty - 8,
locked_qty = locked_qty + 8,
version = version + 1
WHERE warehouse_id = 1
AND sku_id = 1001
AND available_qty >= 8;

如果返回影响行数是 1,说明预占成功;如果是 0,说明库存不足或记录不存在。这个写法利用了 InnoDB 对更新记录加排他锁的能力,也避免了应用层先读后写的竞态。

表锁、行锁和意向锁

表锁锁住整张表。粒度大,管理简单,但并发能力差。供应链系统里订单、库存、出入库单都是高频表,业务代码一般不应该主动加表锁。

行锁锁住索引记录。InnoDB 的核心并发能力来自行锁。两个事务修改不同 SKU 的库存时,只要走的是不同索引记录,就可以并发执行。

意向锁是 InnoDB 自动加在表级别的锁,用来表示“这个事务准备在表里的某些行上加锁”。它主要用于协调表锁和行锁。业务开发不需要手动控制意向锁,但排查锁等待时要能看懂 ISIX

共享锁和排他锁

共享锁用于读,多个事务可以同时持有共享锁。排他锁用于写,一个事务持有排他锁时,其他事务不能再对同一记录加共享锁或排他锁。

在供应链单据审核里,如果只想读取单据并防止审核过程中被别人改,可以使用当前读:

1
2
3
4
5
6
7
8
9
10
11
12
START TRANSACTION;

SELECT id, status, total_amount
FROM scm_purchase_order
WHERE id = 9001
FOR UPDATE;

UPDATE scm_purchase_order
SET status = 'APPROVED'
WHERE id = 9001 AND status = 'WAIT_APPROVE';

COMMIT;

FOR UPDATE 会对命中的记录加排他锁。其他事务如果也想审核这张采购单,会等待当前事务提交或回滚。这里的重点是事务范围要短:查单据、校验状态、更新状态、写审核日志,然后立刻提交。不要在持锁事务里调用远程接口、发送消息或执行复杂报表。

锁和索引的关系

InnoDB 行锁是加在索引上的。是否命中合适索引,直接决定锁的范围。

下面这条 SQL 如果能命中 uk_wh_sku,通常只会锁住目标 SKU 的库存记录:

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

如果查询条件没有索引,比如按一个低选择性的字段查库存:

1
2
3
4
SELECT *
FROM scm_inventory
WHERE batch_status = 'AVAILABLE'
FOR UPDATE;

数据库可能扫描大量记录,并在扫描过程中对更多索引记录加锁。结果就是一个库存预占请求把无关 SKU 也阻塞了。供应链系统里,库存、批次、库位这些表必须按业务唯一性和高频查询路径设计索引,否则锁问题会被放大。

业务建模建议

库存扣减建议优先使用“条件更新 + 影响行数判断”,不要在应用层先查库存再扣库存。

单据审核建议使用状态机约束:

1
2
3
4
5
6
UPDATE scm_purchase_order
SET status = 'APPROVED',
approved_by = 101,
approved_at = NOW()
WHERE id = 9001
AND status = 'WAIT_APPROVE';

这类 SQL 天然具备幂等特征。重复审核时,第二次更新影响行数为 0,应用层可以返回“状态已变更”。

对于金额结算、库存转移这类强一致流程,悲观锁是合理选择。但对读多写少、冲突概率低的配置类数据,比如供应商报价、运输模板,可以考虑乐观锁,用 version 控制并发覆盖。

排查时看什么

线上出现锁等待时,先确认三个事实:

  • 哪个事务在等。
  • 它等的是哪张表、哪个索引、哪类锁。
  • 持锁事务正在执行什么 SQL,为什么还没提交。

常用命令:

1
2
3
4
5
6
7
8
SHOW PROCESSLIST;
SHOW ENGINE INNODB STATUS;

SELECT *
FROM performance_schema.data_locks;

SELECT *
FROM performance_schema.data_lock_waits;

如果 MySQL 版本较老,没有 performance_schema.data_locks,就重点看 SHOW ENGINE INNODB STATUS 里的 latest detected deadlock 和 transaction 信息。

小结

MySQL 锁的本质是数据库给并发事务安排修改顺序。供应链系统的库存、单据、批次、结算都依赖这个顺序。写业务代码时,要把锁设计落实到 SQL:使用明确索引、缩短事务、用状态条件保护更新、避免持锁做慢操作。能做到这些,锁就不是线上事故的来源,而是业务一致性的基础设施。

ERP报表统计设计:口径、快照与增量计算

ERP 系统里的报表统计看起来只是把数据查出来、汇总一下、展示成表格或图表,但真实项目里,报表往往是最容易拖慢系统、最容易和业务口径争议、也最难长期维护的部分。

原因很简单:ERP 覆盖采购、销售、库存、生产、财务、人事、仓储等多个业务域。每个业务域都有自己的单据、状态、时间口径和组织权限。报表统计不是一个单纯的 SQL 问题,而是数据模型、业务口径、性能、权限和交付方式的综合问题。

ERP 报表统计优化流程

报表统计为什么难

第一个难点是数据来源多。

一个销售毛利报表可能需要销售订单、出库单、发票、成本价、退货单、客户档案、商品档案。一个库存周转报表可能需要期初库存、入库流水、出库流水、调拨单、盘点单、库存成本。数据分散在多个模块,任何一个模块的状态定义不清楚,报表结果就会不准确。

第二个难点是业务口径复杂。

同样是“销售额”,财务可能按已开票金额统计,销售部门可能按已审核订单金额统计,仓库可能只关心已出库金额。口径不同,结果就不同。如果系统没有把口径写清楚,用户会认为报表错了。

第三个难点是数据量增长快。

ERP 系统上线初期,订单、库存流水、财务凭证数量不大,直接查交易表还能接受。运行一年后,库存流水可能达到几千万甚至上亿行。原来 1 秒的统计 SQL 可能变成 30 秒。

第四个难点是权限过滤复杂。

不同用户能看的组织、仓库、客户、供应商、商品类目不同。报表查询不仅要统计数据,还要拼接权限条件。如果权限模型复杂,SQL 会变得非常重。

第五个难点是实时性要求不一致。

老板看经营大盘,希望数字尽量实时;财务月结报表更强调准确;仓库作业看板需要分钟级刷新;历史经营分析允许 T+1。不同报表如果都按实时查询设计,系统成本会很高。

常见问题点

ERP 报表项目里最常见的问题,是把报表当成页面功能开发,而不是当成数据产品设计。

典型表现包括:

  • 每个报表单独写一套复杂 SQL,逻辑重复。
  • 报表直接查询交易库,和业务写入争抢资源。
  • 没有统一指标口径,同一个指标在不同页面结果不一致。
  • 大量使用 SELECT *、多表 JOIN、动态条件拼接。
  • 明细报表和汇总报表混在一起。
  • 用户随意选择超大时间范围,导致全表扫描。
  • 导出功能同步执行,拖垮应用线程。
  • 报表结果没有版本,月结后历史数据还会变化。

这些问题短期看只是慢,长期看会变成信任问题。用户一旦认为报表不准,即使系统性能优化了,也很难恢复信任。

优化思路一:先统一指标口径

报表优化的第一步不是建索引,而是定义口径。

每个核心指标都应该回答这些问题:

  • 指标名称是什么。
  • 统计对象是什么。
  • 数据来源表是什么。
  • 使用哪个时间字段。
  • 包含哪些状态。
  • 是否扣除退货、作废、冲红。
  • 是否含税。
  • 是否按组织、仓库、客户、供应商过滤。
  • 数据刷新频率是多少。

例如“销售额”可以定义为:统计已审核且未作废销售订单的含税金额,按订单审核时间归属日期,退货单按负数冲减。这个定义必须在报表说明、SQL 逻辑和测试用例里保持一致。

没有口径统一,后面的性能优化意义有限,因为优化得越快,错误传播得越快。

优化思路二:交易库和统计库分离

ERP 的交易库主要服务于业务写入和核心查询,比如下单、审核、入库、出库、付款。报表统计通常是宽范围扫描和聚合,负载特征完全不同。

如果所有报表都直接查交易库,高峰期报表会影响业务操作。更稳妥的方式是把交易数据同步到统计库或数仓,在统计库里做报表查询。

常见方式包括:

  • 通过 binlog 或 CDC 同步交易数据。
  • 通过 MQ 订阅业务事件。
  • 通过定时任务增量抽取。
  • 通过离线 ETL 生成 T+1 报表。

小系统可以先用只读从库承接报表查询。数据量继续增长后,再引入 ClickHouse、Doris、Elasticsearch 或专门的数据仓库。

优化思路三:建设汇总表和宽表

复杂报表不能每次都从原始明细重新计算。

例如销售日报,如果每次都扫描销售订单、退货单、客户表、商品表、组织表,再按日期、区域、客户分组,成本会很高。可以每天或每小时生成汇总表:

1
2
3
4
5
6
7
8
9
10
erp_sales_daily_summary
- summary_date
- org_id
- customer_id
- product_category_id
- order_amount
- return_amount
- net_amount
- order_count
- updated_at

页面查询时直接按汇总表聚合,速度会稳定很多。

宽表适合解决多表关联问题。比如订单分析宽表可以提前把订单号、客户、业务员、区域、商品类目、金额、状态、审核时间等字段放在同一张表里。报表查询不再频繁 JOIN 多张业务表。

汇总表和宽表的代价是数据冗余和同步逻辑复杂,但对于高频报表,这个代价通常值得。

优化思路四:按报表类型选择实时性

不是所有报表都需要实时。

可以把 ERP 报表分成几类:

  • 实时看板:库存预警、待审批、待发货,要求秒级或分钟级。
  • 经营汇总:销售日报、采购日报、仓库作业日报,允许分钟级或小时级。
  • 财务报表:应收应付、成本核算、月结报表,强调准确和可追溯。
  • 历史分析:年度趋势、供应商绩效、客户贡献,允许 T+1。

实时看板可以用缓存、事件驱动和小范围查询。经营汇总可以用增量任务刷新。财务报表需要快照和版本。历史分析可以走离线数仓。

这样做的好处是把系统资源花在真正需要实时的地方,而不是让所有报表都背上实时查询的成本。

优化思路五:限制查询范围和导出方式

报表页面必须有保护措施。

常见策略包括:

  • 默认时间范围不要太大。
  • 对明细报表限制最大查询跨度。
  • 大范围查询必须走异步任务。
  • 导出任务进入队列,完成后生成下载文件。
  • 对高频用户和重报表做限流。
  • 对历史数据做分区或归档。

比如库存流水明细可以允许页面查询最近 31 天。如果用户要导出一年数据,系统创建导出任务,后台慢慢生成 Excel 或 CSV,完成后通知用户下载。这样不会占用 Web 请求线程,也不会让用户长时间等待。

优化思路六:做好权限预处理

ERP 报表的权限经常比业务页面更复杂。一个区域经理能看多个组织,一个仓库主管只能看部分仓库,一个采购员只能看自己负责的供应商。

如果每次查询都动态拼接复杂权限 SQL,性能和维护性都会变差。可以考虑把用户可访问范围预处理成权限表或缓存。

例如:

1
2
3
4
erp_user_data_scope
- user_id
- scope_type
- scope_id

报表查询时先拿到用户可访问的组织、仓库、供应商,再参与过滤。对于权限范围很大的用户,可以使用角色级缓存或组织树预计算。

权限必须和报表口径一起测试。报表统计错一部分是数据问题,越权展示则是安全问题。

优化思路七:让报表可核对

报表不仅要快,还要能解释。

用户看到销售额 128 万时,经常会追问:这个数字由哪些单据组成?为什么和昨天看到的不一样?为什么和财务报表不同?

因此报表系统需要支持从汇总到明细的下钻。汇总表里的数字应该能追溯到明细单据,财务月结类报表还应该有快照版本。月结完成后,历史报表不能因为后续业务数据修改而悄悄变化。

对于关键报表,可以保留统计任务日志:统计时间、数据范围、处理行数、异常记录、结果版本。这样出现争议时可以快速定位。

一个供应链 ERP 报表优化例子

假设系统有一个“供应商履约报表”,统计供应商按时交货率、延期次数、采购金额、退货金额。

初始实现可能是一个大 SQL,实时关联采购订单、收货单、退货单、供应商表,并按供应商分组。数据量小时可用,数据量大后页面打开要 20 秒。

优化可以这样做:

  1. 明确口径:按采购订单承诺交货日期和实际收货日期计算是否延期,作废采购单不统计,退货金额单独列示。
  2. 建立采购履约明细宽表,把采购单、供应商、承诺日期、收货日期、金额、退货金额、状态提前整理好。
  3. 每小时增量刷新履约明细宽表。
  4. 每天生成供应商履约汇总表。
  5. 页面默认查汇总表,点击供应商后再下钻明细。
  6. 超过 6 个月范围的导出走异步任务。

优化后,页面查询从扫描多张交易表变成读取汇总表,响应时间稳定在几百毫秒。更重要的是,指标口径清楚,用户可以下钻核对每一个数字。

总结

ERP 报表统计的核心难点不只是 SQL 慢,而是数据来源多、业务口径多、权限复杂、实时性差异大、历史数据需要可追溯。

优化报表统计的顺序应该是:先统一口径,再分离统计负载,再建设宽表和汇总表,再按实时性分层,再控制查询和导出,最后补上权限、下钻和快照能力。

报表系统做得好,不只是页面快,而是让业务人员相信数据、理解数据,并且能用数据做决策。