【redis实战篇】第四天

一,封装通用redis处理类CacheClient

使用泛型和函数编程将缓存空值解决缓存穿透,逻辑过期和互斥锁解决缓存击穿封装为通用的方法,方便后续使用。

(1)函数编程思想Function<ID, R>代表有参ID有返回值R的函数(R apply = dbFallback.apply(id);),调用者需传入一个函数逻辑(this::getById)

(2)其余参数通过泛型一一替换即可

@Slf4j
@Component
public class CacheClient {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    public void setWitLogicExpire(String key, Object value, Long time, TimeUnit unit) {
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    //获取锁
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "lock", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    //释放锁
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }


    //Function<ID, R>代表有参有返回值的函数,调用者需传入一个函数逻辑
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        //1,查询缓存
        String key = keyPrefix + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2,判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            return null;
        }

        //3,判断是否过期 (逻辑过期)
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        if (expireTime.isAfter(LocalDateTime.now())) {
            //4,未过期,直接返回店铺信息
            return r;
        }
        //5,已过期,需要缓存重建
        String lockKey = LOCK_SHOP_KEY + id;
        //获取锁
        boolean isLock = tryLock(lockKey);
        if (isLock) {
            String reShop = stringRedisTemplate.opsForValue().get(key);
            //这一步可以考虑删除
            if (StrUtil.isBlank(reShop)) {
                return null;
            }
            //判断是否过期 (逻辑过期)
            RedisData redisDataCheck = JSONUtil.toBean(shopJson, RedisData.class);
            R rCheck = JSONUtil.toBean((JSONObject) redisDataCheck.getData(), type);
            LocalDateTime expireTimeCheck = redisDataCheck.getExpireTime();
            if (expireTimeCheck.isAfter(LocalDateTime.now())) {
                //未过期,直接返回店铺信息
                return rCheck;
            }
            //6,获取锁成功,开启独立线程,重建缓存,可以改善到异步重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                //重建缓存
                try {
                    R apply = dbFallback.apply(id);
                    this.setWitLogicExpire(key, apply, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unlock(lockKey);
                }
            });
        }

        return r;
    }
}

 

 二,全局唯一ID生成器

1,全局唯一ID生成策略

(1)UUID

(2)redis自增

(3)雪花算法 

(4)数据库自增(类型数据库替代redis,定义ID自增表)

2,redis自增结构

符号(1bit)+时间戳(31bit)+序列号(32bit)

3,redisWorker实现(stringRedisTemplate.opsForValue().increment()自增方法)

利用位运算和或运算实现两个long类型的拼接;redis中的key拼接了日期,方便我们统计每天,每月和每年的订单量。

@Component
public class RedisIdWorker {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    //开始时间时间戳1735689600
    private static final long BEGIN_TIMESTAMP = 1735689600L;

    //序列号的位数
    private static final int COUNT_BITS = 32;

    public long nextId(String keyPrefix) {
        //1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;
        //2.生成序列号
        // 获取当前日期,精确到天,防止超过序列号上限
        // 使用yyyy:MM:dd格式在redis中会分层级,方便我们统计每天,每月和每年的订单量
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix+ ":" + date);
        //3.拼接并返回
        //左移32位全为零作为序列号,通过或运算(0|1=1,0|0=0)等于本身拼接序列号
        return timestamp << COUNT_BITS | count;
    }

三,实现抢购代金卷基本功能 

(1)通过代金卷id获取秒杀卷信息SeckillVoucher,然后判断是否开始、结束和库存。

(2)通过id更新扣减库存,返回是否成功结果

(3)成功创建订单对象,使用ID生成器生成唯一id作为订单id,填入用户id和代金卷id

    public Result seckillVoucher(Long voucherId) {
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始!");
        }
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束!");
        }
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
 boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0).update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        return Result.ok(orderId);
    }
      

 

四,解决超卖问题

高并发下的竞争大量用户同时提交订单,导致多个订单读取到相同的库存状态;订单系统、库存系统之间的数据同步滞后,导致超卖。

1,悲观锁(synchronized{}、lock等)

定义:假设其他人会同时修改数据,因此每次操作时都会加锁,确保一个线程独占访问。

优点:避免数据不一致,适合写操作频繁或需要高数据一致性的场景。

缺点:锁竞争可能导致性能瓶颈,降低并发能力。

2,乐观锁(版本号法和时间戳)

定义:假设其他人不会频繁修改数据,不每次都加锁,而是在提交时检查冲突。

优点:提高并发性能,减少锁开销,适合读多写少的场景。

缺点:冲突发生时需回滚或重试,增加复杂性。

 3,利用类版本号法解决超卖问题

tips:这里的sql语句尽量选用原子性的操作,即:数据库层面的原子操作,否则易出现安全问题。

(1)将库存作为版本号,每次更新时都要将数据库当前库存和之前查询到的库存进行比对,如果不一致说明有线程已经修改过,所以更新失败。

boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock", seckillVoucher.getStock()).update();

(2)由于高并发场景,这里会有相当一部分线程更新失败,库存”卖不出去“,也是不正确的;所以直接让库存字段与0比较,只有大于0才会更新。

boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
    .eq("voucher_id", voucherId)
    .gt("stock", 0).update();

4,一人一单需求

 限定一个用户只能购买一单,当用户购买时,通过用户id和代金卷id查询是否有订单数量,一旦数量大于0说明该用户已购买,返回错误信息。

(1)一人一单判断,扣减库存,创建订单返回归纳到一个方法createVoucherOrder()。

long count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
    return Result.fail("不能重复下单!");
}

 (2)涉及到多表的写操作必须添加事务,改变事务范围,将方法createVoucherOrder()进行事务管理即可。 

(3)添加悲观锁 synchronized{}指定锁的范围

(3.1)锁的范围

        对userid加锁,因为一人一卖,只需要锁对应的那个对象就行,不同的用户会加新的锁,但是因为userId.toString()的底层会new String()所以即使是同一个id转换为字符串地址也会不同导致锁也不同,所以这里调用intern()来返回同一个字符串的引用,保证锁的范围。

(3.2)锁的位置

        方法createVoucherOrder()内加锁-->这里加锁会因为事务比锁范围大,导致锁已经释放而事务还未提交导致其他线程拿到锁进来,因为事务还未提交,所以查询依然为旧值,从而引发线程安全问题,所以在方法createVoucherOrder()调用处加锁

(3.3)获取代理对象

tips:启动类添加@EnableAspectJAutoProxy(exposeProxy = true)这个默认为false,暴露代理对象;添加aspectjweaver依赖。

        直接调用方法createVoucherOrder()省略了一个"this."调用的是VoucherOrderServiceImpl这个对象的方法,而不是代理对象的;但是事这个务是通过VoucherOrderServiceImpl的代理对象来实现的,所以这里要获取到代理对象。

Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId,userId);
}

        

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值