Skip to main content

隔离级别、锁与最终一致性实战

在掌握 InnoDB 锁机制后,需要结合隔离级别与业务代码理解锁的实际行为。单机场景下用悲观锁或乐观锁即可;跨服务(订单、库存分库)时,本地事务无法覆盖全局一致性,需要配合 Kafka + 本地重试 + 死信队列 兜底。

本文分两部分:单机锁实战(RC/RR、间隙锁、悲观/乐观锁、死锁)与 分布式最终一致性(下单扣库存链路)。

RC 与 RR 的生产差异

常见配置:MySQL 默认隔离级别为 RR(可重复读),不少互联网公司改为 RC(读已提交)+ binlog 格式 ROW

隔离级别间隙锁并发一致性
RR有,锁住行及行间间隙较低,易死锁同一事务内可重复读,防幻读
RC无,只锁实际命中行较高可能出现不可重复读、幻读

RR 间隙锁示例WHERE id BETWEEN 1 AND 10 即使范围内无记录,也会锁住该区间,阻止其他事务插入 id = 5

选型参考

  • 强一致性场景(金融对账):倾向 RR,或 RC 下辅以分布式锁 / 乐观锁。
  • 高并发场景(抢购、秒杀):倾向 RC,在应用层补偿一致性。

间隙锁复现

以下用两个并发事务演示 RR 下普通索引 UPDATE 引发的间隙锁阻塞。

终端 A(先执行,暂不提交):

UPDATE orders SET price = 100 WHERE status = 1;
-- 暂停 10 秒后再 COMMIT

终端 B(A 未提交时执行):

INSERT INTO orders (status, price) VALUES (1, 200);

预期结果:B 被阻塞,直至 A 提交或触发 Lock wait timeout exceeded。说明 RR 下对 status = 1 的 UPDATE 在普通索引上产生了间隙锁,阻塞同间隙内的 INSERT。

悲观锁与乐观锁

悲观锁(SELECT ... FOR UPDATE

在事务内锁定行,其他事务对同一行的写操作需排队等待。

TypeORM 示例:

await this.repo.findOne({
where: { id: 1 },
lock: { mode: 'pessimistic_write' },
});

适用:冲突频繁、必须保证互斥(库存扣减)。事务内逻辑应尽量短,避免 RPC 或长耗时计算占用连接。

乐观锁(Version 字段)

通过版本号检测冲突,更新时校验 version 是否变化。

TypeORM 示例:Entity 添加 @VersionColumn(),更新 SQL 形如:

UPDATE ... SET version = version + 1 WHERE id = 1 AND version = :oldVersion

影响行数为 0 表示版本冲突,应用层捕获后重试。

适用:冲突少、读多写少(如修改用户昵称)。并发 TPS 高,但冲突时会产生重试。

选型对比

方式冲突频繁(抢库存)冲突极少(改资料)
悲观锁推荐连接占用高,不推荐
乐观锁大量重试,不推荐推荐

死锁本地复现

开启两个并发请求调用同一扣库存接口,在第一个事务提交前让第二个事务持有冲突锁,可触发:

Deadlock found when trying to get lock

排查时执行 SHOW ENGINE INNODB STATUS,查看 LATEST DETECTED DEADLOCK 中的 WE ROLL BACK TRANSACTION*** (1) TRANSACTION*** (2) TRANSACTION,定位 ownerwaiter 的锁等待关系。


从单机锁到分布式一致性

典型问题

订单服务与库存服务分属不同数据库时,@Transactional 无法跨服务回滚,会出现:

  • 订单已创建,库存扣减失败 → 超卖风险需人工对账。
  • 库存已扣,订单未落库 → 库存被占用但无对应订单。

核心原则:先保证本服务数据落地,再通过消息异步驱动下游;失败靠重试 + 死信队列(DLQ)+ 补偿任务,而不是指望分布式两阶段提交。

架构概览

与 Kafka 系列文档的对应关系:

环节风险详见
生产者发消息消息丢失消息丢失解决方案
消费者处理重复消费消息重复消费
分区再分配Rebalance 丢消息/重复Rebalance 机制

生产者:事务提交后再发消息

订单创建与 Kafka 发送不能放在同一本地事务里(MQ 不参与 DB 事务)。正确顺序:

  1. 本地事务写入订单,状态 PENDING
  2. 事务提交成功后再发 Kafka 消息。
  3. 发送失败时记录日志,由补偿任务扫描 PENDING 订单重发(见文末 Outbox 方案)。
// order.service.ts
@Injectable()
export class OrderService {
constructor(
@InjectRepository(Order) private orderRepo: Repository<Order>,
private dataSource: DataSource,
private kafkaProducer: KafkaProducerService,
) {}

async createOrder(dto: CreateOrderDto) {
const order = await this.dataSource.transaction(async (manager) => {
const entity = manager.create(Order, { ...dto, stockStatus: 'PENDING' });
return manager.save(entity);
});

// 事务已提交,再发消息
try {
await this.kafkaProducer.send('inventory.deduct', {
orderId: order.id,
productId: dto.productId,
quantity: dto.quantity,
timestamp: Date.now(),
});
} catch (err) {
// Kafka 不可用:订单已落库,依赖补偿任务重发
this.logger.error('Kafka send failed, order pending retry', { orderId: order.id, err });
}
return order;
}
}

生产环境更稳妥的做法是 Transactional Outbox:在同一事务内写业务表 + outbox 表,由独立进程轮询 outbox 发 Kafka,避免「DB 提交成功、消息未发出」的窗口期无迹可寻。

消费者:扣库存 + 重试 + DLQ

消费者侧要点:

  • 先处理业务,再提交 offsetenable.auto.commit=false),避免消息丢失。
  • 乐观锁冲突等可恢复错误做有限次本地重试。
  • 不可恢复错误(库存不足)转发 DLQ 后提交 offset,避免阻塞主队列。
  • 消费逻辑必须幂等(按 orderId 去重),应对重复投递。
// inventory.consumer.ts
@Injectable()
export class InventoryConsumer {
constructor(
private inventoryService: InventoryService,
private kafkaProducer: KafkaProducerService,
) {}

@KafkaConsumer('inventory.deduct')
async handleDeduct(message: DeductPayload, context: KafkaContext) {
const { orderId, productId, quantity } = message;

try {
await this.deductWithRetry(orderId, productId, quantity);
await this.kafkaProducer.send('order.update.status', { orderId, status: 'PAID' });
await context.commitOffset();
} catch (error) {
if (this.isRetryable(error)) {
// 不提交 offset,由 Kafka 重新投递
throw error;
}
await this.kafkaProducer.send('inventory.deduct.dlq', {
originalMessage: message,
error: error.message,
failedAt: new Date().toISOString(),
});
await context.commitOffset();
}
}

private async deductWithRetry(orderId: string, productId: string, quantity: number) {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const ok = await this.inventoryService.deductStock(productId, quantity, orderId);
if (!ok) throw new ConflictException('version conflict');
return;
} catch (e) {
if (!(e instanceof ConflictException) || attempt === maxAttempts) throw e;
await sleep(attempt * 200);
}
}
}

private isRetryable(error: unknown): boolean {
return error instanceof ConflictException;
}
}

deductStock 内部按前文选型使用悲观锁或乐观锁;orderId 作为幂等键,重复消费时直接返回成功。

库存扣减与锁的衔接

场景锁策略说明
秒杀、热点 SKU悲观锁 FOR UPDATE冲突高,避免乐观锁大量重试
普通商品、冲突低乐观锁 @VersionColumn吞吐更高,冲突时消费者本地重试
扣减记录幂等唯一索引 (order_id)防止重复消费双扣
-- 悲观锁扣减(事务内)
SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE;
UPDATE inventory SET stock = stock - ? WHERE product_id = ? AND stock >= ?;

-- 乐观锁扣减
UPDATE inventory
SET stock = stock - ?, version = version + 1
WHERE product_id = ? AND version = ? AND stock >= ?;

故障演练清单

步骤操作预期
1. 正常链路下单 → 扣库存 → 订单变 PAIDConsumer Lag 趋近 0
2. 乐观锁冲突人为让首次扣减抛 ConflictException控制台可见重试日志,最终成功
3. 库存不足将库存设为 0 后下单消息进入 inventory.deduct.dlq,主队列不堵塞
4. Kafka 宕机下单时 Broker 不可用订单保持 PENDING,补偿任务恢复后重发

Outbox 补偿(生产级兜底)

仅靠 try-catch 会在服务重启或 Kafka 长时间不可用时留下大量 PENDING 订单。标准做法是:

  1. Outbox 表:与订单同事务写入 { order_id, topic, payload, status, retry_count }
  2. 定时任务(如每分钟):扫描 status = PENDING 且创建超过 5 分钟的记录,重发 Kafka 并递增 retry_count
  3. 上限与告警retry_count 超过阈值转人工或 DLQ,避免无限重试。
@Cron('*/1 * * * *')
async scanPendingOutbox() {
const rows = await this.outboxRepo.find({
where: { status: 'PENDING', retryCount: LessThan(10) },
});
for (const row of rows) {
try {
await this.kafkaProducer.send(row.topic, row.payload);
await this.outboxRepo.update(row.id, { status: 'SENT' });
} catch {
await this.outboxRepo.increment({ id: row.id }, 'retryCount', 1);
}
}
}

幂等键(orderId)保证补偿重发与正常消费结果一致,与 消息重复消费 中的去重设计一脉相承。

面试速答

Q:订单服务重启或 Kafka 宕机,如何保证 PENDING 订单最终被处理?

A:Transactional Outbox + 定时补偿扫描;消费端按 orderId 幂等;生产者 acks=all + 幂等生产者;消费者手动提交 offset 且先处理后提交。彻底失败进 DLQ 人工介入。