上一章说了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出现宕机或者服务重启时,所有信息将被丢失