记一次 Redis 上线事故


Redis 作为最被广泛使用的缓存中间件之一,虽然市面上大量开源的 Redis 开源客户端提供的 API 是非常简洁的,但如果我们需要将它用得游刃有余,使其真正高效地服务于我们的应用服务,那么我们就需要深入理解 Redis 的存储结构和运行机制。最近在公司项目上线过程中遇到了一次 Redis 相关的上线事故,故记录下来,作为一种经验的总结,同时也希望加深自己对于 Redis 的理解和思考。

现象


在服务发布上线时,发现线上 Redis 实例的 CPU 负载频繁飙升至 100%,导致 Redis 主线程被长时间阻塞,严重影响其他命令的执行。

Redis 服务监控

此时应用服务处于刚刚启动的阶段,并没有大批量的业务操作被执行,所以基本可以分析是应用服务在启动阶段对 Redis 的操作造成了该问题。我们知道 Redis 采用的是 IO 多路复用的机制,虽然在 Redis 6.0 版本后支持采用多线程方式与客户端建立连接,但是执行命令的主线程依然是单线程串行执行。因此我们需要重点关注针对 Redis 的耗时操作,尤其是时间复杂度为 O(N) 级别的操作,应该注意尽量规避,否则长时间阻塞 Redis 主线程往往会给业务带来致命性的问题。

定位


对于项目启动阶段可能存在的 Redis 耗时命令,我立刻想到了前不久自己的写的一段代码。为了缓存使用的便利性,我在项目中集成了 Spring Cache Redis 作为缓存方案,同时出于业务上的需要,必须在项目上线时清除 Redis 中的部分缓存,为了避免手动执行脚本清理,因此在项目中编写了项目启动阶段自动清理部分缓存的逻辑。

/**
 * 项目启动后缓存自动清理
 * @author Ethan Zhang
 */
@Slf4j
public class CacheCleaner implements ApplicationListener<ContextRefreshedEvent> {

    private static final List<String> CACHES_NEED_TO_CLEAN = Arrays.asList(
            EnterpriseDetailCache.CACHE_NAME,
            GoodsCategoryCache.CACHE_NAME,
            GoodsLabelCache.CACHE_NAME,
            RegionCache.CACHE_NAME,
            ServerCategoryCache.CACHE_NAME,
            ServerTypeCache.CACHE_NAME,
            WalletTrackTypeCache.CACHE_NAME
    );

    @Resource
    private RedisTemplate<Object, Object> redisTemplate;

    @Async
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        CACHES_NEED_TO_CLEAN.stream().map(cacheName -> CachePrefixConstant.COMMON_CACHE_PREFIX + cacheName + ":*").forEach(this::cleanWithPattern);
    }

    public void cleanWithPattern(String pattern) {
        redisTemplate.execute((RedisCallback<Object>) connection -> {
            try {
                Cursor<byte[]> cursor = connection.scan(ScanOptions.scanOptions().count(1 << 10).match(pattern).build());
                cursor.forEachRemaining(connection::del);
            } catch (Exception e) {
                log.error("clean cache error", e);
            } finally {
                connection.close();
            }
            return null;
        });
    }

}

可以看到这段代码的主要逻辑是监听 Spring 上下文中的 ContextRefreshedEvent 生命周期事件,回调执行清除缓存。针对项目中需要清理的缓存,循环执行 scan 命令扫描到所匹配的 KEY 并进行删除操作。一般来讲使用 scan 命令替代 keys 命令进行 KEY 模糊匹配是为了避免长时间阻塞 Redis 主线程,初期我这里也是出于这样的考虑。按照常规的理解,scan 命令是类型迭代器的原理,可以有效的避免一次性避免扫描 Redis 全库,并不会长时间占用 Redis 的线程资源,那么问题究竟出在哪里呢?

分析


经过一番资料查询,我发现根本问题还是在于对于 scan 命令的理解。scan 命令的设计类似于 iterator 迭代器模式,也可以看成是一个 cursor 游标。

截图自 Redis 官方文档

从官方文档的描述中我们可以看到 scan 使用一个游标值指定开始扫描的位置,本次扫描完成后,会返回当前游标停留的位置,同时这个位置也应该作为下一次扫描开始的位置,如果返回的游标位置为 0,表示已经完成全量扫描,这是一个典型的迭代器设计。

截图自 Redis 官方文档

那么 scan 命令单次迭代时会扫描多少 KEY 呢?事实上 Redis 并不保证这个数字的精确性。针对一些 size 足够小的 KEY,例如针对一些足够小的 SETSORTED SETHASH,可能一次迭代就能返回其中所有的元素,当然我们可以通过 count 参数对扫描数量进行一定程度的调节。

截图自 Redis 官方文档

需要注意的是 Redis 也并不保证单次迭代返回的元素数量与 count 参数值相等,我们可以认为 count 值是单次迭代返回的最大元素数量。

整体来讲,虽然 scan 命令并不像 keys 那样对 Redis 进行一次性的全量遍历,但其时间复杂度依然是 O(N) 级别,如果 Redis 中的 KEY 达到一定数量级,而我们又将 count 参数设置较大时,单次的 scan 执行时间依然是较长的。在我面临的场景中,线上 KEY 量达到了百万级,同时我将 count 参数设置为 1024scan 命令单次迭代的时间应该是较长的,再加上我在项目启动阶段循环多次执行该命令,RedisCPU 在短时间内被占满也就不足为奇。因此在这个业务场景下如果我需要清理缓存,使用 scan 命令去模糊匹配恐怕并不是一个明智的选择。

解决


针对在项目启动阶段清理 KEY 的需求,如果要避免全量扫描 Redis 库,我认为最理想的状态还是精确匹配删除 KEY,这样的话可以利用 Redis 本身的 hash 索引。但前提是在缓存写入阶段,我们必须将需要清理的 KEY 记录下来。Spring CacheSpring Framework 提供的一个缓存抽象框架,它本身并不提供这样的支持,因此我考虑对其进行一定的扩展。

缓存 KEY 收集

首先我定义了一个缓存收集类 SpringCacheKeyCollector,提供 collect 方法对缓存 KEY 进行收集。我选择将 KEY 存储到 RedisSET 结构中,同时在项目启动阶段从 SET 中获取所有的 KEY 进行批量删除。需要说明的是此处我面临的业务场景 KEY 数量级并不会太大,因此我直接使用 smembersSET 全量返回,如果 KEY 数量级较大,建议使用 sscan 对其进行迭代操作。

/**
 * Spring Cache Key 收集,项目启动时清理
 * @author Ethan Zhang
 */
@Slf4j
public class SpringCacheKeyCollector implements ApplicationRunner {

    private static final String KEY = CachePrefixConstant.COMMON_CACHE_PREFIX + "spring-cache-key-collector:";

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public void collect(String cacheKey) {
        stringRedisTemplate.opsForSet().add(KEY, cacheKey);
    }

    public void clean() {
        Set<String> members = stringRedisTemplate.opsForSet().members(KEY);
        if (CollectionUtils.isNotEmpty(members)) {
            log.info("clean spring cache keys : {}", String.join(", ", members));
            stringRedisTemplate.delete(members);
        }
        stringRedisTemplate.delete(KEY);
    }

    @Async
    @Override
    public void run(ApplicationArguments args) {
        clean();
    }

}

自定义 RedisCache

RedisCacheSpring Cache Redis 提供的操作 Redis 的缓存抽象,如果需要在对 Redis 的缓存操作中嵌入自定义逻辑,那么我需要对其扩展。

/**
 * Spring Cache Key 自动收集
 * @see SpringCacheKeyCollector
 * @author Ethan Zhang
 */
@Slf4j
public class AutoCleanableRedisCache extends RedisCache {

    public AutoCleanableRedisCache(String name, byte[] prefix, RedisOperations<?, ?> redisOperations, long expiration) {
        super(name, prefix, redisOperations, expiration);
    }

    @Override
    public void put(RedisCacheElement element) {
        super.put(element);
        collectCacheKey(element.getKeyBytes());
    }

    @Override
    public ValueWrapper putIfAbsent(RedisCacheElement element) {
        ValueWrapper valueWrapper = super.putIfAbsent(element);
        collectCacheKey(element.getKeyBytes());
        return valueWrapper;
    }

    private void collectCacheKey(byte[] cacheKey) {
        try {
            SpringCacheKeyCollector springCacheKeyCollector = CommonSpringContextUtil.getBean(SpringCacheKeyCollector.class);
            springCacheKeyCollector.collect(new String(cacheKey, StandardCharsets.UTF_8));
        } catch (Exception e) {
            log.error("collect cache key error", e);
        }
    }

}

我的做法是覆盖 RedisCache 中针对 KEYput 类型操作,在缓存写入完成后执行先前我所定义的 SpringCacheKeyCollector#collect 操作,将 KEY 收集起来。

配置 Spring Cache Redis

在自定义 RedisCache 后,如果要使其生效,还需要覆盖 Spring Cache Redis 的默认配置。

public class AutoCleanableRedisCacheManager extends RedisCacheManager {

    public AutoCleanableRedisCacheManager(RedisOperations redisOperations) {
        super(redisOperations);
    }

    @Override
    protected AutoCleanableRedisCache createCache(String cacheName) {
        long expiration = computeExpiration(cacheName);
        return new AutoCleanableRedisCache(cacheName, (isUsePrefix() ? getCachePrefix().prefix(cacheName) : null), getRedisOperations(), expiration);
    }

}

自定义 RedisCacheManager 缓存管理类,并重写其中的 createCache 方法,用于创建我先前自定义的 RedisCache 缓存抽象实例,并将 RedisCacheManager 依据 Spring Cache 的规范装配到 IOC 容器中。

/**
 * Spring Cache 缓存配置
 * @see DemoCache 缓存示例
 * @author Ethan Zhang
 */
@EnableCaching
@Configuration
public class SpringCacheAutoConfiguration extends CachingConfigurerSupport {

    /**
     * 分布式缓存方案 Redis
     * @see RedisCacheManager
     */
    @AutoConfigureAfter(RedisAutoConfiguration.class)
    @Configuration
    static class CustomRedisCacheConfiguration {

        private final StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        private final GenericToStringSerializer<Object> genericToStringSerializer = new GenericToStringSerializer<>(Object.class);

        @Bean(REDIS_CACHE_MANAGER)
        public RedisCacheManager redisCacheManager(RedisTemplate<Object, Object> redisTemplate) {
            AutoCleanableRedisCacheManager redisCacheManager = new AutoCleanableRedisCacheManager(redisTemplate);
            redisCacheManager.setUsePrefix(true);
            redisCacheManager.setDefaultExpiration(Duration.ofDays(1).getSeconds());
            // 缓存过期时间自定义,默认缓存 1 天
            Map<String, Long> expires = new HashMap<>();
            expires.put("demo", Duration.ofHours(1).getSeconds());
            redisCacheManager.setExpires(expires);
            redisCacheManager.setCachePrefix(cacheName -> stringRedisSerializer.serialize(COMMON_CACHE_PREFIX + cacheName + ":"));
            return redisCacheManager;
        }

        @Bean
        public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
            RedisTemplate<Object, Object> template = new RedisTemplate<>();
            template.setConnectionFactory(redisConnectionFactory);
            template.setDefaultSerializer(new GenericFastJsonRedisSerializer());
            template.setKeySerializer(genericToStringSerializer);
            template.setHashKeySerializer(genericToStringSerializer);
            template.setEnableTransactionSupport(false);
            return template;
        }

    }

}

此处不对 Spring Cache 的使用进行过多阐述,有兴趣可以参考我的这篇文章 Spring Cache 缓存抽象,这里只针对扩展的思路进行分享。


文章作者: Ethan Zhang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Ethan Zhang !
评论
 上一篇
Spring 事务抽象带来的思考 Spring 事务抽象带来的思考
一般来说,在一个复杂系统中,我们会集成很多的 data access frameworks,也就是所谓的数据持久层框架,例如通过 Hibernate,MyBatis 操作 Mysql 这样的关系型数据库,亦或是通过 Jedis,Redis
2020-12-31 Ethan Zhang
下一篇 
Spring Security(二)权限管理 Spring Security(二)权限管理
在另一篇文章 Spring Security(一)身份认证 中我们讲到了 Spring Security 的 Authentication 身份认证过程,当一个用户的身份被系统识别,我们会给这个用户赋予一定的权限,这些权限决定用户能够访问
2020-12-05 Ethan Zhang