基于Mysql 8.0新特性实现钱包余额高并发加钱

基于Mysql 8.0新特性实现钱包余额高并发加钱

目录

  1. MySQL SKIP LOCKED 特性详解
  2. 钱包余额并发处理:加钱拆分 + 扣钱归集

1. MySQL SKIP LOCKED 特性详解

是什么

SKIP LOCKED 是 MySQL 8.0 引入的行级锁特性,用于 SELECT ... FOR UPDATE 或 SELECT ... FOR SHARE 语句中,跳过被其他事务锁定的行,而不是等待锁释放。

对比传统行为

方式 行为 遇到锁时的表现
SELECT ... FOR UPDATE 默认 阻塞等待,直到锁释放或超时
SELECT ... FOR UPDATE NOWAIT MySQL 8.0+ 立即报错,不等待
SELECT ... FOR UPDATE SKIP LOCKED MySQL 8.0+ 直接跳过被锁的行,返回未被锁的行

语法

SELECT * FROM orders
WHERE status = 'PENDING'
FOR UPDATE SKIP LOCKED;

典型使用场景:任务队列并发消费

假设有一张任务表:

CREATE TABLE task_queue (
    id BIGINT PRIMARY KEY,
    task_name VARCHAR(100),
    status ENUM('PENDING', 'PROCESSING', 'DONE'),
    worker_id VARCHAR(50)
);

多个消费者并发抢占任务:

-- 消费者1 开启事务
BEGIN;
SELECT * FROM task_queue
WHERE status = 'PENDING'
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- 假设拿到了 id=1 的行,将其锁定

UPDATE task_queue SET status = 'PROCESSING', worker_id = 'worker-1'
WHERE id = 1;
COMMIT;
-- 消费者2 同时开启事务
BEGIN;
SELECT * FROM task_queue
WHERE status = 'PENDING'
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- 跳过被消费者1锁定的 id=1,直接拿到 id=2

UPDATE task_queue SET status = 'PROCESSING', worker_id = 'worker-2'
WHERE id = 2;
COMMIT;

执行流程图示

假设表中有 3 条 PENDING 记录,两个消费者并发执行:

id=1  id=2  id=3   (status 都是 PENDING)

消费者1: 锁定 id=1 ✓  ──→  返回 id=1
消费者2: 遇到 id=1 已锁 ✗──→ 跳过
         锁定 id=2 ✓  ──→  返回 id=2

与其他方案的对比

方案 问题
普通 FOR UPDATE 并发消费者互相阻塞,退化成串行
乐观锁(版本号) 高并发下大量重试,浪费 CPU
Redis 分布式锁 引入额外中间件,增加复杂度
SKIP LOCKED 无需等待、无需重试、无额外组件

注意事项

  1. MySQL 版本要求:必须 8.0+
  2. 结果不确定性:跳过的行不代表不存在,只是当前被锁,不保证返回"全部可用行"
  3. 不适用于需要完整数据集的场景:如果你需要读取所有符合条件的行且不允许遗漏,不能用 SKIP LOCKED
  4. 索引要求:SKIP LOCKED 在 InnoDB 中工作,建议查询走索引,否则可能锁住过多行导致大量跳过

总结:SKIP LOCKED = "我要拿锁,但别人锁住的行我不要,直接跳过,给我剩下的就行"——专为高并发任务队列场景设计,避免了阻塞等待,实现了真正的无锁并发消费。


2. 钱包余额并发处理:加钱拆分 + 扣钱归集

核心思路

将一个钱包余额拆成 N 个子行(slot),用 SKIP LOCKED 实现不同请求锁定不同子行,达到并发。约定 slot_index = 0 为主 slot,扣钱只从主 slot 扣。

CREATE TABLE wallet_slot (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    slot_index INT NOT NULL,
    balance DECIMAL(18,2) NOT NULL DEFAULT 0,
    version BIGINT NOT NULL DEFAULT 0,
    UNIQUE KEY uk_user_slot (user_id, slot_index),
    INDEX idx_user (user_id)
) ENGINE=InnoDB;

比如 user_id=1001 拆成 10 个 slot:

id user_id slot_index balance
1 1001 0 50.00
2 1001 1 30.00
... 1001 ... ...
10 1001 9 20.00

总余额 = SUM(balance) = 100.00

加钱(充值)— 简单,效果极佳

BEGIN;
-- 随机挑一个没被锁的 slot,加上金额
SELECT * FROM wallet_slot
WHERE user_id = 1001
ORDER BY RAND()
LIMIT 1
FOR UPDATE SKIP LOCKED;

-- 假设拿到 slot_index=3,余额 50
UPDATE wallet_slot
SET balance = balance + 100.00, version = version + 1
WHERE user_id = 1001 AND slot_index = 3;

COMMIT;

并发效果:10 个并发充值请求可以分别锁不同的 slot,完全并行,吞吐量提升近 10 倍。

扣钱(提现/消费)— 非常复杂,是核心难题

难点:你不知道哪个 slot 有足够余额

场景:总余额 100,要扣 80
slot 0: 50  ← 被其他事务锁住了
slot 1: 30  ← 可用
slot 2: 20  ← 可用

用 SKIP LOCKED 会跳过 slot 0,只看到 slot1+slot2=50
不够扣 80,但其实总余额是够的

扣钱完整流程

扣钱请求
  │
  ▼
锁主slot(slot_index=0) ──→ 余额足够?──是──→ 直接扣 ──→ 结束
                              │
                              否
                              ▼
                        按需归集(从其他slot搬余额)
                              │
                              ▼
                        归集后余额足够?──是──→ 扣款 ──→ 结束
                              │
                              否
                              ▼
                        余额不足,抛异常

扣钱 + 按需归集 Java 实现

public void deduct(Long userId, BigDecimal amount) {
    // 1. 锁定主 slot (slot_index=0)
    WalletSlot primarySlot = lockSlot(userId, 0);
    if (primarySlot == null) {
        throw new BizException("主账户不存在");
    }

    // 2. 主 slot 余额足够,直接扣
    if (primarySlot.getBalance().compareTo(amount) >= 0) {
        deductFromSlot(userId, 0, amount);
        return;
    }

    // 3. 余额不足,触发按需归集
    BigDecimal totalAvailable = primarySlot.getBalance();
    List<WalletSlot> lockedSubSlots = new ArrayList<>();

    // 按 slot_index 升序逐个锁定(跳过主slot和被锁的slot)
    for (int i = 1; i < SLOT_COUNT; i++) {
        WalletSlot subSlot = lockSlotSkipLocked(userId, i);
        if (subSlot == null) continue; // 被其他事务锁住,跳过
        lockedSubSlots.add(subSlot);
        totalAvailable = totalAvailable.add(subSlot.getBalance());

        // 凑够了就停,不需要锁更多行
        if (totalAvailable.compareTo(amount) >= 0) break;
    }

    // 4. 归集后仍然不足
    if (totalAvailable.compareTo(amount) < 0) {
        throw new BizException("余额不足");
    }

    // 5. 执行归集:将子slot余额清零,汇入主slot
    BigDecimal needCollect = amount.subtract(primarySlot.getBalance());
    for (WalletSlot sub : lockedSubSlots) {
        if (needCollect.compareTo(BigDecimal.ZERO) <= 0) break;
        BigDecimal canCollect = sub.getBalance().min(needCollect);
        deductFromSlot(userId, sub.getSlotIndex(), canCollect);
        needCollect = needCollect.subtract(canCollect);
    }

    // 6. 主slot扣款(此时余额已足够)
    deductFromSlot(userId, 0, amount);
}

定时全量归集(兜底保障)

按需归集只搬"够用"的金额,会导致子 slot 中仍有零散余额。需要定时任务做全量归集:

@Scheduled(fixedDelay = 5000) // 每5秒
public void fullConsolidation() {
    // 找出有零散余额的用户(子slot余额 > 0)
    List<Long> userIds = jdbcTemplate.queryForList(
        "SELECT DISTINCT user_id FROM wallet_slot " +
        "WHERE slot_index > 0 AND balance > 0 LIMIT 100",
        Long.class);

    for (Long userId : userIds) {
        try { consolidateUser(userId); }
        catch (Exception e) { log.warn("归集失败", e); }
    }
}

private void consolidateUser(Long userId) {
    // 按固定顺序锁全部slot
    List<WalletSlot> allSlots = jdbcTemplate.query(
        "SELECT * FROM wallet_slot WHERE user_id = ? ORDER BY slot_index FOR UPDATE SKIP LOCKED",
        rowMapper, userId);

    BigDecimal totalToCollect = BigDecimal.ZERO;
    List<Integer> subSlotIndexes = new ArrayList<>();

    for (WalletSlot slot : allSlots) {
        if (slot.getSlotIndex() > 0 && slot.getBalance().compareTo(BigDecimal.ZERO) > 0) {
            totalToCollect = totalToCollect.add(slot.getBalance());
            subSlotIndexes.add(slot.getSlotIndex());
        }
    }

    if (totalToCollect.compareTo(BigDecimal.ZERO) <= 0) return;

    // 子slot清零
    for (Integer idx : subSlotIndexes) {
        jdbcTemplate.update(
            "UPDATE wallet_slot SET balance = 0, version = version + 1 " +
            "WHERE user_id = ? AND slot_index = ?", userId, idx);
    }

    // 主slot增加
    jdbcTemplate.update(
        "UPDATE wallet_slot SET balance = balance + ?, version = version + 1 " +
        "WHERE user_id = ? AND slot_index = 0", totalToCollect, userId);
}

防死锁核心原则

死锁场景:
  事务A:锁 slot1 → 锁 slot0  (先子后主)
  事务B:锁 slot0 → 锁 slot1  (先主后子)
  → 死锁!

解决:所有操作永远按 slot_index 升序加锁
操作 加锁顺序
加钱 随机选 → SKIP LOCKED 跳过冲突
扣钱 先锁 slot0 → 再锁 slot1,2,3...
定时归集 按 slot_index 升序锁
余额查询 SKIP LOCKED 或 READ COMMITTED 快照读

完整容错链路

按需归集(实时,可能不完整)
      ↓ 残留零散余额
定时全量归集(每5秒,尽量合并)
      ↓ 极端情况仍有残留
对账修正(T+1 对账,最终一致)

策略对比总结

维度 单行锁 拆分+SKIP LOCKED
加钱并发 ❌ 串行 ✅ 近 N 倍并发
扣钱并发 ❌ 串行 ⚠️ 改善有限
余额查询 简单 需要 SUM
对账复杂度 低 高(多行需聚合)
数据一致性 简单 复杂
开发成本 低 高

实际建议

场景 建议
充值为主、扣款少(如押金账户) 值得拆分,效果显著
充扣均衡(如支付钱包) 拆分 + 异步归集,扣钱走主 slot
充扣都很频繁 考虑 Redis 预扣 + 异步入库 方案
并发量不大(QPS < 1000) 单行 + 乐观锁足矣,不必拆分

总结:拆分加钱效果好,拆分扣钱代价高,实际落地通常用"加钱拆分 + 扣钱归集"的组合策略。核心思路:扣钱时能凑多少凑多少(按需归集),凑不到的留给后台慢慢搬(定时归集),最终由对账兜底。

posted @ 2026-05-29 14:23  Nervermore10086  阅读(39)  评论(0)    收藏  举报