一,封装通用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);
}