优惠券秒杀案例 - CAS、Redis+Lua脚本解决高并发并行

目录

一、认识悲观锁和乐观锁?

二、一人一单问题(优化)

三、并行执行带来的问题

3.1Redis实现分布式锁

[3.1.1 基础代码](#3.1.1 基础代码)

[3.1.2 保证释放的锁是自己的](#3.1.2 保证释放的锁是自己的)

[3.1.3 Lua脚本保证原子性](#3.1.3 Lua脚本保证原子性)


情景介绍:

超卖问题在我们业务中很常见,当高并发访问数据库时,可能就会出现该问题,例如有100张优惠券,在1秒内被抢光,如果不考虑线程安全问题,这时候很可能卖出去超过100张。

一、认识悲观锁和乐观锁?

悲观锁:

  • **概念:**认为线程安全问题一定会发生,所以,为每一个线程加锁,让它们串行化执行,例如java中的synchronized,lock这些都是悲观锁。
  • **优点:**简单粗暴
  • **缺点:**性能一般

乐观锁:

  • **概念:**认为线程安全问题不一定发生,所有,当修改数据的时候,再次查询数据库,判断这个值有没有被修改过,这就是CAS锁机制。
  • **优点:**性能好
  • **缺点:**成功率低

为什么这里会成功率低呢?

加入有100个线程抢50张票,100个线程同时读取到了数据库,线程1修改了数据库,那么其他99个线程都会失败。。这就出现了还有票却没卖出去的问题

改进方案:

查询的时候不需要查询是否修改过,只查询是否库存>0即可


二、一人一单问题(优化)

经过测试,上面的乐观锁是一个用户下了所有的单,那么现在要求一人一单,该怎么解决呢?

解决办法:我们可以在下单之前啊,查询数据库中该用户是否下单,如果已经下单,那么直接返回,同样,这里也会遇到线程安全问题,这又该如何解决呢?

解决办法:我们还是要加锁,由于这次是判断数据库中的数据存不存在,所以不能加乐观锁了,只能加悲观锁。

java 复制代码
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宕机了,那么就永远无法操作这个业务了。

sql 复制代码
setnx lock thread1 # 普通
# Redis可能宕机
expire lock 10 # 设置过期时间作为保底策略

**问题2:**这里宕机发生了过期时间也设置不上,所以也会有问题,我们直接合并两个命令

sql 复制代码
set lock thread1 EX 10 NX
  • 释放锁
sql 复制代码
del lock # 手动释放锁

下面进行代码实现,有多个版本。

3.1.1 基础代码

第一个版本的代码省略,直接上第二个版本的。

3.1.2 保证释放的锁是自己的

**问题:**上面逻辑有问题,因为如果线程1执行逻辑耗时比较长,这时候锁过期了,线程2就可以获取了,线程1执行完逻辑释放锁,把线程2的锁给释放了,这样又会导致并行问题。

**解决:**释放锁的时候只能释放自己的锁,(加锁标识)

java 复制代码
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脚本代码

Lua 复制代码
-- 判断线程标识与锁标识是否一致
if(AVGV[1] == redis.call("get", KEYS[1])) then
	// 释放锁
	return redis.call("del", KEYS[1]);
end
return 0;

修改释放锁逻辑

java 复制代码
    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实现最终版本的加锁和释放锁的逻辑
相关推荐
时间静止不是简史3 小时前
生产事故排查:Redis 宕机导致补偿队列消费线程死亡,通话录音未上送业务平台
java·redis
2301_32241428046 小时前
基于SpringBoot+Vue的休闲零食超市管理系统的设计与实现59004-计算机毕设 原创(免费领源码+部署教程+开发环境)
java·vue.js·spring boot·redis·mysql·决策树·推荐算法
catoop9 小时前
API Key 设计与安全生成方案:结构拆解、数据库设计与 Java 实现
java·数据库·安全
Web极客码9 小时前
我用 Claude 分析 Redis 哨兵日志,实现跨机房数据不一致自动对齐
数据库·redis·php
库库的数据库笔记9 小时前
国产信创数据库的MySQL兼容性深度对比解析
大数据·数据库·mysql·oceanbase·政务
microrain9 小时前
SagooIoT 的缓存换后端只改一行配置,文件后端连 Redis 都不需要
物联网·缓存·golang·sagooiot
姚远Oracle ACE10 小时前
为什么无法连接到 PostgreSQL?
数据库·postgresql
ss27310 小时前
AI时代首先数据库-PostgreSQL实战笔记 | 22 PostgreSQL实例与集群
数据库·笔记·postgresql
ss27310 小时前
AI时代首先数据库-PostgreSQL实战笔记 | 21 PostgreSQL进程与内存架构介绍
数据库·笔记·postgresql