目录
情景介绍:
超卖问题在我们业务中很常见,当高并发访问数据库时,可能就会出现该问题,例如有100张优惠券,在1秒内被抢光,如果不考虑线程安全问题,这时候很可能卖出去超过100张。
一、认识悲观锁和乐观锁?
悲观锁:
- 概念:认为线程安全问题一定会发生,所以,为每一个线程加锁,让它们串行化执行,例如java中的synchronized,lock这些都是悲观锁。
- 优点:简单粗暴
- 缺点:性能一般
乐观锁:
- 概念:认为线程安全问题不一定发生,所有,当修改数据的时候,再次查询数据库,判断这个值有没有被修改过,这就是CAS锁机制。
- 优点:性能好
- 缺点:成功率低
为什么这里会成功率低呢?
加入有100个线程抢50张票,100个线程同时读取到了数据库,线程1修改了数据库,那么其他99个线程都会失败。。这就出现了还有票却没卖出去的问题
改进方案:
查询的时候不需要查询是否修改过,只查询是否库存>0即可
二、一人一单问题(优化)
经过测试,上面的乐观锁是一个用户下了所有的单,那么现在要求一人一单,该怎么解决呢?
解决办法:我们可以在下单之前啊,查询数据库中该用户是否下单,如果已经下单,那么直接返回,同样,这里也会遇到线程安全问题,这又该如何解决呢?
解决办法:我们还是要加锁,由于这次是判断数据库中的数据存不存在,所以不能加乐观锁了,只能加悲观锁。
public Result seckillVoucher(Long voucherId) {
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("活动还未开始");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("活动已经结束");
}
// 库存不足
if(voucher.getStock() < 1){
return Result.fail("库存不足");
}
// 注意两点
// 1.释放锁时机 先提交事务,在释放锁
// 2.防止事务失效
Long userHolder = UserHolder.getUser().getId();
synchronized (userHolder.toString().intern()){
// 使用代理对象调用该函数,防止事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createOrder(voucherId);
}
}
@Transactional
public Result createOrder(Long voucherId){
// 一人一单
Long userHolder = UserHolder.getUser().getId();
int count = query().eq("user_id", userHolder).eq("voucher_id", voucherId).count();
if(count > 0){
return Result.fail("用户已经购买一次");
}
// 更新库存
boolean success = iSeckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0).update();
if(!success){
return Result.fail("库存不足");
}
// 添加下单数据
VoucherOrder voucherOrder = new VoucherOrder();
// 全局ID
long nextId = redisIdWorker.nextId("order");
voucherOrder.setId(nextId);
// voucher_id
voucherOrder.setVoucherId(voucherId);
// 用户id
voucherOrder.setUserId(UserHolder.getUser().getId());
// 存数据
save(voucherOrder);
// 返回订单id
return Result.ok(nextId);
}
逻辑也是相当也复杂,其中要注意的是释放锁的时机,还有防止事务失效。
三、并行执行带来的问题
前面说的都是单体项目,也就是只有一个服务器,一个JVM,但是如果同时部署两台服务,又会出现一人两单问题,原因是每个JVM都维护自己的内存,这是synchronized锁只针对自己的那块内存有效,这就是并行问题。
分布式锁实现的三种方式
3.1Redis实现分布式锁
- 获取锁
- 获取失败不等待,直接返回结果(非阻塞)
问题1:这里要设置过期时间作为保底策略,因为一旦获取锁之后Redis宕机了,那么就永远无法操作这个业务了。
setnx lock thread1 # 普通
# Redis可能宕机
expire lock 10 # 设置过期时间作为保底策略
问题2:这里宕机发生了过期时间也设置不上,所以也会有问题,我们直接合并两个命令
set lock thread1 EX 10 NX
- 释放锁
del lock # 手动释放锁
下面进行代码实现,有多个版本。
3.1.1 基础代码
第一个版本的代码省略,直接上第二个版本的。
3.1.2 保证释放的锁是自己的
问题:上面逻辑有问题,因为如果线程1执行逻辑耗时比较长,这时候锁过期了,线程2就可以获取了,线程1执行完逻辑释放锁,把线程2的锁给释放了,这样又会导致并行问题。
解决:释放锁的时候只能释放自己的锁,(加锁标识)
public class SimpleRedisTemplate {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisTemplate(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 获取锁
* @param timeoutSec
* @return
*/
public boolean tryLock(Long timeoutSec){
// 1.利用UUID区分不同服务的相同线程,拼接上线程ID
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean b = stringRedisTemplate
.opsForValue()
.setIfAbsent(name + KEY_PREFIX, threadId , timeoutSec, TimeUnit.MINUTES);
return Boolean.TRUE.equals(b); // 防止b为null
}
/**
* 释放锁
*/
public void unLock(){
// 获取锁 是自己的才释放
String lockId = stringRedisTemplate.opsForValue().get(name + KEY_PREFIX);
String threadId = ID_PREFIX + Thread.currentThread().getId();
if(threadId.equals(lockId)){
stringRedisTemplate.delete(name + KEY_PREFIX);
}
}
}
3.1.3 Lua脚本保证原子性
问题:如果释放锁时JVM正在进行垃圾回收,那么该命令也会阻塞,这样也会导致锁过期而没释放,就又会重复上面的问题,所以我们要保证释放锁这一段逻辑的原子性,我们使用Lua脚本
Lua脚本简单使用:
此处有待补充~~因为我也不是很会
Lua脚本代码
-- 判断线程标识与锁标识是否一致
if(AVGV[1] == redis.call("get", KEYS[1])) then
// 释放锁
return redis.call("del", KEYS[1]);
end
return 0;
修改释放锁逻辑
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// 提前加载Lua脚本
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unLock(){
// 调用Lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(name + KEY_PREFIX),
ID_PREFIX + Thread.currentThread().getId());
}
四、总结:
- 我们首先使用了悲观锁或乐观锁解决了基本的多线程安全问题
- 针对一人一单问题 CAS机制+悲观锁,这里注意释放锁的时机还有避免让spring中的事务失效
- 使用Redis解决并行问题,因为JVM只维护自己的内存(synochrazied失效)
- Lua脚本+Redis实现最终版本的加锁和释放锁的逻辑