文章目录
全局唯一ID
当用户抢购时,就会生成订单并保存到订单表中,而订单表如果使用数据库自增 id 就存在一些问题:
- id 的规律性太明显
- 采用数据库自增ID受单表数据量的限制,订单表采用分库分表时候,ID在不同表会重复
设计
序列号为了在并发下高可用和唯一性,可采用Redis的自增来构成序列号,不过要考虑以下问题:
- Redis的自增值要在2的64次方的范围内
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;
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// keyPrefix业务字段,订单业务可用传order,也可生成其他业务的全局唯一id
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天,一天一个key,使得value从0自增不会超出大小范围
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// Redis incrby 命令将 key 中储存的数字加上指定的增量值,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCRBY 命令
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回,左移并进行或操作
return timestamp << COUNT_BITS | count;
}
public static void main(String[] args) {
LocalDateTime of = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
long l = of.toEpochSecond(ZoneOffset.UTC);
// LocalTime类的toEpochSecond()方法用于将此LocalTime转换为自1970-01-01T00:00:00以来的秒数
System.out.println(l);
}
}
测试
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}
await 方法 是阻塞方法,我们担心分线程没有执行完时,main 线程就先执行,所以使用 await 可以让 main 线程阻塞,那么什么时候 main 线程不再阻塞呢?当CountDownLatch 内部维护的 变量变为 0 时,就不再阻塞,直接放行,那么什么时候 CountDownLatch 维护的变量变为 0 呢,我们只需要调用一次 countDown ,内部变量就减少 1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是 0,此时 await 就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。
添加秒杀卷
- 普通代金卷表:优惠券的基本信息,优惠金额、使用规则等
- 秒杀卷表:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息(普通代金卷拓展)
// 要加事务,同时保存到redis,提供更好的性能
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到Redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
实现秒杀下单
下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询相关店铺的具体优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
超卖问题
如果并发多个线程,在仅剩一张优惠劵的同时判断库存都有剩余,则会并发减库存导致超卖,对于该问题可用乐观锁解决
// 每次更新时候带上之前查询库存的版本号,如果更新时候字段被改过则放弃此次更新
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?
但该优化是错误,因为如果库存为99,多个线程并发,判断版本号为98,与上次查询到的99不一样,放弃更新,但实际上还有库存,我们可继续改进
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
一人一单
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
// 5.一人一单逻辑
// 5.1.用户id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
//6,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock", 0); //where id = ? and stock > 0
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,插入数据本不存在,所以难以用乐观锁解决,所以我们需要使用悲观锁操作
如果把synchronized加到方法上,锁的粒度太粗了,在使用锁过程中,
控制锁粒度
是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,封装要锁住的代码段:
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}
intern () 这个方法是从常量池中拿到数据,如果我们直接使用 userId.toString () 他拿到的对象实际上是不同的对象,new 出来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用 intern () 方法
但是以上代码还是存在问题,问题的原因在于当前方法被 spring 的事务控制,如果你在方法内部加锁,可能会
导致当前方法事务还没有提交,但是锁已经释放
,这样会出现有线程释放了锁,但事务没有提交,同一用户的其他线程获取锁查询数据库发现还是没有购买记录,所以也新增了一个数据
,这时就会导致两个事务提交了,有两条记录,不符合一人一单,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
// 5.一人一单逻辑
// 5.1.用户id
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()) {
return createVoucherOrder(voucherId)
}
}
但是以上做法依然有问题,
因为你调用的方法,其实是 this. 的方式调用的,事务想要生效,还得利用代理来生效
,所以这个地方,我们需要获得原始的事务对象, 来操作事务
synchronized(userId.toString().intern()) {
// 要添加相关依赖和暴露接口
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId)
}
分布式集群
不同机器不同JVM实例,所以会导致锁失效
分布式锁
原理
要素设计
Redis实现方案
redis 作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用
redis
作为分布式锁,利用setnx
这个方法,如果插入 key 成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
要点和难点
- 线程互斥:两个线程只能一个获取锁
- 死锁:当业务执行完成,应该释放该锁,给其他线程获取
- 锁误删:就是
线程1业务阻塞导致锁超时释放了
,其他业务获取锁后被线程1业务完成后给执行释放了
,我们要对锁加标识 - 锁续期:当线程1超时释放了,业务没有完成,第二个锁获取了该锁去执行业务,这是不应该的,
因为线程1的业务没有提交,第二个线程获取锁了去数据库查询本人订单并没有下单,所以下了一单,第一个线程也重新获取cpu时间片也去下了单
,这显然没有满足需求。所以当第一个业务的事务没有提交时,锁应该不被释放(这是给redission解决的了
)
import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private String name;
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
// 创建锁对象(新增代码)
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取锁对象
boolean isLock = lock.tryLock(1200);
// 加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unlock();
}
}
由于获取锁、锁判断和释放锁不是原子性(也就是释放锁业务不是原子性的),所以极端情况下:
if(threadId.equals(id)) {
// 判断完要释放锁,cpu切换阻塞,导致锁超时,第二个线程获取了锁,然后重新获取cpu时间片释放了第二个线程刚刚获取的锁,这还是会出现锁误删情况
stringRedisTemplate.delete(KEY_PREFIX + name);
}
Lua 脚本解决多条命令原子性问题
释放锁的业务流程是这样的
- 1、获取锁中的线程标示
- 2、判断是否与指定的标示(当前线程标示)一致
- 3、如果一致则释放锁(删除)
- 4、如果不一致则什么都不做
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
利用 Java 代码调用 Lua 脚本改造分布式锁
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
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(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
redission分布式锁
依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
简单入门
@Resource
private RedissionClient redissonClient;
@Test
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
/* 尝试获取锁,参数分别是:
获取锁的最大等待时间(期间会重试),
锁自动释放时间,
时间单位 */
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
基于setnx
实现的分布式锁存在下面的问题:
不可重入问题
采用了hash结构,锁标识由业务名称+线程id
组成,如果是同一把锁重入,则value值+1,表示第二次获取了锁,如果为0则表示锁没有人持有。
# 获取锁成功都是返回nil
# 判断锁是否存在,是进入创建并获取锁逻辑
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
# 再判断锁标识是否自己,是自己的进入锁重入逻辑
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
# 否则获取锁失败,返回锁过期时间
"return redis.call('pttl', KEYS[1]);"
不可重试问题
/*
第一个参数是等待时间,如果没有给也是默认是-1,redission不会等待,获取失败则马上返回结果
第二个参数是超时时间,如果没有给默认是-1,redission会给一个默认时间
第三个参数是时间单位
*/
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime); // 传化时间单位
long current = System.currentTimeMillis(); // 获取当前时间
long threadId = Thread.currentThread().getId(); // 线程id
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId); // 尝试获取锁,也就是进入上面lua脚本重入脚本逻辑,返回null则表示获取锁成功
// lock acquired
if (ttl == null) {
return true;
}
// 失败,计算尝试获取锁花费的时间,并和等待时间比较
time -= System.currentTimeMillis() - current;
// 如果超过剩余等待时间,则表示等待时间内获取锁失败
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
// 订阅释放锁的消息
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// 订阅等待时间如果超过剩余等待时间,则也是获取锁失败
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
// 失败后要取消订阅
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
return false;
}
try {
// 再次计算剩余等待时间
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
while (true) {
long currentTime = System.currentTimeMillis();
// 满足上述条件了,就是还有剩余等待时间且收到了消息订阅的通知,可用再次重试获取锁了
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
// 重试获取锁成功
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// waiting for message
// 获取锁还是失败了,但还有剩余时间
currentTime = System.currentTimeMillis();
// 也要等,不过要采用信号量,如果ttl过期时间 < 剩余等待时间,则等待ttl时间后再唤醒该线程
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
// 其实这里看得不是很懂了,可以查阅相关资料深入了解
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
其实就是采用消息订阅和信号量解决不可重入问题
超时释放
如果不设置过期时间,redission会采用看门狗机制
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
// putIfAbsent:没有该key则加入,有则返回null
// 为了保证如果是同一把锁重入,返回的是同一个实例,不同线程则返回不同的ExpirationEntry实例
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
// 不为null,则证明是一个新的线程来获取锁
entry.addThreadId(threadId);
// 给这把锁加上一个定时任务,每隔一段时间重新刷新过期时间,直到业务完成释放锁后才把定时任务接触
renewExpiration();
}
}
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
// 定时任务
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
看门狗机制就是新线程如果获取锁成功后加一个定时任务,每隔一段时间去更新过期时间,直到业务完成释放锁才解除
流程图
主从一致性问题
假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个 slave 变成 master,而此时新的 master 中实际上并没有锁信息,此时锁信息就已经丢掉了。
为了解决这个问题,redission 提出来了 MutiLock 锁
,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功
,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。
当我们去设置了多个锁时,redission 会将多个锁添加到一个集合中,然后用 while 循环去不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,假设有 3 个锁,那么时间就是 4500ms,假设在这 4500ms 内,所有的锁都加锁成功, 那么此时才算是加锁成功,如果在 4500ms 有线程加锁失败,则会再次去进行重试
秒杀优化
优化方案:我们将耗时比较长的逻辑判断放入到 redis 中(
库存判断,一人一单
),我们只需要进行快速的逻辑判断,不用等下单逻辑走完,我们直接给用户返回成功,后台去异步把成功下单的数据记录到mysql即可。
库存和一人一单的业务解决分别采用了string和set数据结构
6.2 秒杀优化 - Redis 完成秒杀资格判断
需求:
1.新增秒杀优惠券的同时,将优惠券信息保存到 Redis 中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
2.基于 Lua 脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
3.如果抢购成功,将优惠券 id 和用户 id 封装后存入阻塞队列
4.开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题(会损耗jvm内存)
- 数据安全问题(不能持久化可能会丢失)
Redis 消息队列(基于 Stream 的消息队列)
Stream 是
Redis 5.0 引入的一种新数据类型
,可以实现一个功能非常完善的消息队列。
# 往名为users的队列中发送一个内容为{name=jacks, age=21}的消息,并且使用Redis自动生成ID
127.0.0.1:6379> XADD users * name jacks age 34
"1663310029278-0"
# 在名为users的队列中一次读取1条消息
# 0代表从第一条消息开始
# $代表从最新一条消息开始
127.0.0.1:6379> XREAD COUNT 1 STREAMS users 0
1) 1) "users"
2) 1) 1) "1663310029278-0"
2) 1) "name"
2) "jacks"
3) "age"
4) "34"
注意:当我们指定起始 ID 为 $ 时
,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过 1 条以上
的消息到达队列,则下次获取时也只能获取到最新的一条
,会出现漏读
消息的问题
基于 Stream 的消息队列 - 消费者组
特点
# 给队列steam:orders创建一个消费者组g1,从第一个消息开始消费(其实就是为了消费之前保留的消息,如果是从最新的消息消费,那以前的消息则不会被消费)
XGROUP CREATE steam:orders g1 0
优化步骤
秒杀业务:判断是否有购买资格,有则操作redis保存的数据,不等订单创建直接返回结果给客户
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
@Override
public Result seckillVoucher(Long voucherId) {
// 获取当前登录用户id
Long userId = UserHolder.getUser().getId();
// 生成全局唯一id给订单实体类
long orderId = redisIdWorker.nextId("order");
// 1.执行lua脚本,
/*
进行库存判断,库存不足返回1
再进行一人一单判断,如果set集合已经存在用户id,表明不能重复购买,返回2
有购买资格则扣减库存,往set集合添加用户id标记用户购买过该券
发送订单消息给队列stream.orders,给之后异步生成订单
*/
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // 没有key,给一个空数组
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 3.返回订单id
return Result.ok(orderId);
}
lua脚本
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
创建线程池,项目启动时,开启一个线程任务,尝试获取 stream.orders 中的消息,完成下单
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
// 如果消费者c1不存在,往g1组加入c1消费者
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
// >:从下一个未消费的消息开始,确保每一个消息都能被消费掉
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
handlePendingList();
}
}
}
// 处理异常消息
private void handlePendingList() {
while (true) {
try {
// 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
// 0:从第一个开始,如果确认消费了会移除出pending-list,从第一个开始也是为了确保异常消息都能被消费
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有异常消息,结束循环
break;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
} catch (Exception e) {
// 出异常了不用递归调用了,因为有外循环
log.error("处理订单异常", e);
}
}
}
}