Redis结合LUA脚本实现序列号唯一引发的问题

Redis结合LUA脚本实现序列号唯一引发的问题

背景

  • 项目中使用redis结合lua脚本来获取序列号,保证序列号的唯一,lua脚本是我在网上找的,看好多大神都在用,也就觉得没问题,直接引入了自己的项目。脚本内容如下(本人对脚本内容添加了注释,方便读者理解):
-- 获取最大的序列号,样例为16081817202494579
-- 从redis中获取到的序列如果小于传入的序列号,就把redis中的序列号置为当前序列号,并返回给调用者
-- 从redis中获取到的序列如果大于传入的序列号,就按照增长规则递增,并返回给调用者
-- 通过这样的方式保证序列号的唯一性

local function get_max_seq()
    //KEYS[1]:第一个参数代表存储序列号的key  相当于代码中的业务类型
    local key = tostring(KEYS[1])
    //KEYS[2]:第二个参数代表序列号增长速度  
    local incr_amoutt = tonumber(KEYS[2])
    //KEYS[3]:第三个参数为序列号 (yyMMddHHmmssSSS + 两位随机数)
    local seq = tostring(KEYS[3])
    //序列号过期时间大小
    local month_in_seconds = 24 * 60 * 60 * 30

    //Redis的 SETNX 命令可以实现分布式锁,用于解决高并发
    //如果key不存在,将 key 的值设为 seq,设置成成功返回1   未设置返回0 
    //若给定的 key 已经存在,则 SETNX 不做任何动作。 
    if (1 == redis.call('setnx', key, seq))
    then
    //设置key的生存时间   为  month_in_seconds秒  
        redis.call('expire', key, month_in_seconds)
    //将序列返回给调用者
        return seq
    else
    //key值存在,直接获取key值大小(序列号)
        local prev_seq = redis.call('get', key)
    //获取到的序列号  小于 当前序列号
        if (prev_seq < seq)
        then
    //直接将key值设为当前序列号
            redis.call('set', key, seq)
    //返回给调用者
            return seq
        else
    //获取到的序列号  大于  当前序列号  就将key值置为key+incr_amoutt
            redis.call('incrby', key, incr_amoutt)
    //将key+incr_amoutt 返回给调用者
            return redis.call('get', key)
        end
    end
end
return get_max_seq()

在脚本中可以使用redis.call函数调用Redis命令

在脚本中可以使用return语句将值返回给客户端,如果没有执行return语句则默认返回null

脚本优化

  • 项目业务功能在不断扩展,redis中存放的数据也越来越多,为了更加方便的管理redis中的key,我对脚本内容进行了修改,将原有的key-value存储修改为key-hashkey-value形式存储,修改后的脚本:
local function get_max_seq()

    local key = 'SEEKER:SEQ:BIZ'

    local increment = 1

    local hkey = tostring(KEYS[1])

    local seq = tostring(KEYS[2])

    local month_in_seconds = 24 * 60 * 60 * 30

    if (1 == redis.call('hsetnx', key, hkey, seq))
    then
        redis.call('expire',key,month_in_seconds)
        return seq
    else
        local prev_seq = redis.call('hget',key, hkey)
        if(prev_seq < seq)
        then
            redis.call('hset',key,hkey,seq)
            return seq
        else
            redis.call('hincrby', key, hkey, increment)
            return redis.call('hget', key, hkey)
        end
    end
end
return get_max_seq()

高并发导致获取序列号重复

  • 使用修改后的脚本,项目也稳定运行了半年多时间,突然有一天,运维跟我说获取的序列号重复了。于是我本地环境模拟高并发开始测试脚本,即每次传入脚本的seq参数都是固定字符串,结果获取到序列号有重复的,脚本的确有问题。

  • 经研究:脚本中在比较字符串大小时,使用的是tostring,比较结果不准确,可能出现’24’ > ‘25’情况(具体脚本为什么不能用tostring进行比较,请读者自行查阅资料),应该使用tonumber,于是再次对脚本进行了修改。修改后脚本内容如下:

local function get_max_seq()

    local key = tostring(KEYS[1])

    local increment = tonumber(KEYS[2])

    local hkey = tostring(KEYS[3])

    local seq = tonumber(KEYS[4])

    local month_in_seconds = 2592000

    if (1 == redis.call('hsetnx', key, hkey, seq))
    then
        redis.call('expire',key,month_in_seconds)
        return seq
    else
        local prev_seq = redis.call('hget',key, hkey)
        if(tonumber(prev_seq) < seq)
        then
            redis.call('hset',key,hkey,seq)
            return seq
        else
            return redis.call('hincrby', key, hkey, increment)
        end
    end
end
return get_max_seq()
  • 使用修改后的脚本再进行高并发测试,序列号不会重复,问题已经解决。

总结

  • 任何新技术的引用,都要仔细研究,亲身测试。

附:测试代码(springboot实现)

package com.seeker.controller;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import org.apache.commons.lang.StringUtils;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.DefaultScriptExecutor;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import org.springframework.util.FileCopyUtils;

/**
 * @title
 * @description
 * @since Java8
 */
@Component
public class RedisUtil {

    private static StringRedisTemplate redisStringTemplate;

    private static RedisScript<String> redisScript;

    private static DefaultScriptExecutor<String> scriptExecutor;

    private RedisUtil(StringRedisTemplate template) throws IOException {
        RedisUtil.redisStringTemplate = template;

        // 初始化lua脚本调用 的redisScript 和 scriptExecutor
        ClassPathResource luaResource = new ClassPathResource("get_next_seq.lua");
        EncodedResource encRes = new EncodedResource(luaResource, "UTF-8");
        String luaString = FileCopyUtils.copyToString(encRes.getReader());

        redisScript = new DefaultRedisScript<>(luaString, String.class);
        scriptExecutor = new DefaultScriptExecutor<>(redisStringTemplate);
    }

    public static String getBusiId(String type) {
        List<String> keyList = new ArrayList<>();
        keyList.add("24");
        keyList.add("23");

        String seq = scriptExecutor.execute(redisScript, keyList);
        return type + seq;

    }

}
package com.seeker.controller;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.Vector;
import java.util.concurrent.CountDownLatch;

import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.methods.PostMethod;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * 
 * @author Fan.W
 * @since 1.8
 */
@Controller
@RequestMapping("/seeker")
public class TestController {
    private static Vector<String> s = new Vector<>();
    @Autowired
    private StringRedisTemplate template;

    @RequestMapping(value = "/redistest")
    public String redistest() {

        CountDownLatch startSignal = new CountDownLatch(1);

        for (int i = 0; i < 100; ++i) {
            new Thread(new Task(startSignal)).start();
        }

        startSignal.countDown();
        return "hello";
    }

    class Task implements Runnable {
        private final CountDownLatch startSignal;

        Task(CountDownLatch startSignal) {
            this.startSignal = startSignal;
        }

        public void run() {
            try {
                String seq = RedisUtil.getBusiId("24");
                System.out.println(seq);
                if (s.contains(seq)) {
                    System.out.println("重复id " + seq);
                } else {
                    s.add(seq);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章