线上redis单机版迁移到集群版lua踩坑记录

380 阅读3分钟

特殊说明以下内容涉及到公司代码部分均已用demo替代

背景

为了节约阿里云硬件成本,组内打算将某应用的redis迁移到一个公共的redis中,从而下线该redis。

redis区别

原redis目标redis
版本Redis 4.0社区办Redis 4.0 社区版
部署方式单机版阿里云集群版(代理模式)

代码逻辑图(商品查询 + 商品修改)

image.png

具体代码

public class TestDemo {

    private Jedis jedis;

    public List<Goods> search(GoodsSearch goodsSearch) {
        TestDemo testDemo = new TestDemo();
        //1.根据goodsSearch里面的每个字段获取md5加密
        String md5Key = testDemo.md5Key("Goods_PREFIX", goodsSearch);
        String cacheData = jedis.get(md5Key);
        if (!StringUtils.isBlank(cacheData)) {
            return JSON.parseArray(cacheData, Goods.class);
        }
        //2.从es搜索数据
        List<Goods> result = new ArrayList<>();
        //3.设置redis数据,key:md5加密,value:搜索结果值
        jedis.set(md5Key, JSON.toJSONString(result));
        jedis.expire(md5Key, 60 * 5);

        //4.执行lua脚本建立md5跟搜索结果集的映射关系,方便后续商品变更及时取消md5对应的redis缓存
        List<String> goodsNo = result.stream().map(Goods::getGoodsNo).collect(Collectors.toList());
        this.createRelation(md5Key, goodsNo);
        return result;
    }

    public void modifyGoods(String goodsNo) {
        TestDemo testDemo = new TestDemo();
        //1.修改商品属性
        //2.删除该商品对应的查询条件md5结果
        testDemo.deleteGoodsCache("CACHE_REFRESH:" + goodsNo + ":*");
    }

    private void createRelation(String searchRequestMd5Key, List<String> goodsNos) {
        Jedis jedis = new Jedis();
        String batchAddlua = "local value = ARGV[1];\n" +
                "local exp = ARGV[2];\n" +
                "local prefix = "CACHE_REFRESH:"\n" +
                "for i=1, #(KEYS) do\n" +
                "redis.call("sadd", prefix..KEYS[i], value)\n" +
                "redis.call("expire", prefix..KEYS[i], exp)\n" +
                "end";
        Integer expireTime = 1000 * 60 * 5;
        jedis.eval(batchAddlua, goodsNos, Arrays.asList(searchRequestMd5Key, expireTime.toString()));
    }

    private void deleteGoodsCache(String goodsNo) {
        String redisKey = "CACHE_REFRESH:" + goodsNo;
        Set<String> smembers = jedis.smembers(redisKey);
        smembers.forEach(
                key -> {
                    jedis.del(key);
                }
        );
    }

    public static final class GoodsSearch {

        private String goodsNo;

        private String picUrl;
    }


    public static final class Goods {

        private String goodsNo;

        private String picUrl;

        private Integer price;

        public String getGoodsNo() {
            return goodsNo;
        }

        public void setGoodsNo(String goodsNo) {
            this.goodsNo = goodsNo;
        }

        public String getPicUrl() {
            return picUrl;
        }

        public void setPicUrl(String picUrl) {
            this.picUrl = picUrl;
        }

        public Integer getPrice() {
            return price;
        }

        public void setPrice(Integer price) {
            this.price = price;
        }
    }

    /**
     * 获取搜搜条件所有字段的md5加密结果
     *
     * @param prefix
     * @param objects
     * @return
     */
    public String md5Key(String prefix, Object... objects) {
        StringBuilder sb = new StringBuilder();
        Arrays.stream(objects).forEach(e -> {
            sb.append(JSONObject.toJSONString(e));
        });
        return prefix + DigestUtils.md5DigestAsHex(sb.toString().getBytes());
    }
}

代码中涉及到的lua脚本以及使用分布式redis调用lua脚本存在的问题

local value = ARGV[1];
local exp = ARGV[2];
local prefix = "CACHE_REFRESH:"
for i=1, #(KEYS) do
redis.call("sadd", prefix..KEYS[i], value)
redis.call("expire", prefix..KEYS[i], exp)
end

阿里云集群架构命令限制文档地址

  1. redis.call方法中的key不能用变量,只能直接用KYES[i],
  2. lua脚本操作的key一定要在一个slot下,redis官方文档-slot

解决方案

1.技术层面解决

综上得知,因为查询结果集多个key注定无法放在一个redis-slot下面, 如果强制放在一个slot下会导致key分布不均匀, 查询性能无法提升, 如上使用lua脚本去批量设置过期时间,主要是为了解决redis多次网络交互的消耗。那么目前我能想到的就是用redis的pipelining(管道),管道简介

2.方案层面解决

我们可以问一下,自己用redis的目的是什么?是为了应付流量高峰期那几分钟或者几秒,而不是尽量用redis,而不走搜索。明白了这一点,我们来看看上面方案有什么缺点。

缺点
  1. 需要建立md5Key(请求参数)跟多个goods的映射关系,缓存的清除需要人为代码操作
  2. 设置过长的缓存过期时间,可能会导致意外的长时间的数据不一致
  3. 删除缓存需要代码逻辑进行,增加了代码的复杂度
解决方案如下:
  1. 设置比较短的过期时间,按照业务高峰期时间来定
  2. 告知上游,如果对数据一致性要求比较高,不要走缓存逻辑,走数据库接口
  3. 根据接口查询QPS,在es层面进行优化,目前理论上es能解决大部分普通场景的搜索