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

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

情景介绍:

       超卖问题在我们业务中很常见,当高并发访问数据库时,可能就会出现该问题,例如有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实现最终版本的加锁和释放锁的逻辑
相关文章
|
9月前
|
缓存 NoSQL 算法
高并发秒杀系统实战(Redis+Lua分布式锁防超卖与库存扣减优化)
秒杀系统面临瞬时高并发、资源竞争和数据一致性挑战。传统方案如数据库锁或应用层锁存在性能瓶颈或分布式问题,而基于Redis的分布式锁与Lua脚本原子操作成为高效解决方案。通过Redis的`SETNX`实现分布式锁,结合Lua脚本完成库存扣减,确保操作原子性并大幅提升性能(QPS从120提升至8,200)。此外,分段库存策略、多级限流及服务降级机制进一步优化系统稳定性。最佳实践包括分层防控、黄金扣减法则与容灾设计,强调根据业务特性灵活组合技术手段以应对高并发场景。
2596 7
|
10月前
|
NoSQL 算法 安全
redis分布式锁在高并发场景下的方案设计与性能提升
本文探讨了Redis分布式锁在主从架构下失效的问题及其解决方案。首先通过CAP理论分析,Redis遵循AP原则,导致锁可能失效。针对此问题,提出两种解决方案:Zookeeper分布式锁(追求CP一致性)和Redlock算法(基于多个Redis实例提升可靠性)。文章还讨论了可能遇到的“坑”,如加从节点引发超卖问题、建议Redis节点数为奇数以及持久化策略对锁的影响。最后,从性能优化角度出发,介绍了减少锁粒度和分段锁的策略,并结合实际场景(如下单重复提交、支付与取消订单冲突)展示了分布式锁的应用方法。
829 3
|
缓存 NoSQL 搜索推荐
【📕分布式锁通关指南 03】通过Lua脚本保证redis操作的原子性
本文介绍了如何通过Lua脚本在Redis中实现分布式锁的原子性操作,避免并发问题。首先讲解了Lua脚本的基本概念及其在Redis中的使用方法,包括通过`eval`指令执行Lua脚本和通过`script load`指令缓存脚本。接着详细展示了如何用Lua脚本实现加锁、解锁及可重入锁的功能,确保同一线程可以多次获取锁而不发生死锁。最后,通过代码示例演示了如何在实际业务中调用这些Lua脚本,确保锁操作的原子性和安全性。
713 6
【📕分布式锁通关指南 03】通过Lua脚本保证redis操作的原子性
|
缓存 NoSQL 架构师
Redis批量查询的四种技巧,应对高并发场景的利器!
在高并发场景下,巧妙地利用缓存批量查询技巧能够显著提高系统性能。 在笔者看来,熟练掌握细粒度的缓存使用是每位架构师必备的技能。因此,在本文中,我们将深入探讨 Redis 中批量查询的一些技巧,希望能够给你带来一些启发。
Redis批量查询的四种技巧,应对高并发场景的利器!
|
缓存 NoSQL 测试技术
Redis压测脚本及持久化机制
Redis压测脚本及持久化机制简介: Redis性能压测通过`redis-benchmark`工具进行,可评估读写性能。持久化机制包括无持久化、RDB(定期快照)和AOF(操作日志),以及两者的结合。RDB适合快速备份与恢复,但可能丢失数据;AOF更安全,记录每次写操作,适合高数据安全性需求。两者结合能兼顾性能与安全性,建议同时开启并定期备份RDB文件以确保数据安全。
241 9
|
NoSQL Redis 数据库
Redis 功能扩展 Lua 脚本 对Redis扩展 eval redis.call redis.pcall
通过本文的介绍,我们详细讲解了 Lua 脚本在 Redis 中的作用、`eval` 命令的使用方法以及 `redis.call` 和 `redis.pcall` 的区别和用法。通过合理使用 Lua 脚本,可以实现复杂的业务逻辑,确保操作的原子性,并减少网络开销,从而提高系统的性能和可靠性。
875 13
|
存储 缓存 NoSQL
云端问道21期方案教学-应对高并发,利用云数据库 Tair(兼容 Redis®*)缓存实现极速响应
云端问道21期方案教学-应对高并发,利用云数据库 Tair(兼容 Redis®*)缓存实现极速响应
398 1
|
缓存 NoSQL 关系型数据库
云端问道21期实操教学-应对高并发,利用云数据库 Tair(兼容 Redis®)缓存实现极速响应
本文介绍了如何通过云端问道21期实操教学,利用云数据库 Tair(兼容 Redis®)缓存实现高并发场景下的极速响应。主要内容分为四部分:方案概览、部署准备、一键部署和完成及清理。方案概览中,展示了如何使用 Redis 提升业务性能,降低响应时间;部署准备介绍了账号注册与充值步骤;一键部署详细讲解了创建 ECS、RDS 和 Redis 实例的过程;最后,通过对比测试验证了 Redis 缓存的有效性,并指导用户清理资源以避免额外费用。
315 1
|
监控 安全
公司用什么软件监控电脑:Lua 脚本在监控软件扩展功能的应用
在企业环境中,电脑监控软件对保障信息安全、提升效率至关重要。Lua 脚本在此类软件中用于扩展功能,如收集系统信息、监控软件使用时长及文件操作,向指定服务器发送数据,支持企业管理和运营。
240 6
|
10月前
|
缓存 NoSQL 关系型数据库
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?