背景
秒杀品的数据在秒杀过程中,是大流量的主要集中地,其QPS会高于其他相关接口,配套的缓存方案不可或缺。
总体原则
采用混合缓存:本地缓存 + 分布式缓存
以本地缓存为主,分布式缓存为辅,本地缓存是第一道防线,分布式缓存是第二道防线;
单机的混合缓存
客户端请求先打到本地缓存上,如果未命中或版本号不合法,则访问分布式缓存去获取最新的值。这里有几个需要注意的地方:
- 在进行本地缓存更新时,要进行加锁处理,保证只有一个本地线程去更新本地缓存,避免多次更新影响性能。
- 在更新分布式缓存时,要先获取分布式锁,再进行更新,避免缓存击穿;
- 如果在数据库获取的值为空,也要存入缓存,避免缓存穿透;
- 如果获取分布式锁失败,不要进行重复尝试获取锁,直接返回客户端,显示稍后再试即可;
集群的混合缓存
每一台服务器都拥有自己的本地缓存,当本地缓存失效时,才尝试获取分布式锁访问分布式缓存,获取最新的数据。
应当尽量使用本地缓存来应对流量洪峰,访问远程的分布式缓存会存在网络IO等的时间损耗。
技术实现
本地缓存
优势
- 没有远程IO,性能优越;
- 有利于服务的横向扩展,因为大部分请求都是打到单机缓存上;
技术实现
本地缓存使用 Guava 中的 Cache
// 定义
private final static Cache<Long, SeckillGoodsCache> localCache = CacheBuilder.newBuilder()
.initialCapacity(10)
.concurrencyLevel(5)
.expireAfterWrite(10L, TimeUnit.SECONDS)
.build();
// 添加值
localCache.put(activityId, distributedSeckillGoodsCache);
// 获取值
SeckillGoodsCache localSeckillGoodsCache = localCache.getIfPresent(activityId);
缓存的生命周期
被动更新:缓存的TTL到期;
主动更新:传入的版本号大于本地缓存中的版本号,说明缓存版本滞后,需要从分布式缓存中获取;
分布式缓存
作用
维护本地缓存的数据一致性,主要作用在于协调和同步最新数据到本地缓存。
技术实现
分布式缓存方案默认采用的是主流的缓存框Redis,配合Redisson实现分布式锁。
具体实现参考文章:juejin.cn/post/718520…
缓存生命周期:
被动更新:基于Redis的数据驱逐策略,例如:缓存的TTL到期;
主动更新:业务数据驱动的数据更新。当业务侧有数据变更时,将会主动刷新分布式缓存。
代码实现
实体类代码
@Data
@Accessors(chain = true)
public class SeckillGoodCache {
/**
* 数据是否存在
*/
protected boolean exist;
/**
* 商品实体
*/
private SeckillGood seckillGood;
/**
* 版本号
*/
private Long version;
/**
*没有获取到锁 是否重试
*/
private boolean later;
public SeckillGoodCache with(SeckillGood seckillGood) {
this.exist = true;
this.seckillGood = seckillGood;
return this;
}
public SeckillGoodCache withVersion(Long version) {
this.version = version;
return this;
}
public SeckillGoodCache tryLater() {
this.later = true;
return this;
}
public SeckillGoodCache notExist() {
this.exist = false;
return this;
}
}
- exist字段表示数据库中的数据是否存在,而不是表示缓存是否存在;因为数据库不存在时也要向缓存放入空值,防止缓存穿透;
- later字段表示有缓存正在更新,请稍后重试。因为我们只能让一个线程去修改缓存,但不能让其他线程忙等,应该让他们返回,否则会给系统资源带来负担;
逻辑实现代码
@Service
public class SeckillGoodCacheServiceImpl implements SeckillGoodCacheService {
private final static Logger logger = LoggerFactory.getLogger(SeckillGoodCacheServiceImpl.class);
private final static Cache<Long, SeckillGoodCache> localCache = CacheBuilder.newBuilder()
.initialCapacity(10)
.concurrencyLevel(5)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
private static final String UPDATE_ITEMS_CACHE_LOCK_KEY = "UPDATE_ITEMS_CACHE_LOCK_KEY_";
private final Lock localCacheUpdateLock = new ReentrantLock();
// 分布式锁
@Resource
private DistributedLockFactoryService distributedLockFactoryService;
// 分布式缓存
@Autowired
private DistributedCacheService distributedCacheService;
@Autowired
private SeckillGoodMapper seckillGoodMapper;
@Override
public SeckillGoodCache getSeckillGoodCache(Long activityId, Long itemId, Long version) {
// 获取本地缓存
SeckillGoodCache seckillGoodCache = localCache.getIfPresent(itemId);
// 检查本地缓存是否过期
if (seckillGoodCache != null) {
if (version == null) {
logger.info("本地缓存命中|{}", itemId);
return seckillGoodCache;
}
Long cacheVersion = seckillGoodCache.getVersion();
if (cacheVersion.equals(version) || version < cacheVersion) {
logger.info("本地缓存命中|{}, {}", itemId, version);
return seckillGoodCache;
}
// 如果传入的版本大于本地缓存的版本,意味本地缓存滞后,需要更新
if (version > cacheVersion) {
return getLatestDistributedSeckillGood(activityId, itemId, version);
}
}
return getLatestDistributedSeckillGood(activityId, itemId, version);
}
/**
* 远程缓存的获取 + 本地缓存更新
* @param activityId
* @param itemId
* @param version
* @return
*/
private SeckillGoodCache getLatestDistributedSeckillGood(Long activityId, Long itemId, Long version) {
logger.info("itemCache|读取远程缓存|{}", itemId);
// 获取远程缓存
SeckillGoodCache distributedGoodCache = distributedCacheService.getObject(buildItemCacheKey(itemId), SeckillGoodCache.class);
// 远程缓存为空 就更新远程缓存
if (distributedGoodCache == null || distributedGoodCache.getSeckillGood() == null) {
distributedGoodCache = updateDistributedSeckillGood(itemId);
}
// 不为空就更新本地缓存
if (distributedGoodCache != null && !distributedGoodCache.isLater()) {
// 只需要一个线程更新该锁即可 本地缓存
boolean isLockSuccess = localCacheUpdateLock.tryLock();
if (isLockSuccess) {
try {
localCache.put(itemId, distributedGoodCache);
logger.info("本地缓存已更新|{}", itemId);
} finally {
localCacheUpdateLock.unlock();
}
}
}
return distributedGoodCache;
}
private SeckillGoodCache updateDistributedSeckillGood(Long itemId) {
logger.info("更新远程缓存|{}", itemId);
DistributedLock distributedLock = distributedLockFactoryService.getDistributedLock(UPDATE_ITEMS_CACHE_LOCK_KEY + itemId);
try {
boolean tryLock = distributedLock.tryLock(1, 5, TimeUnit.SECONDS);
// 如果没有获得到锁,就返回重试
if (!tryLock) {
return new SeckillGoodCache().tryLater();
}
// 再次检查
SeckillGoodCache distributedSeckillCache = distributedCacheService.getObject(buildItemCacheKey(itemId), SeckillGoodCache.class);
if (distributedSeckillCache != null) {
return distributedSeckillCache;
}
// 查询数据库
SeckillGood seckillGood = seckillGoodMapper.selectById(itemId);
SeckillGoodCache seckillGoodCache = new SeckillGoodCache();
if (seckillGood == null) {
// 数据不存在 也要返回 也要存缓存 防止缓存穿透
seckillGoodCache.notExist();
} else {
seckillGoodCache.with(seckillGood).setVersion(System.currentTimeMillis());
}
logger.info("itemCache|远程缓存已更新|{}", itemId);
distributedCacheService.put(buildItemCacheKey(itemId), JSON.toJSONString(seckillGoodCache), FIVE_SECONDS, TimeUnit.SECONDS);
return seckillGoodCache;
} catch (InterruptedException e) {
logger.error("itemCache|远程缓存更新失败|{}", itemId);
return new SeckillGoodCache().tryLater();
} finally {
distributedLock.unlock();
}
}
private String buildItemCacheKey(Long itemId) {
return GOOD_CACHE_KEY + itemId;
}
}
缓存常见问题
缓存穿透
客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库
解决方案:
- 💚缓存空对象
- 实现简单,维护方便
- 额外的内存消耗
- 可能造成短期的内存不一致
- 使用布隆过滤器(基于hash函数和byte数组实现)
- 内存占用少,没有多余的key
- 实现复杂
- 存在误判可能
- 增加id的复杂度
- 做好数据的基础格式的校验
- 加强用户权限校验
缓存雪崩
同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大的压力
- 给不同的key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然消失了,无数请求访问会在瞬间给数据库带来巨大的冲击
解决方案:
-
互斥锁
-
逻辑过期
| 优点 | 缺点 | |
|---|---|---|
| 互斥锁 | - 没有额外的内存消耗 |
- 保证一致性
- 实现简单 | - 线程需要等待,性能受影响
- 可能有死锁风险 | | 逻辑过期 | - 线程无需等待 | - 不保证一致
- 有额外的内存消耗
- 实现复杂 |
互斥锁实现代码
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 先查缓存
String stringJson = stringRedisTemplate.opsForValue().get(key);
// 存在就直接返回
if (StrUtil.isNotBlank(stringJson)) {
Shop shop = JSONUtil.toBean(stringJson, Shop.class);
return shop;
}
// ""表示缓存和数据库都不存在,null表示数据库中存在,但缓存中不存在
if (stringJson != null) {
return null;
}
// 加上锁, 保证在缓存击穿的时候,只有少量的线程可以访问到数据库
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean lock = tryLock(lockKey);
// 如果失败就要睡眠并重试
if (!lock) {
Thread.sleep(50);
// 重试
return queryWithMutex(id);
}
// 没有就查找数据库
shop = getById(id);
if (shop == null) {
// 没有就存入空串
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 有就存入redis并返回
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(key, CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unLock(lockKey);
}
return shop;
}
/**
* 利用redis实现互斥锁
*
* @param key
* @return
*/
public boolean tryLock(String key) {
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(aBoolean);
}
/**
* 释放锁
*
* @param key
*/
public void unLock(String key) {
stringRedisTemplate.delete(key);
}
逻辑过期代码
/**
* 线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 使用逻辑失效进行查询
*
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 先查缓存
String stringJson = stringRedisTemplate.opsForValue().get(key);
// 不存在就直接返回null
if (StrUtil.isBlank(stringJson)) {
return null;
}
// 存在,获取RedisData对象,判断是否在有效期
RedisData redisData = JSONUtil.toBean(stringJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
//获取shop对象
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 如果在有效期内
if (expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
// 不在有效期,获取锁重新加载
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 已经获得了锁
if (isLock) {
// 另起线程重建逻辑
CACHE_REBUILD_EXECUTOR.submit(()-> {
try {
saveShopToRedis(id, 20L);
} catch (Exception e) {
unLock(lockKey);
}
});
}
// 失败成功也好都返回旧的信息
return shop;
}
/**
* 实现逻辑过期
*
* @param id
* @param seconds
*/
private void saveShopToRedis(Long id, Long seconds) {
// 从数据库查询到shop
Shop shop = getById(id);
// 设置预热数据
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(seconds));
// 存入redis,这里存入
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
缓存设计的一般性原则
- 热点数据一律进缓存;
- 缓存场景一律采取本地缓存+分布式缓存的综合方案;
- 优先读取本地缓存,以本地缓存为主,远端分布式缓存为辅;
- 所有缓存设置过期时间,本地缓存过期时间控制在秒级;
- 本地缓存务必同时设置容量驱逐和时间驱逐两种方式;
- 缓存KEY具有业务可读性,杜绝不同场景出现相同KEY;
- 缓存列表数据时,仅缓存第一页,缓存数量不超过20;
- 杜绝并发更新缓存,防止缓存击穿;
- 空数据进缓存,防止缓存穿透;
- 读数据时,先读缓存,再读数据库;
- 写数据时,先写数据库,再写缓存;