解决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<Map<String, Object>>进行返回,这时候我们在结果中会发现 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<Map>, 这时候我们就发现,从字符串转换过来的数据,其数据类型发生了变化,<font style="color:#DF2A3F;">publishTime</font> 字段的类型从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类型的数据的逻辑比较粗暴,直接通过遍历的方式,后续可以优化,在其中加入特殊标识来标识是存在,提示数据处理速度。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容