【 Redis | 实战篇 秒杀实现 】

目录

前言:

1.全局ID生成器

2.秒杀优惠券

2.1.秒杀优惠券的基本实现

2.2.超卖问题

2.3.解决超卖问题的方案

2.4.基于乐观锁来解决超卖问题

3.秒杀一人一单

3.1.秒杀一人一单的基本实现

3.2.单机模式下的线程安全问题

3.3.集群模式下的线程安全问题


前言:

实现全局ID生成器,秒杀优惠券(基于乐观锁解决超卖问题),秒杀的一人一单(单机与集群线程安全问题)

1.全局ID生成器

1.1.思考:由于之前一直都在数据库中设置id为自增长字段(自增1),以订单id举例,那么会出现什么问题呢?

  • 订单id每次新增一个订单就自增1,那么这样id规律性太明显 了,用户可以直接根据它每次下的订单id来判断商家的营收情况(获取到了商家的数据)
  • 如果订单量多 ,数据库一张表已经无法满足保存这么多数据了,我们需要分表 来保存数据,但是由于我们设置的id自增(每张表都是从1开始自增 ),因此我们的订单id将会重复 ,在以后售后处理时,我们需要根据订单id来查询订单信息,而订单id有重复的,那么就不便于我们进行售后处理

1.2.订单id的特性:

  • 订单量大
  • id要唯一

1.3.全局ID生成器的要求:

  • 唯一性:保证id唯一
  • 高可用性:保证无论什么时候使用都可以生成正确的id
  • 高性能性:保证生产的id的速度足够快
  • 递增性:保证id的生产一定是整体逐渐递增的,有利于数据库创建索引增加插入速度
  • 安全性:规律性不能太明显

1.4.实现方案:

  1. UUID:生成16进制最终转换成字符串(无序并且不自增)
  2. Redis自增::第1位是符号位,始终为0;接下来的31位是时间戳,记录了ID生成的时间;最后的32位是序列号,生成64位的二进制最终形成long类型数据
  3. snowflake(雪花算法):第1位是符号位,始终为0;接下来的41位是时间戳,记录了ID生成的时间;然后的10位是工作进程ID,用于区分不同的服务器或进程;最后的12位是序列号,用于在同一毫秒内生成不同的ID,生成64位的二进制最终形成long类型数据
  4. 数据库自增:单独使用一张表来存生成的id值,其他要使用id的表就来查询即可

1.5.具体实现(Redis自增方案):

为什么可以实现:

  • 唯一:由于Redis是独立于数据库之外的(不管有几张表或者是有几个数据库),我们的Redis始终是只有一个(唯一),因此它的自增的id就永远唯一
  • 高可用:利用集群,哨兵,主从方案
  • 高性能:Redis基于内存,数据库基于硬盘,因此性能更好
  • 递增:Redis自带命令可以实现自增
  • 安全性:不会直接使用Redis的自增数值(依旧是规律性太明显),采用拼接信息实现

怎么实现:我们采用拼接信息实现,而为了增加性能,我们采用数值类型(long类型),它占用空间小,对建立索引方便

实现步骤:拼接信息,第1位是符号位,始终为0(0位正,1为负);接下来的31位是时间戳(秒数),记录了ID生成的时间;最后的32位是序列号(Redis自增数),生成64位的二进制最终形成long类型数据

解释:

时间戳(秒数):利用当前时间减去你自己设置的开始时间最后得到的时间秒数


思考:那为什么不直接使用当前时间的秒数呢

解释:还是由于使用当前时间秒数容易被猜到规律,规律性明显
序列号:Redis自增数


实现:Redis自增数使用String类型中的命令increment(每次自增1),并且由于该命令是如果Redis中没有key就会帮你自动创建key然后自增(此时值为1),存在key那么就直接将key中的value自增1,最终返回value值


细节:由于使用的Redis的命令那么最终序列号作为value将存入Redis,那么存入Redis的自增数不就是我们的订单数吗?那以后我们需要统计订单数是不是直接查询Redis就行,而为了方便查询,我们的key是不是需要设置一个有意义的(通过key)


key的设置:自己设置前缀(以后生成id的不只是订单id,因此我们需要自己指定对应前缀来区分),然后用前缀拼接时间(具体到天),最终形成一个key


思考:加前缀我能理解,为了区分存入Redis的key,那为什么还要拼接时间呢?

解释:如果你的序列号都使用同一个key,Redis存入是由上限的,而且为了你以后方便查询,key拼接时间(具体到天),那么我们可以统计每一天的下单量

实现细节:

思考:我们最终得到了时间戳(秒)long类型,序列号(订单数)long类型,我们需要拼接形成一个全新的long,符号位不需要管(正数0,负数1)

步骤:将时间戳向左移32位 (留给序列号的),由于向左移位时以0来填充 ,那么再将移位后的时间戳异或上序列号即可(只有有一个为真那就是真,有1就是1),第一位符号位不需要管,时间戳是正数(id一般也会设置为正数),最终形成一个新的long类型的id

解释:我们这里是进行的二进制计算,而二进制只有0/1,那么有值就为1,没有值就为0了(异或)

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

@Component
public class RedisIdWorker {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //定义开始时间戳
    private static final Long BEGIN_TIME_SECOND = 1740960000L;
    //移动位数
    private static final Long COUNT_BIT = 32L;

    public Long setId(String keyPrefix){
        //1.设置时间戳
        //当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //最终时间戳
        Long time = second - BEGIN_TIME_SECOND;

        //2.获取序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //Redis返回的序列号
        long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);

        //拼接
        return time << COUNT_BIT | increment;
    }
}

Redis图效果:

2.秒杀优惠券

2.1.秒杀优惠券的基本实现

思考:在下单优惠券之前,我们需要判断两点

  • 秒杀是否开始或结束
  • 库存是否充足

步骤:

前端提交优惠券id

==》后端接收id

==》根据优惠券id查询数据库,得到优惠券信息

==》判断秒杀是否开始或结束

==》秒杀没有开始或已经结束

==》返回错误信息


==》秒杀正在进行

==》判断库存是否充足

==》不足

==》返回错误信息


==》充足

==》扣减库存

==》创建订单

==》返回订单id

java 复制代码
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private ISeckillVoucherService iSeckillVoucherService;
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.根据id查询数据库优惠券信息
        SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);

        //2.获取时间
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();

        //3.判断时间
        if (beginTime.isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀还未开始");
        }
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }

        //4.获取库存
        Integer stock = voucher.getStock();
        //库存不足
        if(stock < 1){
            return Result.fail("库存不足");
        }

        //库存足
        //5.库存减1
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        //订单id
        Long orderId = redisIdWorker.setId("order");
        voucherOrder.setId(orderId);
        //用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //存入数据库
        save(voucherOrder);
        return Result.ok(orderId);
    }

}
java 复制代码
@Component
public class RedisIdWorker {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //定义开始时间戳
    private static final Long BEGIN_TIME_SECOND = 1740960000L;
    //移动位数
    private static final Long COUNT_BIT = 32L;

    public Long setId(String keyPrefix){
        //1.设置时间戳
        //当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //最终时间戳
        Long time = second - BEGIN_TIME_SECOND;

        //2.获取序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //Redis返回的序列号
        long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);

        //拼接
        return time << COUNT_BIT | increment;
    }
}

解释:我们唯一要注意的点就是秒杀的时间和库存的数量(判断)

2.2.超卖问题

解释:

  • 前提:库存此时为1

例子:线程1先执行查询库存,线程2再执行查询,线程1扣减库存,线程2扣减库存

==》线程1先执行

==》线程1查询库存(1)

==》线程2抢到执行权

==》线程2查询库存(1)

==》线程1再次抢到执行权

==》由于库存大于0

==》线程1执行库存扣减操作

==》此时库存(0)

==》线程2执行

==》由于之前查询库存结果为1

==》线程2也执行库存扣减操作

==》此时库存(-1)


那么此时优惠券库存为-1,已经形成了超卖问题

2.3.解决超卖问题的方案

解决方案:

方案一:悲观锁

悲观锁:认为线程安全问题一定会发生,因此在每次操作数据之前先获取锁,以此确保线程安全,保证线程串行执行

  • Synchronized,Lock都属于悲观锁
  • 优点:简单粗暴
  • 缺点:性能一般

方案二:乐观锁

乐观锁:认为线程安全问题不一定发生,因此不加锁,只是在更新数据时去判断有没有其他线程来对数据进行了修改

  • 如果没有修改则认为是安全的,自己才更新数据
  • 如果已经被其他线程修改说明发生了安全问题,此时可以重试或返回异常
  • 优点:性能好
  • 缺点:存在安全率低的问题

解释:悲观锁就是直接加锁,由于是加锁其他线程都需要等待因此性能低,乐观锁是不加锁,由于不加锁那么就会出现安全问题(概率低)

思考:

  1. 由于我们是优惠券库存问题(有数据给我们判断,这个数据到底有没有修改过),我们可以直接根据库存 来判断是否出现数据不一致问题,那么就可以采用乐观锁
  2. 如果不是库存呢,那么只能通过数据的整体变化来判断,此时采用乐观锁是复杂的,你需要判断的数据太多了,那么就采用悲观锁
  3. 但是悲观锁的性能一般,怎么提高性能呢:采用分批加锁(分段锁),将数据分成几份(假设分成10张表),那么用户是不是同时去这10张表抢,同时10个人抢(效率提高),最终思想:每次锁定的资源少

总结:如果要更新数据那么可以使用乐观锁,添加数据使用悲观锁

2.4.基于乐观锁来解决超卖问题

**版本号法:**设置版本号,每次查询库存时也查询版本号,最后扣减库存时增加判断条件(就是此时的版本号应该等于我先前查询到的版本号),如果不等于事务回滚

思想:更新数据前比较版本号是否发生改变

步骤:

前端提交优惠券id

==》后端接收id

==》根据优惠券id查询数据库,得到优惠券信息,获取版本号

==》判断秒杀是否开始或结束

==》秒杀没有开始或已经结束

==》返回错误信息


==》秒杀正在进行

==》判断库存是否充足

==》不足

==》返回错误信息


==》充足

==》判断版本号是否发生改变

==》改变返回错误信息


==》版本号相同

==》扣减库存

==》创建订单

==》返回订单id

CAS法:直接比较库存,在更新数据时增加判断条件(库存是否发生改变),库存改变不执行更新操作事务回滚

思想:直接利用已有数据来进行判断,根据数据是否发生变化来确定是否更新数据

步骤:

前端提交优惠券id

==》后端接收id

==》根据优惠券id查询数据库,得到优惠券信息

==》判断秒杀是否开始或结束

==》秒杀没有开始或已经结束

==》返回错误信息


==》秒杀正在进行

==》判断库存是否充足

==》不足

==》返回错误信息


==》充足

==》判断库存是否发生变化

==》改变返回错误信息


==》库存相同

==》扣减库存

==》创建订单

==》返回订单id

思考:由于我们是优惠券库存问题,那么我们可以直接使用库存来直接判断,只有库存发生变化,那我们就不进行更新操作

代码实现:

java 复制代码
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private ISeckillVoucherService iSeckillVoucherService;
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.根据id查询数据库优惠券信息
        SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);

        //2.获取时间
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();

        //3.判断时间
        if (beginTime.isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀还未开始");
        }
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }

        //4.获取库存
        Integer stock = voucher.getStock();
        //库存不足
        if(stock < 1){
            return Result.fail("库存不足");
        }

        //库存足
        //5.库存减1
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId).eq("stock",stock)//乐观锁
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        //订单id
        Long orderId = redisIdWorker.setId("order");
        voucherOrder.setId(orderId);
        //用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //存入数据库
        save(voucherOrder);
        return Result.ok(orderId);
    }

}
java 复制代码
@Component
public class RedisIdWorker {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //定义开始时间戳
    private static final Long BEGIN_TIME_SECOND = 1740960000L;
    //移动位数
    private static final Long COUNT_BIT = 32L;

    public Long setId(String keyPrefix){
        //1.设置时间戳
        //当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //最终时间戳
        Long time = second - BEGIN_TIME_SECOND;

        //2.获取序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //Redis返回的序列号
        long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);

        //拼接
        return time << COUNT_BIT | increment;
    }
}

弊端:如果同时大量用户抢优惠券,而此时库存还有100张,用户们都先进行了查询库存的操作,但都没有进行库存扣减操作,等到第一个先抢到优惠券后,库存改变,那么其他用户全部会抢券失败

前提:优惠券库存100

例子:100个线程都先进行了查询库存操作,都还没有执行到判断库存是否发生改变

==》线程1-100查询库存(100)

==》线程1优先于其他线程先执行完判断库存操作(100)

==》线程1扣减库存(99)

==》不管之后是哪个线程来执行判断库存操作

==》库存已经发生变化,抢券失败


那么此时100个用户抢券,只抢券成功一人,但是我的优惠券库存却还有99张,失败率极高

怎么提高用户抢券的成功率呢

思考: 由于库存不能是负数 ,那么我们最后判断的条件不再是库存是否改变,而是库存大于0 就行,只要有库存那么我就卖给用户,即使出现大量用户同时进行抢券的情况,我们也可以将券买给用户(而不是只能卖给第一个用户),并且当库存只有一张时 ,由于我们是更新操作,数据库只允许一个线程来执行更新操作,不允许多个线程同时执行更新库存操作(最后一张券被大量用户抢时,总会有一个用户抢到,其他用户则抢不到)

步骤:

前端提交优惠券id

==》后端接收id

==》根据优惠券id查询数据库,得到优惠券信息

==》判断秒杀是否开始或结束

==》秒杀没有开始或已经结束

==》返回错误信息


==》秒杀正在进行

==》判断库存是否充足

==》不足

==》返回错误信息


==》充足

==》再次判断库存是否大于0

==》库存不足返回错误信息


==》库存足

==》扣减库存

==》创建订单

==》返回订单id

代码实现:

java 复制代码
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private ISeckillVoucherService iSeckillVoucherService;
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.根据id查询数据库优惠券信息
        SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);

        //2.获取时间
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();

        //3.判断时间
        if (beginTime.isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀还未开始");
        }
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }

        //4.获取库存
        Integer stock = voucher.getStock();
        //库存不足
        if(stock < 1){
            return Result.fail("库存不足");
        }

        //库存足
        //5.库存减1
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)..gt("stock",0)//乐观锁
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        //订单id
        Long orderId = redisIdWorker.setId("order");
        voucherOrder.setId(orderId);
        //用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //存入数据库
        save(voucherOrder);
        return Result.ok(orderId);
    }

}
java 复制代码
@Component
public class RedisIdWorker {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //定义开始时间戳
    private static final Long BEGIN_TIME_SECOND = 1740960000L;
    //移动位数
    private static final Long COUNT_BIT = 32L;

    public Long setId(String keyPrefix){
        //1.设置时间戳
        //当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //最终时间戳
        Long time = second - BEGIN_TIME_SECOND;

        //2.获取序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //Redis返回的序列号
        long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);

        //拼接
        return time << COUNT_BIT | increment;
    }
}

3.秒杀一人一单

3.1.秒杀一人一单的基本实现

**思考:**由于是秒杀问题,因此不能让用户一个人全部买走(这不就是黄牛吗),那么我们可以实现一个用户只能下一单

步骤:

前端提交优惠券id

==》后端接收id

==》根据优惠券id查询数据库,得到优惠券信息

==》判断秒杀是否开始或结束

==》秒杀没有开始或已经结束

==》返回错误信息


==》秒杀正在进行

==》判断库存是否充足

==》不足

==》返回错误信息


==》充足

==》根据优惠券id和用户id来查询数据库,返回查询数量

==》判断数量是否大于0

==》大于0,即用户已经下过一单(每张优惠券id不同)

==》返回错误信息


==》数量小于0,即用户没有下单

==》再次判断库存是否大于0

==》库存不足返回错误信息


==》库存足

==》扣减库存

==》创建订单

==》返回订单id

代码实现:

java 复制代码
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private ISeckillVoucherService iSeckillVoucherService;
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.根据id查询数据库优惠券信息
        SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);

        //2.获取时间
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();

        //3.判断时间
        if (beginTime.isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀还未开始");
        }
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }

        //4.获取库存
        Integer stock = voucher.getStock();
        //库存不足
        if(stock < 1){
            return Result.fail("库存不足");
        }
        //根据用户id和优惠券id查询数据库
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if(count > 0){
            //该用户已经下过单了
            return Result.fail("一个用户只能下一单");
        }

        //库存足
        //5.库存减1
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)..gt("stock",0)//乐观锁
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        //订单id
        Long orderId = redisIdWorker.setId("order");
        voucherOrder.setId(orderId);
        //用户id
        voucherOrder.setUserId(userId);
        //存入数据库
        save(voucherOrder);
        return Result.ok(orderId);
    }

}
java 复制代码
@Component
public class RedisIdWorker {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //定义开始时间戳
    private static final Long BEGIN_TIME_SECOND = 1740960000L;
    //移动位数
    private static final Long COUNT_BIT = 32L;

    public Long setId(String keyPrefix){
        //1.设置时间戳
        //当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //最终时间戳
        Long time = second - BEGIN_TIME_SECOND;

        //2.获取序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //Redis返回的序列号
        long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);

        //拼接
        return time << COUNT_BIT | increment;
    }
}

3.2.单机模式下的线程安全问题

解释:

前提:库存充足,并且是同一个用户下单,此时该用户还没有下单(订单数量0)


例子:一个用户同时发出俩个请求(买相同的优惠券),线程1先查询,线程2后查询,线程1判断用户是否下过单,线程2判断用户是否下过单

==》线程1先执行

==》线程1查询订单数量(0)

==》线程1判断订单数量

==》订单数量为0,可以下单

==》线程2抢到执行权

==》线程2执行查询订单数量(0)

==》订单数量为0,也可以下单

==》线程1抢到执行权

==》由于订单数量为0,线程1执行下单操作

==》线程2执行

==》由于订单数量为0,线程2执行下单操作


那么最终一个用户下了两单,出现了并发安全问题

思考: 这是不是还是超卖问题,那么还是使用锁来解决,而我们现在是执行创建订单 的操作,乐观锁是需要根据数据的变化来实现的,因此不能使用乐观锁(修改用乐观,添加用悲观)

思路: 既然使用悲观锁 ,那么我们需要考虑在哪里加锁合适,是整个方法都加上锁吗? 不是吧,我们最终问题出现在哪,是并发查询订单数量那里而之前的查询库存操作(等等)是不需要加锁的(加锁是会导致我们的性能降低,因此我们需要考虑加锁的合适位置)既然是对于方法内部部分代码进行加锁,那么我们可以将要加锁的代码抽离出来,对于这个新方法进行加锁,而我们这里使用synchronized

java 复制代码
@Transactional
    public synchronized Result creatOrder(Long voucherId) {
        //根据用户id和优惠券id查询数据库
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if(count > 0){
            //该用户已经下过单了
            return Result.fail("一个用户只能下一单");
        }
        //库存足
        //5.库存减1
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId).gt("stock",0)//乐观锁
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        //订单id
        Long orderId = redisIdWorker.setId("order");
        voucherOrder.setId(orderId);
        //用户id
//        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //存入数据库
        save(voucherOrder);
        return Result.ok(orderId);
    }

细节: 我们是直接将锁synchronized加在整个新方法上吗 ?(返回值类型之前),不是吧,这样我们锁住的是整个方法 (synchronized锁对象是该类的实例),那么不同用户都使用同一把锁(串行执行,效率极低),注意:需要加上事务注解

思考: 为了将效率提高 ,那么我们需要将锁的范围缩小一个用户一把锁(不同的用户不同的锁),不建议将synchronized直接加在方法上

实现: 那么我们可以将方法内的代码抽离出来形成代码块,然后对代码块加锁synchronized,而为了保证一个用户一把锁,那么我们对于synchronized的定义该怎么办

一个用户一把锁的问题我们之前不是取出来了用户的id吗,直接用id来定义synchronized,不对,如果直接用用户id这个变量来定义锁,那么相同用户发出多次请求,请求的锁不同(每次用户id的创建地址不同),那我们直接用用户id里面的id值就行(id.toString()),同样不对,toString()方法的底层依旧是new一个新的String类型,那么还是地址不同,锁不同

问题解决:使用id.toString().intern(),intern()方法的原理是虽然你toString()方法会new一个新的String对象,但是我会先去字符串池里找,找不到对应的值我才会new,找到了我直接复用该String地址,从而保证了用户id的值一样锁的定义也一样

java 复制代码
@Transactional
    public  Result creatOrder(Long voucherId) {
        //根据用户id和优惠券id查询数据库
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()){
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if(count > 0){
                //该用户已经下过单了
                return Result.fail("一个用户只能下一单");
            }
            //库存足
            //5.库存减1
            boolean success = iSeckillVoucherService.update()
                    .setSql("stock = stock -1")
                    .eq("voucher_id", voucherId).gt("stock",0)//乐观锁
                    .update();
            if (!success){
                return Result.fail("库存不足");
            }
            //6.创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            //优惠券id
            voucherOrder.setVoucherId(voucherId);
            //订单id
            Long orderId = redisIdWorker.setId("order");
            voucherOrder.setId(orderId);
            //用户id
            voucherOrder.setUserId(userId);
            //存入数据库
            save(voucherOrder);
            return Result.ok(orderId);
        }
        }

代码块锁事务管理问题: 由于此时锁是加在方法内部 的,而我们的事务管理是由Spring来管理,要等到锁释放后,方法执行完,才能进行事务提交(更新库存,创建订单),而此时锁优先于事务提交之前就已经释放了,那么其他的线程就可以进行操作,依然会出现并发问题

解释:

前提:同一个用户发出两个请求,并且此时用户没有下单(订单数0)

==》线程1先执行

==》线程1查询订单数量(0)

==》线程1获取锁成功,执行锁内代码

==》线程1释放锁,但是事务还未提交

==》线程2查询订单数量(0)

==》线程2获取锁成功,执行锁内代码

==》线程2释放锁

==》线程1事务提交成功(订单加1)

==》线程2事务提交成功(订单加1)


此时同一个用户下了俩单

解决: 既然是锁和事务执行顺序问题,那么我们先让事务先执行,锁后释放,而由于我们已经将要加锁的代码抽离出来形成一个新的方法,那么我们可以在调用该方法时给它加锁,从而锁住整个函数,保证数据已经更新

java 复制代码
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private ISeckillVoucherService iSeckillVoucherService;
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Override
    public Result seckillVoucher(Long voucherId) {
        //1.根据id查询数据库优惠券信息
        SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);

        //2.获取时间
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();
        //3.判断时间
        if (beginTime.isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀还未开始");
        }
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        //4.获取库存
        Integer stock = voucher.getStock();
        //库存不足
        if(stock < 1){
            return Result.fail("库存不足");
        }
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()){
            return creatOrder(voucherId);
        }

    }

    @Transactional
    public  Result creatOrder(Long voucherId) {
        //根据用户id和优惠券id查询数据库
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if(count > 0){
            //该用户已经下过单了
            return Result.fail("一个用户只能下一单");
        }
        //库存足
        //5.库存减1
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId).gt("stock",0)//乐观锁
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        //订单id
        Long orderId = redisIdWorker.setId("order");
        voucherOrder.setId(orderId);
        //用户id
        voucherOrder.setUserId(userId);
        //存入数据库
        save(voucherOrder);
        return Result.ok(orderId);
    }

}
java 复制代码
@Component
public class RedisIdWorker {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //定义开始时间戳
    private static final Long BEGIN_TIME_SECOND = 1740960000L;
    //移动位数
    private static final Long COUNT_BIT = 32L;

    public Long setId(String keyPrefix){
        //1.设置时间戳
        //当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //最终时间戳
        Long time = second - BEGIN_TIME_SECOND;

        //2.获取序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //Redis返回的序列号
        long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);

        //拼接
        return time << COUNT_BIT | increment;
    }
}

思考: 由于我们使用的是方法调用方法 (锁的),而在相同类里方法调用方法使用的是this关键字,this代表当前类的对象(不是Spring的代理对象),而我们的事务生效是因为Spring对当前类实现了动态代理,是拿到了它的动态代理对象进行的事务管理,而现在的this调用是非代理对象不拥有事务功能(Spring事务失效的可能性之一),因此事务管理将会失效

解决:既然是没有代理对象来调用方法,那么我们就使用代理对象来调用方法

实现:

  • 添加依赖
  • 启动类添加注解(暴露代理对象)
  • 使用AopContet.currentProxy();获取当前对象的代理对象

代码实现:

java 复制代码
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private ISeckillVoucherService iSeckillVoucherService;
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Override
    public Result seckillVoucher(Long voucherId) {
        //1.根据id查询数据库优惠券信息
        SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);

        //2.获取时间
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();
        //3.判断时间
        if (beginTime.isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀还未开始");
        }
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        //4.获取库存
        Integer stock = voucher.getStock();
        //库存不足
        if(stock < 1){
            return Result.fail("库存不足");
        }
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()){
            //获取代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.creatOrder(voucherId);
        }

    }

    @Transactional
    public  Result creatOrder(Long voucherId) {
        //根据用户id和优惠券id查询数据库
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if(count > 0){
            //该用户已经下过单了
            return Result.fail("一个用户只能下一单");
        }
        //库存足
        //5.库存减1
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId).gt("stock",0)//乐观锁
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        //订单id
        Long orderId = redisIdWorker.setId("order");
        voucherOrder.setId(orderId);
        //用户id
        voucherOrder.setUserId(userId);
        //存入数据库
        save(voucherOrder);
        return Result.ok(orderId);
    }

}
java 复制代码
@Component
public class RedisIdWorker {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //定义开始时间戳
    private static final Long BEGIN_TIME_SECOND = 1740960000L;
    //移动位数
    private static final Long COUNT_BIT = 32L;

    public Long setId(String keyPrefix){
        //1.设置时间戳
        //当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //最终时间戳
        Long time = second - BEGIN_TIME_SECOND;

        //2.获取序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //Redis返回的序列号
        long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);

        //拼接
        return time << COUNT_BIT | increment;
    }
}

3.3.集群模式下的线程安全问题

原因:在集群的情况下,同一个用户的多次请求如果请求到不同的Tomcat,那么锁也会不同,依然会出现超卖问题

思考:在集群情况下有多台Tomcat那么就会有多台jvm,而不同的jvm的锁(维护了一个锁的监视器对象)是不同的

解释: 由于我们的锁是基于用户id来实现的,id记录在常量池中,id相同则代表是同一个锁(同一个监视器),就是监视器里有值了(值就是id),无论有多少个线程,只要第一个线程获取到锁(该用户id值被记录在监视器中),其他线程来获取锁,而锁发现监视器已经有值了,那么线程会获取锁失败,所以我们是基于看监视器对象是否记录值,而不同的Tomcat的监视器对象并不共享,因此同一个用户可以在多个Tomcat中形成多个锁

当我们集群时

==》有一个新的部署

==》就会有一个新的Tomcat

==》就会有一个新的jvm

==》就会有一个新的监视器对象(不同的jvm有不同的监视器)

==》因此当id相同时,Tomcat不同时,可以重复获取锁

==》假设有2个jvm

==》2个监视器

==》2个相同的id锁


那么还是会出现线程安全问题,依旧是一个用户可以根据Tomcat的多少来下多少单

总结:在集群/分布式系统的情况下会有多个jvm存在,由于我们使用的是jvm自带的锁synchronized,而每个jvm都有自己的锁监视器对象,所以每个锁都可以有一个线程来获取,出现并行运行,出现安全问题

相关推荐
科技小花3 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸3 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain3 小时前
linux个人心得22 (mysql)
数据库·mysql
阿里小阿希4 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神4 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员4 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java4 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿4 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb
不知名的老吴4 小时前
Redis的延迟瓶颈:TCP栈开销无法避免
数据库·redis·缓存
YOU OU4 小时前
三大范式和E-R图
数据库