秒杀系统如何避免账户余额扣减的竞态条件?

296 阅读10分钟

秒杀系统如何避免账户余额扣减的竞态条件?

从理论到实践,深入解析并发控制机制在秒杀场景中的应用

一、什么是竞态条件?

1.1 定义与场景

在并发编程中,当多个线程或进程同时访问和修改共享资源时,最终的执行结果依赖于执行顺序的现象称为竞态条件(Race Condition)

1.2 一个真实的例子

想象这样的场景:用户账户余额100元,两个线程同时扣减50元。

期望结果:100 - 50 - 50 = 0元

实际结果:账户余额还剩50元,丢失了50元的更新!

1.3 问题分析

原因

  1. 线程A读取余额:100元
  2. 线程B读取余额:100元(此时线程A还未提交)
  3. 线程A计算并更新:100 - 50 = 50元
  4. 线程B计算并更新:100 - 50 = 50元
  5. 线程B的更新覆盖了线程A的更新

这就是典型的**丢失更新(Lost Update)**问题。

1.4 竞态条件流程图


二、解决方案一:悲观锁

2.1 什么是悲观锁?

悲观锁假设会发生并发冲突,因此在访问数据前先获取锁。

核心思想:先获取锁,再操作数据

2.2 数据库实现方式

-- 查询时加排他锁
SELECT * FROM account WHERE user_id = 1 FOR UPDATE;

-- 更新余额
UPDATE account SET balance = balance - 50 WHERE user_id = 1;

-- 提交事务释放锁
COMMIT;

2.3 Java代码实现

@Transactional(rollbackFor = Exception.class)
public boolean deductBalancePessimistic(Long userId, Long amount) {
    // 1. 查询并加锁
    Account account = accountMapper.selectById(userId);

    // 2. 检查余额
    if (account.getBalance().compareTo(amount) < 0) {
        return false;
    }

    // 3. 扣减余额
    account.setBalance(account.getBalance().subtract(amount));
    return accountMapper.updateById(account) > 0;
}

2.4 悲观锁的优缺点

优点

  • 实现简单,逻辑直观
  • 强一致性保证
  • 适合高冲突场景

缺点

  • 并发性能低,线程阻塞等待
  • 数据库压力大
  • 可能发生死锁

适用场景

  • 并发量不高的场景
  • 强一致性要求的场景
  • 冲突率高的写操作

2.5 生产环境最佳实践

缩小锁粒度:只锁定必要的数据,避免锁定整个用户对象

控制事务时间:事务内只做数据库操作,远程调用放到事务外

批量操作:合并多个单次更新为批量更新,减少锁竞争


三、解决方案二:乐观锁

3.1 什么是乐观锁?

乐观锁假设不会发生并发冲突,只在提交时检查。

核心思想:假设无冲突,提交时检查版本号

3.2 版本号机制实现

数据库表设计

CREATE TABLE account (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    balance DECIMAL(20,2) NOT NULL,
    version INT NOT NULL DEFAULT 0,  -- 乐观锁版本号
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

SQL更新语句

-- CAS更新
UPDATE account
SET balance = balance - 50, version = version + 1
WHERE user_id = 1 AND version = 0;

3.3 Java代码实现(带重试)

public boolean deductBalanceOptimistic(Long userId, Long amount) {
    int maxRetries = 5;
    int retryCount = 0;

    while (retryCount < maxRetries) {
        // 1. 查询账户(获取当前版本号)
        Account account = accountMapper.selectById(userId);

        if (account.getBalance().compareTo(amount) < 0) {
            return false;
        }

        // 2. CAS更新(比较并交换)
        Account updateAccount = new Account();
        updateAccount.setId(account.getId());
        updateAccount.setBalance(account.getBalance().subtract(amount));
        updateAccount.setVersion(account.getVersion());

        int rows = accountMapper.updateById(updateAccount);

        if (rows > 0) {
            return true; // 更新成功
        }

        // 3. 更新失败,版本号已变化,重试
        retryCount++;
        Thread.sleep(Math.min(100L * (1L << retryCount), 1000L));
    }

    return false;
}

3.4 乐观锁的优缺点

优点

  • 并发性能高,无锁操作
  • 不会发生死锁
  • 数据库压力小

缺点

  • 高冲突下频繁重试,性能下降
  • 可能ABA问题(可加版本号解决)
  • 需要处理重试逻辑

适用场景

  • 并发量高的场景
  • 冲突率低的场景
  • 读多写少的场景

3.5 生产环境最佳实践

动态重试次数:根据历史冲突率动态调整重试次数(3-10次)

快速失败策略:第一次冲突后立即返回错误,让调用方决定是否重试

指数退避:重试等待时间按指数增长(100ms → 200ms → 400ms...)


四、解决方案三:Redis分布式锁

4.1 为什么需要分布式锁?

在分布式系统中,JVM锁只能锁住当前JVM实例,无法跨服务节点。

4.2 Redis SET命令实现

# 加锁命令
SET lock_key unique_value NX PX 30000

# 参数说明:
# NX: 不存在才设置
# PX: 过期时间(毫秒)
# unique_value: 唯一标识,防止误删

4.3 解锁Lua脚本(保证原子性)

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

4.4 Java代码实现(Redisson)

@Autowired
private RedissonClient redissonClient;

public boolean deductBalanceWithDistributedLock(Long userId, Long amount) {
    String lockKey = "account:lock:" + userId;
    RLock lock = redissonClient.getLock(lockKey);

    boolean acquired = false;
    try {
        // 尝试获取锁
        acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);

        if (!acquired) {
            return false;
        }

        // 执行业务逻辑
        Account account = accountMapper.selectById(userId);
        if (account.getBalance().compareTo(amount) < 0) {
            return false;
        }

        account.setBalance(account.getBalance().subtract(amount));
        return accountMapper.updateById(account) > 0;

    } finally {
        if (acquired && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

4.5 Redis分布式锁的关键点

  1. NX参数:只有key不存在时才设置,防止覆盖
  2. PX参数:设置过期时间,防止死锁
  3. unique_value:唯一标识,防止误删其他线程的锁
  4. Lua脚本:保证解锁操作的原子性
  5. 看门狗:Redisson自动续期,防止业务未执行完锁过期
  6. RedLock:多节点部署,提高可用性

4.6 生产环境最佳实践

超时时间设置:根据业务类型设置合理的超时时间(通常为历史平均执行时间的3-5倍)

可重入性:同一线程可多次获取同一把锁,避免死锁

监控告警:监控锁持有时间、等待时间,设置告警阈值


五、三种锁的详细对比

对比维度悲观锁乐观锁分布式锁
低并发场景性能接近性能接近性能接近
高并发场景锁竞争严重性能更优性能较好
冲突率高适合频繁重试适合
冲突率低浪费锁资源最佳选择性能好
CPU消耗高(CAS自旋)
实现复杂度简单中等中等
分布式支持不支持需额外处理原生支持
死锁风险无(带过期时间)
一致性保证最终一致

六、秒杀系统架构设计

6.1 核心设计思路

  1. Redis预扣库存:使用DECR原子操作,内存级别响应速度
  2. MQ异步处理:削峰填谷,订单创建异步化
  3. 乐观锁扣减:版本号CAS机制,保证数据一致性
  4. 分库分表:按user_id分片,水平扩展
  5. 读写分离:1主2从,查询压力分散
  6. 限流降级:Sentinel保护系统稳定性

6.2 秒杀核心流程

public SeckillResult doSeckill(Long userId, Long activityId, Integer quantity) {
    // 1. 检查活动状态
    SeckillActivity activity = getActivityFromCache(activityId);

    // 2. 检查用户限购
    String limitKey = "seckill:limit:" + activityId + ":" + userId;
    Integer userBought = (Integer) redisTemplate.opsForValue().get(limitKey);
    if (userBought != null && userBought + quantity > activity.getLimitPerUser()) {
        return SeckillResult.fail("超过限购数量");
    }

    // 3. Redis预扣库存(原子递减)
    String stockKey = "seckill:stock:" + activityId;
    Long remainingStock = redisTemplate.opsForValue().decrement(stockKey, quantity);

    if (remainingStock == null || remainingStock < 0) {
        // 库存不足,回滚
        redisTemplate.opsForValue().increment(stockKey, quantity);
        return SeckillResult.fail("库存不足");
    }

    // 4. 记录用户购买数量
    redisTemplate.opsForValue().set(limitKey, userBought + quantity, 1, TimeUnit.HOURS);

    // 5. 发送MQ消息(异步创建订单)
    sendOrderMessage(userId, activity, quantity);

    // 6. 返回秒杀结果
    return SeckillResult.success("秒杀成功,请等待支付");
}

6.3 秒杀流程时序图


七、高性能架构设计

7.1 多级缓存架构

  • L1本地缓存:Caffeine缓存,1秒过期,减少Redis访问
  • L2分布式缓存:Redis缓存,5分钟过期,共享数据
  • L3数据库:MySQL持久化存储

7.2 库存分桶策略

将总库存分到多个Redis桶中,秒杀时随机选择桶扣减,降低竞争热度:

// 库存预热:分10个桶
for (int i = 0; i < 10; i++) {
    String bucketKey = "seckill:stock:" + activityId + ":bucket:" + i;
    redisTemplate.opsForValue().set(bucketKey, totalStock / 10);
}

// 秒杀扣减:随机选择桶
int bucketIndex = ThreadLocalRandom.current().nextInt(10);
String bucketKey = "seckill:stock:" + activityId + ":bucket:" + bucketIndex;
redisTemplate.opsForValue().decrement(bucketKey, quantity);

7.3 限流保护

  • QPS限流:Guava RateLimiter限制总请求量
  • 用户级限流:Redis + Lua实现用户维度的限流
  • 库存预热:提前将库存加载到Redis

八、最佳实践

8.1 高可用配置

数据库连接池配置

spring:
  datasource:
    druid:
      initial-size: 10
      min-idle: 10
      max-active: 100
      max-wait: 5000
      test-while-idle: true
      time-between-eviction-runs-millis: 60000

Redis连接池配置

spring:
  redis:
    lettuce:
      pool:
        max-active: 50
        max-idle: 20
        min-idle: 5
        max-wait: 3000ms

8.2 监控告警

关键指标

  • 秒杀成功率
  • 库存扣减P99耗时
  • Redis连接池使用率
  • 数据库连接池使用率
  • MQ消息堆积量

告警规则

  • 秒杀成功率 < 95%
  • 库存扣减P99 > 100ms
  • Redis连接池使用率 > 90%

8.3 熔断降级

使用Resilience4j实现熔断降级:

  • 失败率阈值:50%
  • 慢调用阈值:3秒
  • 半开状态允许调用数:5次
  • 降级策略:保存到待处理队列,异步处理
8.3.1 熔断器工作原理

熔断器模式(Circuit Breaker Pattern)是一种保护机制,用于防止系统在某个服务出现故障时持续调用导致雪崩效应。

三种状态

  1. 关闭状态(CLOSED):正常情况下,熔断器关闭,请求正常通过。当失败率超过阈值时,熔断器打开。

  2. 打开状态(OPEN):熔断器打开后,所有请求直接被拒绝,执行降级逻辑。经过一段时间后进入半开状态。

  3. 半开状态(HALF_OPEN):允许少量请求通过,如果这些请求成功,则关闭熔断器;如果失败,则继续打开。

8.3.2 监控指标实现

核心监控指标

@Service
public class MetricsService {

    @Autowired
    private MeterRegistry meterRegistry;

    // 秒杀成功次数计数器
    private Counter seckillSuccessCounter;

    // 秒杀失败次数计数器
    private Counter seckillFailureCounter;

    // 余额扣减成功次数计数器
    private Counter balanceDeductSuccessCounter;

    // 余额扣减失败次数计数器
    private Counter balanceDeductFailureCounter;

    /**
     * 记录秒杀成功
     */
    public void recordSeckillSuccess(String activityId) {
        seckillSuccessCounter.increment();
    }

    /**
     * 记录余额扣减成功
     */
    public void recordBalanceDeductSuccess(String lockType, Long userId) {
        balanceDeductSuccessCounter.increment();
    }

    /**
     * 获取监控统计数据
     */
    public MetricsData getMetricsData() {
        MetricsData data = new MetricsData();
        data.setSeckillSuccessCount(seckillSuccessCounter.count());
        data.setSeckillFailureCount(seckillFailureCounter.count());
        data.setBalanceDeductSuccessCount(balanceDeductSuccessCounter.count());
        data.setBalanceDeductFailureCount(balanceDeductFailureCounter.count());

        // 计算成功率
        long total = data.getSeckillSuccessCount() + data.getSeckillFailureCount();
        if (total > 0) {
            data.setSeckillSuccessRate((double) data.getSeckillSuccessCount() / total * 100);
        }

        return data;
    }
}
8.3.3 熔断器配置实现

Resilience4j熔断器配置

@Configuration
public class CircuitBreakerConfig {

    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry() {
        return CircuitBreakerRegistry.of(
            CircuitBreakerConfig.custom()
                .slidingWindowSize(10)  // 滑动窗口大小
                .failureRateThreshold(50) // 失败率阈值50%
                .waitDurationInOpenState(Duration.ofSeconds(30)) // 开启后等待30秒
                .permittedNumberOfCallsInHalfOpenState(5) // 半开状态允许5次调用
                .slowCallRateThreshold(50) // 慢调用率阈值50%
                .slowCallDurationThreshold(Duration.ofSeconds(3)) // 慢调用阈值3秒
                .build(),
            "balanceDeduct",  // 余额扣减熔断器
            "seckillService"   // 秒杀服务熔断器
        );
    }
}
8.3.4 带熔断保护的余额扣减
@Service
public class SeckillServiceWithCircuitBreaker {

    @Autowired
    private CircuitBreakerRegistry circuitBreakerRegistry;

    // 待处理队列(降级时保存请求)
    private final ConcurrentHashMap<Long, AtomicInteger> pendingRequests = new ConcurrentHashMap<>();

    /**
     * 带熔断保护的余额扣减
     */
    public boolean deductBalanceWithCircuitBreaker(Long userId, Long amount, String lockType) {
        CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("balanceDeduct");

        return Try.of(() -> {
            // 检查熔断器状态
            if (circuitBreaker.getState() == CircuitBreaker.State.OPEN) {
                log.warn("熔断器已开启,执行降级策略");
                return fallbackDeductBalance(userId, amount);
            }

            // 正常业务逻辑
            boolean result = doDeductBalance(userId, amount);

            // 记录成功指标
            metricsService.recordBalanceDeductSuccess(lockType, userId);

            return result;

        }).recover(throwable -> {
            // 熔断或异常时的降级处理
            log.warn("执行降级处理,用户: {}", userId);
            return fallbackDeductBalance(userId, amount);
        }).get();
    }

    /**
     * 降级处理:保存到待处理队列
     */
    private boolean fallbackDeductBalance(Long userId, Long amount) {
        log.info("执行降级策略,保存到待处理队列,用户: {}, 金额: {}", userId, amount);
        pendingRequests.computeIfAbsent(userId, k -> new AtomicInteger(0)).addAndGet(amount.intValue());
        return true; // 返回true表示已接受请求(异步处理)
    }

    /**
     * 处理待处理队列中的请求
     */
    public void processPendingRequests() {
        log.info("开始处理待处理队列,待处理用户数: {}", pendingRequests.size());

        pendingRequests.forEach((userId, amount) -> {
            try {
                boolean success = doDeductBalance(userId, amount.longValue());
                if (success) {
                    pendingRequests.remove(userId);
                    log.info("待处理请求处理成功,用户: {}", userId);
                }
            } catch (Exception e) {
                log.error("待处理请求处理失败,用户: {}", userId, e);
            }
        });
    }
}
8.3.5 前端监控面板实现

实时监控面板

九、总结

9.1 三种锁的选择建议

场景推荐方案理由
单机低并发悲观锁实现简单,数据一致性强
单机高并发乐观锁无锁竞争,性能最优
分布式系统Redis分布式锁跨节点互斥,可靠性高
秒杀场景Redis + MQ + 乐观锁结合各自优势

9.2 性能对比数据(测试环境:8核16G,100并发线程)

锁类型成功率平均耗时(ms)吞吐量(ops/s)P99耗时(ms)
悲观锁100%245.32408512
乐观锁100%78.211278187
分布式锁100%156.45639321

本文介绍了秒杀系统中账户余额扣减的竞态条件问题,以及三种解决方案的详细对比。通过实际代码示例和架构图,帮助你理解不同锁机制的适用场景,设计出高性能高可用的秒杀系统。