秒杀系统如何避免账户余额扣减的竞态条件?
从理论到实践,深入解析并发控制机制在秒杀场景中的应用
一、什么是竞态条件?
1.1 定义与场景
在并发编程中,当多个线程或进程同时访问和修改共享资源时,最终的执行结果依赖于执行顺序的现象称为竞态条件(Race Condition)。
1.2 一个真实的例子
想象这样的场景:用户账户余额100元,两个线程同时扣减50元。
期望结果:100 - 50 - 50 = 0元
实际结果:账户余额还剩50元,丢失了50元的更新!
1.3 问题分析
原因:
- 线程A读取余额:100元
- 线程B读取余额:100元(此时线程A还未提交)
- 线程A计算并更新:100 - 50 = 50元
- 线程B计算并更新:100 - 50 = 50元
- 线程B的更新覆盖了线程A的更新
这就是典型的**丢失更新(Lost Update)**问题。
1.4 竞态条件流程图

二、解决方案一:悲观锁
2.1 什么是悲观锁?
悲观锁假设会发生并发冲突,因此在访问数据前先获取锁。
核心思想:先获取锁,再操作数据
2.2 数据库实现方式
sql
-- 查询时加排他锁
SELECT * FROM account WHERE user_id = 1 FOR UPDATE;
-- 更新余额
UPDATE account SET balance = balance - 50 WHERE user_id = 1;
-- 提交事务释放锁
COMMIT;
2.3 Java代码实现
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 版本号机制实现
数据库表设计:
sql
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更新语句:
sql
-- CAS更新
UPDATE account
SET balance = balance - 50, version = version + 1
WHERE user_id = 1 AND version = 0;
3.3 Java代码实现(带重试)
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命令实现
bash
# 加锁命令
SET lock_key unique_value NX PX 30000
# 参数说明:
# NX: 不存在才设置
# PX: 过期时间(毫秒)
# unique_value: 唯一标识,防止误删
4.3 解锁Lua脚本(保证原子性)
lua
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
4.4 Java代码实现(Redisson)
java
@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分布式锁的关键点
- NX参数:只有key不存在时才设置,防止覆盖
- PX参数:设置过期时间,防止死锁
- unique_value:唯一标识,防止误删其他线程的锁
- Lua脚本:保证解锁操作的原子性
- 看门狗:Redisson自动续期,防止业务未执行完锁过期
- RedLock:多节点部署,提高可用性
4.6 生产环境最佳实践
超时时间设置:根据业务类型设置合理的超时时间(通常为历史平均执行时间的3-5倍)
可重入性:同一线程可多次获取同一把锁,避免死锁
监控告警:监控锁持有时间、等待时间,设置告警阈值
五、三种锁的详细对比

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

6.1 核心设计思路
- Redis预扣库存:使用DECR原子操作,内存级别响应速度
- MQ异步处理:削峰填谷,订单创建异步化
- 乐观锁扣减:版本号CAS机制,保证数据一致性
- 分库分表:按user_id分片,水平扩展
- 读写分离:1主2从,查询压力分散
- 限流降级:Sentinel保护系统稳定性
6.2 秒杀核心流程
java
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桶中,秒杀时随机选择桶扣减,降低竞争热度:
java
// 库存预热:分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 高可用配置
数据库连接池配置:
yaml
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连接池配置:
yaml
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)是一种保护机制,用于防止系统在某个服务出现故障时持续调用导致雪崩效应。
三种状态:
-
关闭状态(CLOSED):正常情况下,熔断器关闭,请求正常通过。当失败率超过阈值时,熔断器打开。
-
打开状态(OPEN):熔断器打开后,所有请求直接被拒绝,执行降级逻辑。经过一段时间后进入半开状态。
-
半开状态(HALF_OPEN):允许少量请求通过,如果这些请求成功,则关闭熔断器;如果失败,则继续打开。
8.3.2 监控指标实现
核心监控指标:
java
@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熔断器配置:
java
@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 带熔断保护的余额扣减
java
@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.32 | 408 | 512 |
| 乐观锁 | 100% | 78.21 | 1278 | 187 |
| 分布式锁 | 100% | 156.45 | 639 | 321 |
本文介绍了秒杀系统中账户余额扣减的竞态条件问题,以及三种解决方案的详细对比。通过实际代码示例和架构图,帮助你理解不同锁机制的适用场景,设计出高性能高可用的秒杀系统。