小Cの已经记不起来的博客

Redis 缓存穿透、雪崩与 幂等设计

前阵子接手了一个活动查询接口,上线第一天风平浪静,第二天早上十点活动正式开始,数据库连接池直接被打满了,告警在群里炸个不停。看了一眼日志,全是同一个查询在疯狂打 DB。排查下来发现是活动详情的缓存正好在那个时间点集体过期,几千个请求同时穿透到数据库,这玩意儿就是典型的缓存雪崩。顺带还发现有人拿着不存在的 id=-1 在刷接口,缓存里永远查不到,每次都落到数据库,这又是缓存穿透。再加上报名接口被用户连点导致重复提交,一天下来算是把缓存和幂等的坑全踩了一遍。

下面把这几个问题的处理方式记录一下,都是实测可用的,代码以 Spring Boot + Redis 为例,用别的框架思路也一样。

缓存穿透:查不到的数据也要拦住

穿透的本质是请求的数据在缓存和数据库里都不存在,每次请求都直接打到数据库。正常用户偶尔点一下没什么,但遇到恶意扫描,QPS 一高数据库是真的扛不住。

最简单有效的办法是把空结果也缓存起来,查不到就往 Redis 里写一个短过期时间的占位值:

public Activity getActivity(Long id) {
    String key = "activity:" + id;
    String cache = redisTemplate.opsForValue().get(key);
    // 命中占位值,直接返回空
    if ("NULL".equals(cache)) {
        return null;
    }
    if (cache != null) {
        return JSON.parseObject(cache, Activity.class);
    }
    Activity activity = activityMapper.selectById(id);
    if (activity == null) {
        // 空值缓存 60 秒,别设太长,不然真数据加进来后要等一分钟才可见
        redisTemplate.opsForValue().set(key, "NULL", 60, TimeUnit.SECONDS);
        return null;
    }
    redisTemplate.opsForValue().set(key, JSON.toJSONString(activity), 30, TimeUnit.MINUTES);
    return activity;
}

空值缓存能解决大部分问题,但如果攻击者每次都用不同的随机 id,这个方案就有点浪费内存了。这时候可以上布隆过滤器,启动的时候把所有存在的 id 塞进去,请求先过过滤器,不存在的直接拒掉:

// 项目启动时预热
RBloomFilter<Long> bloomFilter = redisson.getBloomFilter("activity:bloom");
bloomFilter.tryInit(1000000L, 0.01);
activityMapper.selectAllIds().forEach(bloomFilter::add);

// 请求入口处判断
if (!bloomFilter.contains(id)) {
    return null;
}

布隆过滤器有个小坑,就是它只能加不能删,数据下线了过滤器里还在,会误放行。不过误放行只是多查一次库,问题不大。要是数据频繁增删,就定期重建一个,或者干脆只用空值缓存兜底。

缓存雪崩:别让 key 集体过期

雪崩一般有两种情况:一是大量 key 设置了相同的过期时间,到点集体失效;二是 Redis 本身挂了,所有请求落到数据库。

第一种好办,给过期时间加个随机偏移就行:

int ttl = 30 * 60 + ThreadLocalRandom.current().nextInt(300);
redisTemplate.opsForValue().set(key, json, ttl, TimeUnit.SECONDS);

这样 30 分钟上下浮动 5 分钟,基本不会出现集体过期。

第二种就是要在数据库前加一层保护,常见做法是互斥锁,只放一个请求去回源,其他请求稍等重试:

String lockKey = "lock:" + key;
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
    if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {
        try {
            // 双重检查,防止拿锁前别人已经写好了
            value = redisTemplate.opsForValue().get(key);
            if (value == null) {
                Activity activity = activityMapper.selectById(id);
                value = activity == null ? "NULL" : JSON.toJSONString(activity);
                redisTemplate.opsForValue().set(key, value, randomTtl(), TimeUnit.SECONDS);
            }
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        Thread.sleep(50);
        return getActivity(id);
    }
}

另外提醒一句,Redis 记得配持久化加主从,不然机器一重启缓存全没了,那才是真的雪崩,前面写的这些代码一个都救不了你。

幂等设计:用户的手速比你想象的快

活动接口还有个问题是重复提交,用户抢名额的时候疯狂连点,前端按钮置灰根本拦不住,抓包重放更是随便来。同一个人提交了五次,数据库要是没约束,就真给他发五个名额了。

我的做法是三层防护,实测比较稳:

第一层,前端按钮点击后置灰,这个只是体验优化,别指望它真的防重。

第二层,用 Redis 的 setIfAbsent 做提交锁:

String idempotentKey = "submit:" + userId + ":" + activityId;
Boolean ok = redisTemplate.opsForValue().setIfAbsent(idempotentKey, "1", 10, TimeUnit.SECONDS);
if (ok == null || !ok) {
    throw new BizException("请勿重复提交");
}

第三层,数据库加唯一索引,这才是最后的底线:

ALTER TABLE activity_order ADD UNIQUE KEY uk_user_activity (user_id, activity_id);

为什么有了 Redis 锁还要唯一索引?因为 Redis 锁有可能失效,比如刚 set 完服务就重启了、或者过期时间设短了请求还没处理完锁先过期了,这时候没有唯一索引兜底就会出脏数据。反过来,只有唯一索引也不行,用户点第二下会收到一个数据库异常,报错信息很难看,所以两层都得有。

插入的时候记得捕获 DuplicateKeyException,把它当成正常情况返回“已报名”就行,别当成系统错误抛给用户。

写在最后

这几个问题单独看都不复杂,但都是不出了事就想不起来的那种。缓存这块记住三句话:查不到的缓存空值,过期的加随机抖动,回源的加互斥锁;幂等这块记住:Redis 拦正常手速,数据库唯一索引拦一切。至于布隆过滤器的容量调优和分布式锁的各种高级玩法,我目前的量级还用不上,等哪天真用上了再来更新吧...

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放