秒杀场景下缓存的设计

804 阅读8分钟

背景

秒杀品的数据在秒杀过程中,是大流量的主要集中地,其QPS会高于其他相关接口,配套的缓存方案不可或缺。

总体原则

采用混合缓存本地缓存 + 分布式缓存

以本地缓存为主,分布式缓存为辅,本地缓存是第一道防线,分布式缓存是第二道防线;

单机的混合缓存

客户端请求先打到本地缓存上,如果未命中或版本号不合法,则访问分布式缓存去获取最新的值。这里有几个需要注意的地方:

  1. 在进行本地缓存更新时,要进行加锁处理,保证只有一个本地线程去更新本地缓存,避免多次更新影响性能
  2. 在更新分布式缓存时,要先获取分布式锁,再进行更新,避免缓存击穿
  3. 如果在数据库获取的值为空,也要存入缓存,避免缓存穿透;
  4. 如果获取分布式锁失败,不要进行重复尝试获取锁,直接返回客户端,显示稍后再试即可;

集群的混合缓存

每一台服务器都拥有自己的本地缓存,当本地缓存失效时,才尝试获取分布式锁访问分布式缓存,获取最新的数据。

应当尽量使用本地缓存来应对流量洪峰,访问远程的分布式缓存会存在网络IO等的时间损耗。

技术实现

本地缓存

优势

  1. 没有远程IO,性能优越;
  2. 有利于服务的横向扩展,因为大部分请求都是打到单机缓存上;

技术实现

本地缓存使用 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;
    }
}
  1. exist字段表示数据库中的数据是否存在,而不是表示缓存是否存在;因为数据库不存在时也要向缓存放入空值,防止缓存穿透
  2. 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;
    }
}

缓存常见问题

缓存穿透

客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库

解决方案:

  1. 💚缓存空对象
  • 实现简单,维护方便
  • 额外的内存消耗
  • 可能造成短期的内存不一致
  1. 使用布隆过滤器(基于hash函数和byte数组实现)
  • 内存占用少,没有多余的key
  • 实现复杂
  • 存在误判可能
  1. 增加id的复杂度
  2. 做好数据的基础格式的校验
  3. 加强用户权限校验

缓存雪崩

同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大的压力

  1. 给不同的key的TTL添加随机值
  2. 利用Redis集群提高服务的可用性
  3. 给缓存业务添加降级限流策略
  4. 给业务添加多级缓存

缓存击穿

缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然消失了,无数请求访问会在瞬间给数据库带来巨大的冲击

解决方案:

  1. 互斥锁

  2. 逻辑过期

优点缺点
互斥锁- 没有额外的内存消耗
  • 保证一致性
  • 实现简单 | - 线程需要等待,性能受影响
  • 可能有死锁风险 | | 逻辑过期 | - 线程无需等待 | - 不保证一致
  • 有额外的内存消耗
  • 实现复杂 |

互斥锁实现代码

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));
    }

缓存设计的一般性原则

  1. 热点数据一律进缓存
  2. 缓存场景一律采取本地缓存+分布式缓存的综合方案;
  3. 优先读取本地缓存,以本地缓存为主,远端分布式缓存为辅
  4. 所有缓存设置过期时间,本地缓存过期时间控制在秒级;
  5. 本地缓存务必同时设置容量驱逐时间驱逐两种方式;
  6. 缓存KEY具有业务可读性,杜绝不同场景出现相同KEY
  7. 缓存列表数据时,仅缓存第一页,缓存数量不超过20;
  8. 杜绝并发更新缓存,防止缓存击穿;
  9. 空数据进缓存,防止缓存穿透;
  10. 读数据时,先读缓存,再读数据库;
  11. 写数据时,先写数据库,再写缓存;