Redis秒杀
1.全局唯一ID
特性:
-
高可用
-
唯一性
-
高性能
-
安全性
-
递增性
全局唯一ID生成有很多方法例如:UUID、redis自增、snowflake算法、数据库自增等。
此我们失语redis自增的方式。
生成策略
具体代码实现
package com.hmdp.utils;
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;
/**
* 全局唯一id生成器,利用Redis的自增功能保证每个ID的唯一性。
*/
@Component
public class RedisWorker {
/**
* 系统开始时间戳,用于计算时间差
*/
private static final long BEGIN_TIMESTAMP = 1640995200L; // 2022-01-01 00:00:00 的UTC时间戳
/**
* 序列号占用的位数
*/
private static final long COUNT_BITS = 32; // 序列号占用32位
private StringRedisTemplate stringRedisTemplate;
/**
* 构造函数,注入StringRedisTemplate
* @param stringRedisTemplate 用于操作Redis的模板类
*/
public RedisWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 生成下一个唯一ID
* @param keyPrefix ID前缀,用于区分不同业务或类别的ID
* @return 生成的唯一ID
*/
public long nextId(String keyPrefix) {
// 1. 计算当前时间戳(秒)
long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
long time = nowSecond - BEGIN_TIMESTAMP; // 时间差,用于占据高位
// 2. 获取当天序列号,基于Redis自增实现
// 根据日期生成Redis键,保证每天序列号从1开始递增
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long increment = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3. 将时间差和序列号拼接成最终的ID
return time << COUNT_BITS | increment; // 左移位数后与序列号进行位或操作
}
}
2.库存超卖
在高并发下,去抢购商品优惠卷时会出现超卖,我们该如何避免呢?
使用锁
我们可以使用锁的机制去实现,说到锁我们脑海里通常会出现悲观锁和乐观锁,那么这两个分别是什么呢?
-
悲观锁:悲观锁是一种在并发控制中采用的策略,它假定多线程访问数据时发生冲突的可能性非常高,因此采取一种保守的机制来防止冲突。当一个事务使用悲观锁访问某个数据时,会先锁定这些数据,确保在整个事务处理过程中,没有任何其他事务能够修改这些数据。这种做法可以有效避免并发带来的数据不一致性问题,但可能会增加系统的锁开销,并可能引起其他的性能问题,如阻塞、死锁等。
-
乐观锁:一种在并发控制中采用的策略,与悲观锁相反,它假设多线程访问数据时发生冲突的可能性较低。在读取数据时,乐观锁不会立即锁定数据,而是先进行读取操作,然后在更新数据时检查在此期间是否有其他事务修改了数据。如果检测到有其他事务进行了修改,那么更新操作就会失败,否则更新成功。 乐观锁通常通过版本号(Version)或时间戳(Timestamp)来实现。在读取数据时,会记录当前的版本号或时间戳。当事务尝试更新数据时,会再次检查版本号或时间戳是否与之前读取时的一致。如果不一致,说明数据已被其他事务修改,更新操作就会失败;如果一致,说明没有发生冲突,更新操作可以继续。 相比于悲观锁,乐观锁的优点在于减少了锁的使用,从而降低了锁带来的开销和潜在的阻塞问题,提高了并发性能。然而,在高并发且冲突频繁的情况下,乐观锁可能导致大量的重试,这可能增加系统的计算负担。
案例
具体业务代码:
@Override
@Transactional(rollbackFor = Exception.class)
public Result seckillVoucher(Long voucherId) {
// 1.获取优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherMapper.selectById(voucherId);
// 2.判断秒杀是否开启
// 2.1获取当前时间
LocalDateTime now = LocalDateTime.now();
if (seckillVoucher.getBeginTime().isAfter(now) ) {
return Result.fail("秒杀未开启");
}
// 3.判断秒杀是否结束
if (seckillVoucher.getEndTime().isBefore(now)){
return Result.fail("秒杀已结束");
}
// 4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5.扣减库存
boolean res = seckillVoucherMapper.updateStock(voucherId);
if (!res){
return Result.fail("库存不足");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
long orderId = RedisWorker.nextId("order");
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}
当在多并发的情况下会出现以下情况:
线程一进入该业务逻辑此时获取到库存为1,查询到的同时线程二也开始进入进行查询获取到库存也为1。接下来线程一再执行扣减库库存后库存为0了,返回到线程二也会执行减库库存那么库存就会出现超卖。
此处使用乐观锁解决
乐观锁分为:版本号法实现与CAS法实现
版本号法:在数据库表添加一个version字段,当线程一执行时同时查出版本号(1)与库存(1),线程二也查询版本号(1)与库存(1),线程一开始执行修改操作(库存减一,版本加一),此时库存为0,版本号为2,那么在线程二执行库存扣减时发现库存版本号不对则不会执行。
update tb_seckill_voucher set stock = stock - 1,version = version+1 where voucher_id = #{voucherId} and version=#{version};
CAS法:这种方式和版本号法非常相似,在库存的本身字段进行操作,当线程一进行扣减后库存为0,线程二在执行扣减时传入的库存值为1,会发现不匹配则不执行。
update tb_seckill_voucher set stock = stock - 1 where voucher_id = #{voucherId} and stock=#{stock};
但是上面的成功概率极低,当多个线程都查询到1时就会出现大量失败。那么只要库存大于0就让该线程执行即可。
-- seckillVoucherMapper.updateStock(voucherId)的sql
updete tb_seckill_voucher set stock = stock - 1 where voucher_id = #{voucherId} and stock > 0;
3.一人一单
一人一单逻辑很简单,无非就是在执行扣减订单前添加判断该用户是否已经下过单。
3.1单服务适用
@Override
public Result seckillVoucher(Long voucherId) {
// 1.获取优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherMapper.selectById(voucherId);
// 2.判断秒杀是否开启
// 2.1获取当前时间
LocalDateTime now = LocalDateTime.now();
if (seckillVoucher.getBeginTime().isAfter(now)) {
return Result.fail("秒杀未开启");
}
// 3.判断秒杀是否结束
if (seckillVoucher.getEndTime().isBefore(now)) {
return Result.fail("秒杀已结束");
}
// 4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5.判断用于是否已经领取
Long userId = UserHolder.getUser().getId();
if (query().eq("user_id", userId).eq("voucher_id", voucherId).count() > 0) {
return Result.fail("用户已经领取过");
}
// 6.扣减库存
boolean res = seckillVoucherMapper.updateStock(voucherId);
if (!res) {
return Result.fail("库存不足");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
long orderId = RedisWorker.nextId("order");
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}
但是在高并发情况下也会出现同样的问题,大量线程都库存count为0,未命中判断直接执行扣减了。我们该如何解决该问题呢?
此处使用悲观锁的方式
@Override
public Result seckillVoucher(Long voucherId) {
// 1.获取优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherMapper.selectById(voucherId);
// 2.判断秒杀是否开启
// 2.1获取当前时间
LocalDateTime now = LocalDateTime.now();
if (seckillVoucher.getBeginTime().isAfter(now)) {
return Result.fail("秒杀未开启");
}
// 3.判断秒杀是否结束
if (seckillVoucher.getEndTime().isBefore(now)) {
return Result.fail("秒杀已结束");
}
// 4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
return createVoucherOrder(voucherId);
}
@Transactional(rollbackFor = Exception.class)
public synchronized Result createVoucherOrder(Long voucherId) {
// 5.判断用于是否已经领取
Long userId = UserHolder.getUser().getId();
if (query().eq("user_id", userId).eq("voucher_id", voucherId).count() > 0) {
return Result.fail("用户已经领取过");
}
// 6.扣减库存
boolean res = seckillVoucherMapper.updateStock(voucherId);
if (!res) {
return Result.fail("库存不足");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
long orderId = RedisWorker.nextId("order");
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}
此处我们可进行优化,因为synchronized锁定的是整个法大大降低了整个的执行效率,那么我们可以使用userid做为条件。
@Override
public Result seckillVoucher(Long voucherId) {
// 1.获取优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherMapper.selectById(voucherId);
// 2.判断秒杀是否开启
// 2.1获取当前时间
LocalDateTime now = LocalDateTime.now();
if (seckillVoucher.getBeginTime().isAfter(now)) {
return Result.fail("秒杀未开启");
}
// 3.判断秒杀是否结束
if (seckillVoucher.getEndTime().isBefore(now)) {
return Result.fail("秒杀已结束");
}
// 4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional(rollbackFor = Exception.class)
public Result createVoucherOrder(Long voucherId) {
// 5.判断用于是否已经领取
Long userId = UserHolder.getUser().getId();
if (query().eq("user_id", userId).eq("voucher_id", voucherId).count() > 0) {
return Result.fail("用户已经领取过");
}
// 6.扣减库存
boolean res = seckillVoucherMapper.updateStock(voucherId);
if (!res) {
return Result.fail("库存不足");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
long orderId = RedisWorker.nextId("order");
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}
主要代码:
synchronized(userId.toString().intern()){
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
为什么这里我们使用AopContext.currentProxy()获取代理对象去调用createVoucherOrder方法呢?
如果我们不调用代理对象的话会导致@Transactional失效,直接使用
return this.createVoucherOrder(voucherId);
的话会发现使用的是本地对象的方法,没有用到spring容器,就无法触发AOP,导致事务失效
。
在实现以上代码是需要进行以下配置:
#在pom.xml添加依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
#在application开启代理注解
@EnableAspectJAutoProxy(exposeProxy = true)//暴露代理对象
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
注意:以上操作只适用于单服务,如果出现集群负载均衡难么会出现问题。
因为synchronized关键字的监听器独立于自己的服务器,所以在集群情况下就行出现synchronized关键字失效。
使用分布式锁即可解决,那么什么是分布式锁呢?
3.2集群适用
分布式锁:
满足分布式系统或者集群模式下多进程可见并且互斥的锁。
分布式锁的实现
redis实现分布式锁:
package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
@Slf4j
public class SimpleRedisLock implements Lock{
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String ID_PREFIX = UUID.fastUUID().toString(true);
@Override
public boolean tryLock(Long timeoutSec) {
String key = "lock:" + name;
// 获取线程名称
String id = ID_PREFIX+Thread.currentThread().getId();
Boolean res = stringRedisTemplate.opsForValue().setIfAbsent(key, id , timeoutSec, TimeUnit.SECONDS);
log.info("获取锁:{}",res);
return Boolean.TRUE.equals(res);
}
@Override
public void unLock() {
// 判断是否是同一把锁
String key = "lock:" + name;
String thread_id = ID_PREFIX+Thread.currentThread().getId();
String id= stringRedisTemplate.opsForValue().get(key);
if (thread_id.equals(id)){
Boolean delete = stringRedisTemplate.delete("lock:" + name);
log.info("删除锁:{}",delete);
}
}
}
问题
:上面代码仍然存在问题,在jvm导致也无堵塞的环境下,还是会导致误删锁。
问题所在
:在上面代码中我们可以看出我们获取锁和释放锁是分开的两个操作,在上图中线程一出现了阻塞状态,假设阻塞了很久导致线程一的锁超时释放了,在阻塞的过程中线程二进入获取锁,此时刚好阻塞结束了要执行释放锁,这时就提前把线程二锁给释放了,导致了锁误删。
解决方案:使用redis的事务保证获取锁和释放锁的原子性,但是过去复杂,此处我们使用lua脚本实现。
lua脚本
Lua教程地址:Lua 教程 - Lua教程 - 菜鸟教程
在Redis中使用LUA脚本:
教程地址:Redis 脚本 - 菜鸟教程
无参语法:
EVAL "return redis.call('set','name','zs')" 0
#"return redis.call('set','name','zs')" 脚本内容
#0 表示脚本参数为0
有参语法:
EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name ww
整合:
在Resource下面添加unLock.lua文件
-- 该Lua函数用于判断Redis中指定键的值是否与给定值相等,如果相等则删除该键,否则返回0。函数接受两个参数,
-- 第一个参数为键名数组,第二个参数为给定的值。函数首先通过redis.call('get',KEYS[1])获取键名数组中第一个键的值,
-- 然后与给定值进行比较,如果相等则执行redis.call('del',KEYS[1])删除该键并返回结果,否则直接返回0。
if(redis.call('get',KEYS[1]) == ARGV[1]) then
return redis.call('del',KEYS[1])
end
return 0
/**
* 简单的Redis分布式锁实现类。
* 使用StringRedisTemplate进行操作,通过设置和检查Redis中的值来实现锁机制。
*/
@Slf4j
public class SimpleRedisLock implements Lock{
private String name; // 锁的名称
private StringRedisTemplate stringRedisTemplate; // Redis模板,用于执行Redis操作
/**
* 构造函数
* @param name 锁的唯一标识符
* @param stringRedisTemplate 用于操作Redis的模板
*/
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
// 使用UUID作为锁的ID前缀,保证唯一性
private static final String ID_PREFIX = UUID.fastUUID().toString(true);
private static final DefaultRedisScript<Long> un_lock; // 解锁的Lua脚本
// 初始化解锁Lua脚本
static {
un_lock = new DefaultRedisScript<>();
un_lock.setLocation(new ClassPathResource("unlock.lua"));
un_lock.setResultType(Long.class);
}
/**
* 尝试获取锁。
* @param timeoutSec 获取锁的超时时间(秒)
* @return 如果成功获取锁返回true,否则返回false
*/
@Override
public boolean tryLock(Long timeoutSec) {
String key = "lock:" + name; // 构造锁的Redis键
// 使用线程ID和ID前缀组合作为锁的ID,保证线程安全
String id = ID_PREFIX+Thread.currentThread().getId();
Boolean res = stringRedisTemplate.opsForValue().setIfAbsent(key, id , timeoutSec, TimeUnit.SECONDS);
log.info("获取锁:{}",res);
return Boolean.TRUE.equals(res);
}
/**
* 释放锁。
* 使用Lua脚本确保释放操作的原子性。
*/
@Override
public void unLock() {
stringRedisTemplate.execute(un_lock,
Collections.singletonList("lock:" + name)
,ID_PREFIX+Thread.currentThread().getId());
}
}
缺点:
1.无法重入:同一个线程无法多次获取同一把锁。
2.不可重试:获取锁只尝试一次就返回false,没有重试机制
3.超时释放
4.主从一致性
根据以上缺点我们下章使用redisson去解决!!!