Redis入门到入土-章节二-秒杀业务

165 阅读2分钟

上一章说了Redis的一些基础概念,也埋下了很多实操上面的具体操作。这一章主要从开发功能上再对redis展开说说

毕竟不是所有开发者都能遇到相应的业务,但是理论知识我们还是需要掌握住

秒杀下单业务

在秒杀场景下,如何保存下单用户的信息

全局id

使用全局id生成器,全局id生成器需要满足的特性:唯一性,高可用,高性能,递增性,安全性

选择方案:自定义全局id生成器

这里选择使用Redis生成全局id。你问为什么?

使用redis生成id,因为redis独立于mysql,生成id不影响MySQL性能,为了区分唯一性,生成id后再添加一段进行拼接,存入数据库时采用全数值类型,占用空间少,能更快建立索引

方案二:UUID

方案三:snowflake(雪花算法)

方案四:数据库自增

采用数据库自增id,在业务代码中我们可以不用管id如何变化,但是这样保存的数据id规律明显,并且在秒杀数量足量时,表的存储量受限

秒杀下单

流程:①提交商品id,查询商品是否开始秒杀;②开始秒杀,判断商品库存是否充足,充足扣减库存,创建订单,返回订单信息;③不充足返回异常结果

public Integer deductionOfInventory(Long id) {
    // 1. 查询是否存在商品
    Object dataFromDb = productService.getById(id);
	if(dataFromDb == null) {
        return null;
    }
    // 2. 是否在指定时间内
    if(dataFromDb.getStartTime().isAfter(LocalDataTime.now())) {
        // 还未开始
        return null;
    }
    if(dataFromDb.getEndTime().isBefore(LocalDataTime.now())) {
        // 已结束
        return null;
    }
    // 3. 是否还有库存
    if(dataFromDb.getStore() < 1) {
        // 卖完了
        return null;
    }
    // 4. 更新库存
    boolean status = productService.update().setSql("stock = stock - 1").eq("id", id).update();
    // 5. 更新成功创建订单
    if(status) {
        Order order = new Order();
        // ...
        return order.getId();
    }
    return null;
}

这种方式基本实现了一个简单的秒杀系统,在单线程中可满足基本能使用,但是在高并发中就会出现超卖的情况

超卖解决方法

常见解决方案为加锁:悲观锁 / 乐观锁

悲观锁:在操作数据之前进行加锁,确保线程串行执行

乐观锁:在操作数据之后更新数据之前再判断是否有其他线程进行修改,有返回错误

乐观锁解决方案

版本号法

操作数据时,查询出商品的数量和操作版本号,在完成使用更新数据时,将版本号作为条件语句携带查询,版本号一致,版本号+1并更新,如果版本号不一致,停止操作;

CAS(Compare And Save)

操作数据时,将更新前查询出的库存数量带入更新语句中比较,如果数量一致表示能更新

productService.updaate().setSql("stock = stock - 1"),eq("id", id).eq("stock", dataFromDb.getStock()).update();

存在的问题

当一条数据更新后,其他竞争的操作将全部错误(第一次修改库存后,后续更新拿到的仓库数量与实际存储的不符合)

解决方法

更新时,只要版本号大于0就能更新

productService.update().setSql("stock = stock - 1").eq("id", id).gt("stock", dataFromDb.getStock()).update();

一人一单

方案一:

在更新数据库(扣减库存)之前,根据用户id查询和商品id查询订单中是否存在,存在不扣减库存

存在问题:多线程情况查询时,会出现几个线程查询都为空的情况,导致还是一人多单

方案二:

将查询用户订单和扣减库存业务抽取出来,在方法上加锁

存在问题:锁方法,使用锁是当前类,粒度过大

方案三:

锁加到业务逻辑中,使用userId作为锁对象

存在问题:释放锁之后提交事务之前,可能存在其他用户进入查询库存,此时上一个事务还未提交,锁粒度过小

方案四:

抽取代码,哪里调用就在那里加锁(使用用户ID)

存在问题:如果调用方没有添加事务注解,抽取代码添加了事务注解;此时调用抽取代码时,事务将不生效(方法中调用同类其他方法时,使用的是当前对象进行调用,而想要事务生效,需要使用代理对象)

long userid = xxx;
synchronized(userid){
    // 获取代理对象
    serviceInterface = (serviceInterface)AopContext.currentProxy();
    serviceInterface.method();
}

问题:在单服务中能正常使用,在微服务集群中,同样可能出现超卖问题。每个服务存在一个JVM,而每个JVM的锁监视器只能监听自己管理的锁,导致同样出现超卖的问题

分布式下超卖问题

在当前方案中,需要解决的问题有

设置锁后redis宕机,锁没有被释放;
设置锁后需要再设置过期时间,在设置过期时间之前Redis宕机。

第二点解决方案:保证设置过期时间和设置锁的原子性,使用SET key value EX time NX来确保锁和过期时间为原子性

在业务中使用分布式锁时,如果尝试获取失败,不能采用持续获取锁资源,在第一次获取锁后需要及时将状态进行返回,等待一段时间后再次获取

在释放锁时,1.执行完业务后手动释放锁;2.超时释放,redis在获取key后发现超时自动释放

String name;
public boolean tryLock(long time) {
    String id = Thread.currentThread().getId();
    // 使用redis互斥锁
    Boolean status = stringRedisTemplate.opsForValue()
                                    .setIfAbsent(name,id,time,TimeUnit.SECONDS);
    return Boolean.TRUE.equals(status);
}
// 直接释放锁
public void unLock(){
    stringRedisTemplate.delete(Thread.currentThread().getId());
}

使用redis互斥锁方式替换synchronized加锁方式

当然,根据以上解决方案,还存在以下问题:

A:虽然设置了超时时间,但当业务执行时间远远大于锁的超时时间,
导致锁提前因超时释放,其他线程执行时,当前业务又释放一次锁,导致新线程还未执行完就被释放锁。(
当前线程不一定释放的时自己的锁)

B:在释放锁时,如果发生线程阻塞造成锁释放超时(再已经判断了是自己的锁,但是释放锁时超时,
锁中没有当前线程的标识,极有可能造成误删锁的情况),同样会造成锁的误删,所以需要保证判断 锁标识和锁释放的原子性

针对问题A的解决方法

  • 在获取锁时给锁存入线程标识;
  • 在释放线程时先获取锁中的线程标识与当前线程比对,一致再释放
String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
String KEY_PREFIX = "lock:";
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);
    }
}

针对问题B的解决方案 解决方案:LUA脚本(一个脚本多条命令,统一执行,保证原子性)

redis内置的脚本使用:redis.call('set',KEYS[1],ARGV[1]) 1 name sss 2

业务中为了减少io流的操作,需要将脚本放在单独文件中,业务中使用RedisScript进行调用

-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

-- 调用
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(keyName),
            value;
}

秒杀优化

秒杀优化:基于lua脚本

lua脚本:

local productId = ARGV[1]
// 用户id
local userId = ARGV[2]
// 订单id
local orderId = ARGV[3]
// 库存key
local productKey = "product:product:" .. productId
// 订单key
local orderKey = "order:order:" .. productId

// 判断库存是否充足
if(tonumber(redis.call('get',productKey))<=0) then
    return 1;
end;
// 判断用户是否下单 SISMEMBER orderKey userId 
if(redis.call('SISMEMBER',orderKey,userId)) then
    return 2;
end;
// 扣减库存 incrby stockKey -1
redis.call('incrby',productKey,-1)
// 添加订单sadd orderKey userId
redis.call('sadd',orderKey,userId)
//
end;

修改业务:


private static final DefaultRedisScript<Long> LUA_SCRIPT;
static {
    // 配置读取的脚本文件
    LUA_SCRIPT = new DefaultRedisScript<>();
    LUA_SCRIPT.setLocation(new ClassPathResource("lua脚本"));
    LUA_SCRIPT.setResultType(Long.class);
}

public Object seckill(Long productId) {
	Long userId = UserHolder.getUser().getId();
	// 使用lua脚本扣减redis库存并保存用户购买信息
	Long execute = stringRedisTemplate.execute()
		LUA_SCRIPT,
		Collections.emptyList(),
		productId.toString, userId.toString()
    );
    // 判断是否成功
    if(execute != 0) {
    	return execute == 1 ? "库存不足" : "已购买过":
    }
    return ok;
}

秒杀优化:基于阻塞队列

用户下完单后,将数据放入阻塞队列中,使用新线程,从阻塞队列中获取数据,实现异步下单

阻塞队列:当线程尝试从阻塞队列中获取元素,但是队列为空时,线程将被阻塞,直到队列中存在数据才会被唤醒

异步下单:开启独立线程从阻塞队列中取出订单下单

// 创建自定义阻塞队列,大小为1024 * 1024
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
// 创建线程池,处理订单不需要太快,创建单线程慢慢执行
static final ExecutorService ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
// 代理对象
IServiceInterface serviceInterface = proxy;
​
@AutoWrite
RedissionClient redisClient;
​
@AutoWrite
ProductServiceInterface productService;
​
// 在使用lua脚本抢购商品后,将数据放入阻塞队列中
public Object seckill(Long productId){
    // 执行lua脚本抢购商品
    ...
    // 成功,将数据放入阻塞队列中
    Product pro = new Product();
    // 订单id,根据实际业务获取
    String orderId = "";
    pro.setId(orderId);
    pro.setUserId(userId);
    pro.setProduct(productId);
    // 放入阻塞队列
    orderTask.add(pro);
    // 提前获取代理对象,让子线程中也能调用到主线程中的方法
    proxy = (IServiceInterface)AopContext.currentProxy();
    // 返回下单成功信息
    return Result.ok(); 
}
​
// 线程执行需要在项目启动就执行,等待队列中数据// 使用内部内创建线程任务,等待获取队列中数据
class OrderHandler implements Runner {
     @Override
        public void run() {
            // 获取队列中信息,task()为阻塞方法,当队列中存在数据时才会执行,获取队列中的信息
            VoucherOrder taskorder = orderTasks.task();
            // 调用外部方法创建订单
            orderInsertToData(taskorder);
        }
}
// 从阻塞队列中获取订单数据同步到数据库
public void orderInsertToData(VoucherOrder order) {
    // 使用订单id创建锁对象,防止重复同步到数据库
    String orderId = order.getId();
    RLock lock = redisClient.getLock("specialKey"+orderId);
    // 根据锁对象判断是否重复下单同步
    boolean isLock = lock.tryLock();
    if(!isLock){
        // 重复下单
        return;
    }
    try{
        // 扣减数据库中数据,保存订单数据到数据库
        decreaseNumAndSave(order);
    }catch(Exception e) {
        
    }finally{
        lock.unlock();
    }
}
​
public void decreaseNumAndSave(VoucherOrder order){
    // 再次判断当前用户是否购买过
    int count = query().eq("user_id",order.getUserId()).eq("product_id",order.getProductId()).count();
    if(count>0) return; // 当前用户已经购买过
    // 扣减库存
    boolean success = productService..update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("productId", order.getProductId()).gt("stock", 0) // where id = ? and stock > 0
                .update();
    if(!success) return; // 失败,库存不足
    // 保存订单数据
    save(order);
}

存在问题:

使用阻塞队列能提高一部分业务响应时间,但是阻塞队列设置了默认大小,所以当有足量的数据进入时,会造成内存溢出

数据安全问题(当JVM出现宕机或者服务重启时,所有信息将被丢失