1. 锁机制在优惠券发放场景中的核心作用
在电商系统中,优惠券发放是一个典型的高并发场景。想象一下双十一大促时,成千上万的用户同时点击"立即领取"按钮的场景。如果没有合理的并发控制,就会出现两种严重问题:
- 超发问题:优惠券实际发放数量超过预设库存
- 超领问题:单个用户领取数量超过个人限领数量
我曾在某电商平台的618大促中亲历过这样的惨案:由于锁机制设计缺陷,价值50元的全场通用券被超发3万多张,直接造成150万元的经济损失。这个教训让我深刻认识到锁机制在高并发场景中的重要性。
2. 乐观锁与悲观锁的选型考量
2.1 乐观锁的实现原理
乐观锁的核心思想是"先查后改+版本控制"。在优惠券场景中,典型的实现方式是在数据库表中增加version字段:
sql复制UPDATE coupon
SET stock = stock - 1,
version = version + 1
WHERE id = 123
AND version = 5 -- 查询时获得的版本号
AND stock > 0;
关键点:当并发更新发生时,只有第一个线程能成功执行,后续线程会因为version不匹配而更新失败。这种方案特别适合读多写少的场景。
2.2 Synchronized的适用场景
Java中的synchronized是典型的悲观锁实现。在单体应用中,我们可以这样保护领券逻辑:
java复制public synchronized void acquireCoupon(Long userId, Long couponId) {
// 领券核心逻辑
}
但synchronized有三个明显局限:
- 只对单个JVM有效
- 锁粒度太粗会严重影响性能
- 无法设置超时时间
2.3 分布式环境下的锁选择
在微服务架构下,Redis分布式锁成为首选方案。其核心命令是:
bash复制SET lock_key unique_value NX PX 30000
这个命令实现了:
- NX:只有当key不存在时才设置(原子性)
- PX:设置过期时间(避免死锁)
- unique_value:标识锁持有者(通常用UUID)
3. Redis分布式锁的完整实现方案
3.1 基础实现与潜在问题
一个基础的Redis锁实现如下:
java复制public boolean tryLock(String key, String value, long expireTime) {
return redisTemplate.opsForValue()
.setIfAbsent(key, value, expireTime, TimeUnit.MILLISECONDS);
}
public void unlock(String key, String value) {
if (value.equals(redisTemplate.opsForValue().get(key))) {
redisTemplate.delete(key);
}
}
但这个实现存在三个致命缺陷:
- 判断value和删除不是原子操作
- 没有锁续期机制
- 锁过期时间设置不合理可能导致业务未完成锁已释放
3.2 使用Lua脚本保证原子性
解决原子性问题的最佳方案是使用Lua脚本:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
在Spring中我们可以这样集成:
java复制private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
public void unlock(String key, String value) {
redisTemplate.execute(
new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
Collections.singletonList(key),
value);
}
3.3 Redisson的完整解决方案
对于生产环境,我强烈推荐使用Redisson库。它提供了完善的分布式锁实现:
java复制RLock lock = redissonClient.getLock("coupon:lock:" + couponId);
try {
// 尝试加锁,最多等待100秒,上锁后30秒自动解锁
if (lock.tryLock(100, 30, TimeUnit.SECONDS)) {
// 执行业务逻辑
}
} finally {
lock.unlock();
}
Redisson的优势在于:
- 自动续期机制(看门狗)
- 丰富的锁类型(可重入锁、公平锁、联锁等)
- 完善的容错处理
4. 性能优化与架构设计
4.1 缓存预热与库存分段
为了减轻数据库压力,我们可以:
- 活动开始前将优惠券信息加载到Redis
- 采用库存分段技术(如将10000张券分成10个段)
java复制// 库存分段示例
public boolean tryAcquireSegment(Long couponId, int segment) {
String key = "coupon:stock:" + couponId + ":" + segment;
Long remain = redisTemplate.opsForValue().decrement(key);
return remain != null && remain >= 0;
}
4.2 异步化处理与MQ削峰
领券操作可以拆分为:
- 快速校验(Redis中完成)
- 异步处理(MQ消息)
java复制public void asyncAcquireCoupon(Long userId, Long couponId) {
// 1. 快速校验
if (!checkInRedis(userId, couponId)) {
throw new BusinessException("领取失败");
}
// 2. 发送MQ消息
mqTemplate.send(new Message(
"COUPON_ACQUIRE_TOPIC",
JSON.toJSONBytes(new AcquireMessage(userId, couponId))
));
}
4.3 Lua脚本整合校验逻辑
将多个校验步骤整合到单个Lua脚本中:
lua复制local userKey = KEYS[1]
local couponKey = KEYS[2]
local userId = ARGV[1]
local limit = tonumber(ARGV[2])
-- 检查用户已领数量
local userCount = redis.call('hget', userKey, userId)
if userCount and tonumber(userCount) >= limit then
return 0
end
-- 检查优惠券库存
local couponStock = redis.call('get', couponKey)
if not couponStock or tonumber(couponStock) <= 0 then
return 0
end
-- 扣减库存
redis.call('decr', couponKey)
redis.call('hincrby', userKey, userId, 1)
return 1
5. 实战中的经验与教训
5.1 锁的粒度控制
我曾见过一个案例:将所有优惠券使用同一把锁,导致系统吞吐量只有50TPS。正确的做法是:
- 按优惠券ID分锁
- 用户维度的锁单独处理
java复制// 好的锁粒度示例
String lockKey = "coupon:lock:" + couponId + ":" + userId.substring(0, 4);
5.2 超时时间设置原则
根据实际业务设置合理的超时时间:
- 评估业务逻辑的最长执行时间
- 设置超时时间为最长执行时间的2-3倍
- 对于不确定时间的操作,使用Redisson的看门狗机制
5.3 监控与告警配置
必须对锁的使用情况进行监控:
- 锁等待时间过长告警
- 锁竞争激烈时自动扩容
- 记录锁获取失败率
java复制// 监控示例
long start = System.currentTimeMillis();
if (lock.tryLock()) {
try {
long cost = System.currentTimeMillis() - start;
metrics.recordLockWaitTime(cost);
// 业务逻辑
} finally {
lock.unlock();
}
} else {
metrics.recordLockFail();
}
6. 常见问题排查指南
6.1 锁无法释放问题
现象:Redis中锁key长期存在
排查步骤:
- 检查业务逻辑是否有未捕获的异常
- 确认finally块中调用了unlock
- 检查锁value是否匹配(防止误删)
6.2 性能突然下降
现象:系统吞吐量骤降
可能原因:
- 锁竞争激烈(使用redis-cli monitor观察)
- 某个业务处理时间变长
- Redis连接池耗尽
解决方案:
- 细化锁粒度
- 引入分段锁
- 增加Redis节点
6.3 数据不一致问题
现象:库存出现负数
排查步骤:
- 检查Lua脚本逻辑是否完整
- 确认所有库存操作都通过锁保护
- 检查是否有后台任务直接操作数据库
在实际项目中,我曾遇到一个隐蔽的bug:运维人员直接连接数据库执行了库存调整,绕过了锁机制。这提醒我们,完善的权限控制和操作审计同样重要。