解决Redis缓存数据类型丢失问题

简介: 解决Redis缓存数据类型丢失问题

一、背景

在通用的数据开放平台中,支持用户编写基于Groovy脚本的接口,Groovy脚本中可以查询数据库,然后对数据库中的数据进行一些处理。平台支持任何接口都可以启用缓存。

缓存不是缓存整个脚本的结果,而是只支持缓存数据库查询语句的结果,这样Groovy脚本中的其他逻辑依然可以处理数据。之前一直运行良好没有问题,最近数据接口开发人员反馈,某个新上的接口一旦开启缓存就会报错,结果沟通与排查发现, 是因为在缓存数据库查询结果到Redis中的时候,丢失了数据类型,导致后续的Groovy处理逻辑出现异常。

二、问题复现

首先来看这个Groovy查询脚本。这个脚本查询数据库中的数据,然后针对数据中的日期时间字段进行需要的处理,然后返回数据。

def result_list = db.selectList('''
    select 
        id, title, url, publish_time
    from t1
    order by publish_time desc limit 50
''')

def date = (new Date().time / 1000).longValue()
for (data in result_list) {
   
    def publishTime = (data.publishTime.time / 1000).longValue()
    def diff_time = Math.round((date - publishTime) / 60)
    if (diff_time < 60) {
   
        data.put("diffTime", String.valueOf(diff_time) + '分钟前')
    } else if (diff_time >= 60 && diff_time < 1440) {
   
        data.put("diffTime", String.valueOf(Math.round(diff_time / 60)) + '小时前')
    } else {
   
        data.put("diffTime", data.publishTime)
    }
}
return result_list

在SQL的查询结果中,包含了publish_time 字段,该字段在StarRocks中的类型为DATETIME 类型,db.selectList 方法的实现中,是直接将数据库返回的结果,包装为一个List>进行返回,这时候我们在结果中会发现 publish_time 字段的类型为Date,因此开发同学直接在下面的处理逻辑中使用了 data.publishTime.time 方法来获取时间戳,这样如果从数据库中获取数据都是没有问题。

由于平台本身逻辑为开发阶段即使开启缓存也不会走缓存,因此在开发阶段,没有发现这个错误,接口上线发布之后,并且开启缓存之后,发现接口异常,报错信息显示:属性丢失,Long类型没有time属性

三、问题分析

接口在开启缓存之后出现问题,也就是表示缓存中的数据存在问题,平台设计为既支持本地缓存也支持Redis缓存,测试之后发现,都存在问题,因此对缓存模块代码进行检查。

缓存采用AOP实现,通过拦截自定义注解来对数据库的查询结果进行缓存,简化后的缓存代码如下:

@Around("dataCacheLayerPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
   
    CacheParams cacheParams = parseCacheParams(apiInfoContent);
    Boolean isLocalTest = apiInfoContent.getIsLocalTest();
    String cacheKey = null;
    // 不是开发环境 并且 启用缓存
    if(!isLocalTest && cacheParams.getEnableCache()){
   
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        //参数名数组
        String[] parameters =  methodSignature.getParameterNames();
        //参数值
        Object[] args = joinPoint.getArgs();
        DataCacheLayer dataCacheLayer = methodSignature.getMethod()
                                .getDeclaredAnnotation(DataCacheLayer.class);
        DataCacheType dataCacheType = dataCacheLayer.dataCacheType();
        cacheKey = generalCacheKey(dataCacheType, parameters, args);
        // 查询缓存
        Object cacheData = queryCache(cacheKey);
        if(cacheData != null){
   
            return JSONArray.parseArray(cacheData.toString(), Map.class);
        }
    }
    // 走原始逻辑
    Object proceed = joinPoint.proceed();
    // 设置缓存
    if(!isLocalTest && cacheParams.getEnableCache()){
   
        addCache(cacheKey, proceed);
    }
    return proceed;
}

从这个代码来看,我们发现从缓存中拿出来的数据是字符串,然后将其转换为了List, 这时候我们就发现,从字符串转换过来的数据,其数据类型发生了变化,publishTime 字段的类型从Date转变为了 Long,我们这时候继续查看具体的缓存代码,这里只看Redis缓存的实现。

@Override
public void set(String cacheKey, Object value, Long cacheTime) {
   
    try {
   
        String jsonString = JSON.toJSONString(value);
        redisUtil.set(buildApiInfoKey(cacheKey), jsonString, cacheTime);
    }catch (Exception e){
   
        LOGGER.error("写缓存失败",e);
    }
}

@Override
public String get(String cacheKey) {
   
    return redisUtil.get(buildApiInfoKey(cacheKey));
}

可以看到我们将一个对象转换为了一个json字符串,将其存储到了Redis中,然后在读取的时候也是读取到了一个字符串。分析到这里,我们认为这是由于json字符串转换导致,因此我们想到,如果我们在Redis中不存储转换之后的字符串而是直接存储对象,是否就能够解决这个问题。

经过实践之后发现,这样并不能解决问题,因为数据在存储到Redis中的时候,会经过Redis的数据序列化策略。默认的数据序列化策略,是将数据序列化为json进行存储,这时候Date类型的字段经过序列化和反序列化之后就变成了Long类型的时间戳。

经过分析和测试,明确了问题出现的根本原因,是因为Redis的数据序列化和反序列导致的;为了保持平台的灵活性和通用性,决定对平台缓存逻辑进行优化。

四、问题解决

经过调研,可以自定义Redis的序列化和反序列化逻辑。因此如果我们在数据存储进行Redis的时候保持其类型,那么在将数据读取出来之后,就可以根据类型进行恢复,这样就不会发现数据类型丢失。

经过分析之后,发现目前只有Date类型的存在问题,因此我们目前只考虑了Date类型的丢失问题,具体的解决方案为:

  1. 在将数据写入Redis的时候,也就是序列化阶段,判断数据是否为Date类型,如果是,将其值转换为特定的字符串数据格式:yyyy-MM-dd HH:mm:ss[type:date] ,然后将其写入Redis。
  2. 在从Redis读取数据的时候,也就是发序列化阶段,判断数据的值是否以[type:date]结尾,如果是将其转换为Date 类型的值。

自定义的序列化器如下:

public class ComplexTypeRedisSerializer implements RedisSerializer<Object> {
   

    // 使用 FastDateFormat,它是线程安全的
    private static final FastDateFormat fastDateFormat = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");


    @Override
    public byte[] serialize(Object value) throws SerializationException {
   
        if (value == null) {
   
            return new byte[0];
        }
        // 对所有 Map 和 List 类型的数据,遍历处理其中的 Date 字段并嵌入类型信息
        processDates(value);
        // 使用 fastjson 将对象序列化为 JSON 字符串
        String jsonString = JSON.toJSONString(value, SerializerFeature.WRITE_MAP_NULL_FEATURES, SerializerFeature.QuoteFieldNames);
        return jsonString.getBytes(StandardCharsets.UTF_8);
    }

    @Override
    public Object deserialize(byte[] bytes) throws SerializationException {
   
        if (bytes == null || bytes.length == 0) {
   
            return null;
        }
        // 将字节数组转换为 JSON 字符串
        String jsonString = new String(bytes, StandardCharsets.UTF_8);
        // 反序列化 JSON 字符串为 Map 或其他对象
        Object jsonObject = JSON.parse(jsonString);
        recursiveParse(jsonObject);
        // 根据字段的类型信息来还原字段类型
        return jsonObject;
    }


    // 递归解析 JSON 对象
    private  void recursiveParse(Object object) {
   
        if (object instanceof JSONObject) {
   
            JSONObject jsonObject = (JSONObject) object;
            for (Map.Entry<String, Object> entry : jsonObject.entrySet()) {
   
                // 递归调用解析每一个字段
                Object value = entry.getValue();
                if(value != null && value.toString().endsWith("[type:date]")){
   
                    jsonObject.put(entry.getKey(), getDate(value.toString()));
                }else{
   
                    recursiveParse(value);
                }
            }
        } else if (object instanceof JSONArray) {
   
            JSONArray jsonArray = (JSONArray) object;
            for (Object o : jsonArray) {
   
                // 递归调用解析每个数组元素
                if(o != null && o.toString().endsWith("[type:date]")){
   
                    jsonArray.add(getDate(o.toString()));
                }else{
   
                    recursiveParse(o);
                }

            }
        }
    }


    private Object getDate(String value) {
   
        if(value.endsWith("[type:date]")){
   
            String dateString = value.substring(0, value.indexOf("[type:date]"));
            try {
   
               return Date.from(LocalDateTime.parse(dateString, formatter).atZone(ZoneId.systemDefault()).toInstant());
            }catch (Exception e){
   
                return dateString;
            }
        }
        return value;
    }

    /**
     *  递归处理Map或者List<Map>中的Date类型
     * @param object 对象
     */
    private  void processDates(Object object) {
   
        if(object instanceof PageList){
   
            object = ((PageList) object).getDataList();
        }
        if (object instanceof Map) {
   
            // 如果是 Map 类型,递归处理每个条目
            Map<Object, Object> map = (Map<Object, Object>) object;
            for (Map.Entry<?, ?> entry : map.entrySet()) {
   
                // 如果值是 Date 类型,更新为格式化字符串
                if (entry.getValue() instanceof Date) {
   
                    map.put(entry.getKey(), fastDateFormat.format(entry.getValue()) + "[type:date]");
                }else{
   
                    // 递归更新每个条目的值
                    processDates(entry.getValue());
                }
            }
        } else if (object instanceof List) {
   
            // 如果是 List 类型,递归处理每个元素
            List<Object> list = (List<Object>) object;
            for (int i = 0; i < list.size(); i++) {
   
                Object item = list.get(i);
                // 如果元素是 Date 类型,修改它
                if (item instanceof Date) {
   
                    list.set(i, fastDateFormat.format(item) + "[type:date]");
                }else{
   
                    // 递归更新列表中的元素
                    processDates(item);
                }
            }
        }
    }
}

然后将自定义的序列化器设置给Redis的value的序列化策略。

public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
   
    RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    // 设置value的序列化规则和 key的序列化规则
    redisTemplate.setValueSerializer(new ComplexTypeRedisSerializer());
    // 使用自定义的序列化器
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.afterPropertiesSet();
    return redisTemplate;
}

五、存在待优化的地方

目前整个判断是否包含Date类型的数据的逻辑比较粗暴,直接通过遍历的方式,后续可以优化,在其中加入特殊标识来标识是存在,提示数据处理速度。

相关文章
|
5天前
|
缓存 监控 NoSQL
Redis经典问题:缓存穿透
本文详细探讨了分布式系统和缓存应用中的经典问题——缓存穿透。缓存穿透是指用户请求的数据在缓存和数据库中都不存在,导致大量请求直接落到数据库上,可能引发数据库崩溃或性能下降。文章介绍了几种有效的解决方案,包括接口层增加校验、缓存空值、使用布隆过滤器、优化数据库查询以及加强监控报警机制。通过这些方法,可以有效缓解缓存穿透对系统的影响,提升系统的稳定性和性能。
|
1月前
|
缓存 NoSQL 关系型数据库
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
本文详解缓存雪崩、缓存穿透、缓存并发及缓存预热等问题,提供高可用解决方案,帮助你在大厂面试和实际工作中应对这些常见并发场景。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
|
1月前
|
存储 缓存 NoSQL
【赵渝强老师】基于Redis的旁路缓存架构
本文介绍了引入缓存后的系统架构,通过缓存可以提升访问性能、降低网络拥堵、减轻服务负载和增强可扩展性。文中提供了相关图片和视频讲解,并讨论了数据库读写分离、分库分表等方法来减轻数据库压力。同时,文章也指出了缓存可能带来的复杂度增加、成本提高和数据一致性问题。
【赵渝强老师】基于Redis的旁路缓存架构
|
23天前
|
存储 NoSQL Redis
redis常见数据类型
Redis 是一种基于内存的键值存储数据库,支持字符串、哈希表、列表、集合及有序集合等多种数据类型,每种类型均有特定用途与适用场景,提供丰富的命令操作,适用于高速数据访问与处理。
37 5
|
1月前
|
缓存 NoSQL PHP
Redis作为PHP缓存解决方案的优势、实现方式及注意事项。Redis凭借其高性能、丰富的数据结构、数据持久化和分布式支持等特点,在提升应用响应速度和处理能力方面表现突出
本文深入探讨了Redis作为PHP缓存解决方案的优势、实现方式及注意事项。Redis凭借其高性能、丰富的数据结构、数据持久化和分布式支持等特点,在提升应用响应速度和处理能力方面表现突出。文章还介绍了Redis在页面缓存、数据缓存和会话缓存等应用场景中的使用,并强调了缓存数据一致性、过期时间设置、容量控制和安全问题的重要性。
43 5
|
1月前
|
存储 消息中间件 NoSQL
使用Java操作Redis数据类型的详解指南
通过使用Jedis库,可以在Java中方便地操作Redis的各种数据类型。本文详细介绍了字符串、哈希、列表、集合和有序集合的基本操作及其对应的Java实现。这些示例展示了如何使用Java与Redis进行交互,为开发高效的Redis客户端应用程序提供了基础。希望本文的指南能帮助您更好地理解和使用Redis,提升应用程序的性能和可靠性。
42 1
|
1月前
|
缓存 NoSQL 中间件
redis高并发缓存中间件总结!
本文档详细介绍了高并发缓存中间件Redis的原理、高级操作及其在电商架构中的应用。通过阿里云的角度,分析了Redis与架构的关系,并展示了无Redis和使用Redis缓存的架构图。文档还涵盖了Redis的基本特性、应用场景、安装部署步骤、配置文件详解、启动和关闭方法、systemctl管理脚本的生成以及日志警告处理等内容。适合初学者和有一定经验的技术人员参考学习。
213 7
|
2月前
|
存储 缓存 NoSQL
数据的存储--Redis缓存存储(一)
数据的存储--Redis缓存存储(一)
107 1
|
2月前
|
存储 缓存 NoSQL
数据的存储--Redis缓存存储(二)
数据的存储--Redis缓存存储(二)
53 2
数据的存储--Redis缓存存储(二)
|
2月前
|
消息中间件 缓存 NoSQL
Redis 是一个高性能的键值对存储系统,常用于缓存、消息队列和会话管理等场景。
【10月更文挑战第4天】Redis 是一个高性能的键值对存储系统,常用于缓存、消息队列和会话管理等场景。随着数据增长,有时需要将 Redis 数据导出以进行分析、备份或迁移。本文详细介绍几种导出方法:1)使用 Redis 命令与重定向;2)利用 Redis 的 RDB 和 AOF 持久化功能;3)借助第三方工具如 `redis-dump`。每种方法均附有示例代码,帮助你轻松完成数据导出任务。无论数据量大小,总有一款适合你。
84 6