【 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了(异或)

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 {@Autowiredprivate 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

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Autowiredprivate ISeckillVoucherService iSeckillVoucherService;@Autowiredprivate RedisIdWorker redisIdWorker;@Override@Transactionalpublic 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.库存减1boolean success = iSeckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).update();if (!success){return Result.fail("库存不足");}//6.创建订单VoucherOrder voucherOrder = new VoucherOrder();//优惠券idvoucherOrder.setVoucherId(voucherId);//订单idLong orderId = redisIdWorker.setId("order");voucherOrder.setId(orderId);//用户idLong userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);//存入数据库save(voucherOrder);return Result.ok(orderId);}}

@Component
public class RedisIdWorker {@Autowiredprivate 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

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

代码实现:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Autowiredprivate ISeckillVoucherService iSeckillVoucherService;@Autowiredprivate RedisIdWorker redisIdWorker;@Override@Transactionalpublic 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.库存减1boolean 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();//优惠券idvoucherOrder.setVoucherId(voucherId);//订单idLong orderId = redisIdWorker.setId("order");voucherOrder.setId(orderId);//用户idLong userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);//存入数据库save(voucherOrder);return Result.ok(orderId);}}

@Component
public class RedisIdWorker {@Autowiredprivate 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

 代码实现:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Autowiredprivate ISeckillVoucherService iSeckillVoucherService;@Autowiredprivate RedisIdWorker redisIdWorker;@Override@Transactionalpublic 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.库存减1boolean 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();//优惠券idvoucherOrder.setVoucherId(voucherId);//订单idLong orderId = redisIdWorker.setId("order");voucherOrder.setId(orderId);//用户idLong userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);//存入数据库save(voucherOrder);return Result.ok(orderId);}}

@Component
public class RedisIdWorker {@Autowiredprivate 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

 代码实现:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Autowiredprivate ISeckillVoucherService iSeckillVoucherService;@Autowiredprivate RedisIdWorker redisIdWorker;@Override@Transactionalpublic 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.库存减1boolean 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();//优惠券idvoucherOrder.setVoucherId(voucherId);//订单idLong orderId = redisIdWorker.setId("order");voucherOrder.setId(orderId);//用户idvoucherOrder.setUserId(userId);//存入数据库save(voucherOrder);return Result.ok(orderId);}}

@Component
public class RedisIdWorker {@Autowiredprivate 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

@Transactionalpublic 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.库存减1boolean 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();//优惠券idvoucherOrder.setVoucherId(voucherId);//订单idLong 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的值一样锁的定义也一样

@Transactionalpublic  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.库存减1boolean 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();//优惠券idvoucherOrder.setVoucherId(voucherId);//订单idLong orderId = redisIdWorker.setId("order");voucherOrder.setId(orderId);//用户idvoucherOrder.setUserId(userId);//存入数据库save(voucherOrder);return Result.ok(orderId);}}

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

解释:

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

==》线程1先执行

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

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

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

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

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

==》线程2释放锁

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

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

------

此时同一个用户下了俩单

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

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Autowiredprivate ISeckillVoucherService iSeckillVoucherService;@Autowiredprivate RedisIdWorker redisIdWorker;@Overridepublic 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);}}@Transactionalpublic  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.库存减1boolean 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();//优惠券idvoucherOrder.setVoucherId(voucherId);//订单idLong orderId = redisIdWorker.setId("order");voucherOrder.setId(orderId);//用户idvoucherOrder.setUserId(userId);//存入数据库save(voucherOrder);return Result.ok(orderId);}}

@Component
public class RedisIdWorker {@Autowiredprivate 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();获取当前对象的代理对象

代码实现: 

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Autowiredprivate ISeckillVoucherService iSeckillVoucherService;@Autowiredprivate RedisIdWorker redisIdWorker;@Overridepublic 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);}}@Transactionalpublic  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.库存减1boolean 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();//优惠券idvoucherOrder.setVoucherId(voucherId);//订单idLong orderId = redisIdWorker.setId("order");voucherOrder.setId(orderId);//用户idvoucherOrder.setUserId(userId);//存入数据库save(voucherOrder);return Result.ok(orderId);}}

@Component
public class RedisIdWorker {@Autowiredprivate 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都有自己的锁监视器对象,所以每个锁都可以有一个线程来获取,出现并行运行,出现安全问题

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/905294.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

如何用URDF文件构建机械手模型并与MoveIt集成

机械手URDF文件的编写 我们用urdf文件来描述我们的机械手的外观以及物理性能。这里为了简便&#xff0c;就只用了基本的圆柱、立方体了。追求美观的朋友&#xff0c;还可以用dae文件来描述机械手的外形。 import re def remove_comments(text):pattern r<!--(.*?)-->…

《构建社交应用的安全结界:双框架对接审核API的底层逻辑与实践》

用户生成内容如潮水般涌来。从日常的生活分享&#xff0c;到激烈的观点碰撞&#xff0c;这些内容赋予社交应用活力&#xff0c;也带来管理难题。虚假信息、暴力言论、侵权内容等不良信息&#xff0c;如同潜藏的暗礁&#xff0c;威胁着社交平台的健康生态。内容审核机制&#xf…

39:分类器流程

第一步 创建支持向量机分类器 create_class_svm (7, rbf, KernelParam, Nu, |ClassNames|, one-versus-one, principal_components, 5, SVMHandle) 第二步 添加样本到分类器里 for ClassNumber : 0 to |ClassNames| - 1 by 1 *列出目录下的所有文件 list_files (ReadPath…

LangChain对话链:打造智能多轮对话机器人

LangChain对话链:打造智能多轮对话机器人 目录 LangChain对话链:打造智能多轮对话机器人ConversationChain 是什么核心功能与特点基本用法示例内存机制自定义提示词应用场景与其他链的结合`SequentialChain` 是什么![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/0…

el-select 结合 el-tree:树形下拉数据

一、单选 <template><div class"selectTree-wapper"><el-selectv-model"selectValue"placeholder"请选择"popper-class"custom-el-select-class"ref"selectRef"clearableclear"clearHandle">&…

BFS算法篇——从晨曦到星辰,BFS算法在多源最短路径问题中的诗意航行(下)

文章目录 引言一、01矩阵1.1 题目链接&#xff1a;https://leetcode.cn/problems/01-matrix/description/1.2 题目分析&#xff1a;1.3 思路讲解&#xff1a;1.4 代码实现&#xff1a; 二、飞地的数量2.1 题目链接&#xff1a;https://leetcode.cn/problems/number-of-enclaves…

Leetcode (力扣)做题记录 hot100(49,136,169,20)

力扣第49题&#xff1a;字母异位词分组 49. 字母异位词分组 - 力扣&#xff08;LeetCode&#xff09; 遍历数组&#xff0c;将每一个字符串变成char数组 然后排序&#xff0c;如果map里面有则将他的值返回来&#xff08;key是排序好的字符串&#xff09; class Solution {pu…

【自学30天掌握AI开发】第1天 - 人工智能与大语言模型基础

自学30天掌握AI开发 - 第1天 &#x1f4c6; 日期和主题 日期&#xff1a;第1天 主题&#xff1a;人工智能与大语言模型基础 &#x1f3af; 学习目标 了解人工智能的发展历史和基本概念掌握大语言模型的基本原理和工作机制区分不同类型的AI模型及其特点理解AI在当前社会中的…

WebRTC 源码原生端Demo入门-1

1、概述 我的代码是比较新的&#xff0c;基于webrtc源码仓库的main分支的&#xff0c;在windows下把源码仓库下载好了后&#xff0c;用visual stdio 2022打开进行编译调试src/examples/peerconnection_client测试项目,主要是跑通这个demo来入手和调试&#xff0c;纯看代码很难…

【LeetCode】删除排序数组中的重复项 II

题目 链接 思路 双指针 我好聪明啊&#xff0c;自己想出了这个双指针的办法&#xff0c;哈哈哈哈哈哈哈&#xff0c;太高兴了 代码 class Solution(object):def removeDuplicates(self, nums):""":type nums: List[int]:rtype: int"""nlen…

通义千问席卷日本!开源界“卷王”阿里通义千问成为日本AI发展新基石

据日本经济新闻&#xff08;NIKKEI&#xff09;报道&#xff0c;通义千问已成为日本AI开发的新基础&#xff0c;其影响力正逐步扩大&#xff0c;深刻改变着日本AI产业的格局。 同时&#xff0c;日本经济新闻将通义千问Qwen2.5-Max列为全球AI模型综合评测第六名&#xff0c;不仅…

第J7周:对于ResNeXt-50算法的思考

目录 思考 一、代码功能分析 1. 构建 shortcut 分支&#xff08;残差连接的旁路&#xff09; 2. 主路径的第一层卷积&#xff08;11&#xff09; 4. 主路径的第三层卷积&#xff08;11&#xff09; 5. 残差连接 激活函数 二、问题分析总结&#xff1a;残差结构中通道数不一致的…

如何解决Jmeter中的乱码问题?

在 JMeter 中遇到乱码问题通常是由于字符编码不一致导致的&#xff0c;常见于 HTTP 请求响应、参数化文件读取、报告生成等场景。以下是系统化的解决方案&#xff1a; 1. HTTP 请求响应乱码 原因&#xff1a; 服务器返回的字符编码&#xff08;如UTF-8、GBK&#xff09;与 J…

# YOLOv2:目标检测的升级之作

YOLOv2&#xff1a;目标检测的升级之作 在目标检测领域&#xff0c;YOLO&#xff08;You Only Look Once&#xff09;系列算法以其高效的速度和创新的检测方式受到了广泛关注。今天&#xff0c;我们就来深入探讨一下 YOLOv2&#xff0c;看看它是如何在继承 YOLOv1 的基础上进行…

小白入!WiFi 技术大解析

WiFi&#xff0c;全称Wireless Fidelity&#xff0c;是一种无线局域网技术&#xff0c;允许电子设备通过无线电波连接到互联网。以下是对WiFi的一些介绍&#xff1a; 一、基本概述 定义&#xff1a;WiFi是一种基于IEEE 802.11标准系列的无线局域网技术&#xff0c;使设备能够…

【prometheus+Grafana篇】基于Prometheus+Grafana实现windows操作系统的监控与可视化

&#x1f4ab;《博主主页》&#xff1a; &#x1f50e; CSDN主页 &#x1f50e; IF Club社区主页 &#x1f525;《擅长领域》&#xff1a;擅长阿里云AnalyticDB for MySQL(分布式数据仓库)、Oracle、MySQL、Linux、prometheus监控&#xff1b;并对SQLserver、NoSQL(MongoDB)有了…

推荐一个感觉非常好的文章,是知识图谱的

为了省浏览的事儿&#xff0c;以后打算写文章都短一些&#xff0c;这样不用被强制登录、关注了 正文 链接是 https://blog.csdn.net/Appleyk/article/details/80422055 放个截图 推荐理由 两个&#xff0c;第一内容确实硬核。第二算是缘分吧&#xff0c;我之前公司好像&am…

《企业级前端部署方案:Jenkins+MinIO+SSH+Gitee+Jenkinsfile自动化实践》

文章目录 前言前端项目CICD时序图一、环境准备1、服务器相关2、Jenkins凭据3、注意事项 二、设计思想1. 模块化设计2.多环境支持3. 制品管理4. 安全部署机制5. 回滚机制 三、CI阶段1、构建节点选择2、代码拉取3、代码编译4、打包并上传至minio 四、CD阶段五、回滚阶段六、构建通…

Go语言超时控制方案全解析:基于goroutine的优雅实现

一、引言 在构建高可靠的后端服务时&#xff0c;超时控制就像是守护系统稳定性的"安全阀"&#xff0c;它确保当某些操作无法在预期时间内完成时&#xff0c;系统能够及时止损并释放资源。想象一下&#xff0c;如果没有超时控制&#xff0c;一个简单的数据库查询卡住…

WTK6900C-48L:离线语音芯片重构玩具DNA,从“按键操控”到“声控陪伴”的交互跃迁

一&#xff1a;开发背景 随着消费升级和AI技术进步&#xff0c;传统玩具的机械式互动已难以满足市场需求。语音控制芯片的引入使玩具实现了从被动玩耍到智能交互的跨越式发展。通过集成高性价比的语音识别芯片&#xff0c;现代智能玩具不仅能精准响应儿童指令&#xff0c;还能实…