redis分布式锁

164 阅读6分钟

一、分布式锁的应用场景

大多数公司的项目并非运行在一台服务器上,比如说一个客户产生了一笔借款任务并提交订单数据,此时如果不对这条命令进行锁定那么在多台服务器上将同时产生该用户的订单数据,因此我们需要分布式锁保证该任务只被一个机器执行

主要作用为保持数据的一致性防止同一个操作被并发的修改造成数据脏乱

二、redis分布式锁

2.1lua脚本:

2.2项目实际使用(以Jredis为例它不支持锁的重入,如果使用重入锁需要使用redission客户端)

2.2.1.resource表写入lua脚本文件

image.png

2.2.2写一个LuaHelper类用于spring与lua整合运行lua脚本

@Slf4j

public class LuaHelper {

 

    public final static String DISTRIBUTE_LOCK_GET = "get distributed lock";

    public final static String DISTRIBUTE_LOCK_RELEASE = "release distributed lock";

    public final static String REQUEST_RATE_LIMITER = "request rate limiter";

 

    public class LuaScript {

        public String script;

        public String sha1;

    }

 

    Map<String, LuaScript> map;

 

 

    private static LuaHelper instance = new LuaHelper();

 

    public static LuaHelper getInstance() {

        return instance;

    }

 

    private LuaHelper() {

        map = new HashMap<>();

        init();

    }

 

    void init() {

        log.info("LuaHelper init");

        // 分布式锁

        addScriptFromFile(DISTRIBUTE_LOCK_GET, "lua/try_get_distributed_lock.lua");

        addScriptFromFile(DISTRIBUTE_LOCK_RELEASE, "lua/release_distributed_lock.lua");

        addScriptFromFile(REQUEST_RATE_LIMITER, "lua/request_rate_limiter.lua");

    }

 

 

    void addScript(String key, String script) {

        LuaScript script1 = new LuaScript();

        script1.script = script;

        script1.sha1 = DigestUtils.sha1Hex(script);

        map.put(key, script1);

    }

 

 

    void addScriptFromFile(String key, String file_path) {

        ClassPathResource res = new ClassPathResource(file_path);

        try {

            byte[] bytes = FileCopyUtils.copyToByteArray(res.getInputStream());

            String data = new String(bytes, StandardCharsets.UTF_8);

            addScript(key, data);

        } catch (IOException e) {

            e.printStackTrace();

            log.error(" load script error " + file_path);

        }

    }

 

    public LuaScript getScript(String key) {

        if (!map.containsKey(key)) {

            return null;

        }

        return map.get(key);

    }

 

    public static Object runScript(RedisTemplate<String, String> redisTemplate, final String scriptKey, final String... params) {

        long start = System.currentTimeMillis();

        Object object = redisTemplate.execute(new RedisCallback<Object>() {

            @Override

            public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {

                LuaHelper.LuaScript script = LuaHelper.getInstance().getScript(scriptKey);

                assert script != null;

                Jedis jedis = (Jedis) redisConnection.getNativeConnection();

 

                try {

                    return jedis.evalsha(script.sha1, params.length, params);

                } catch (Exception ex) {

                    log.info("params:" + params.length);

                    for (int i = 0; i < params.length; i++) {

                        log.info(i + " " + params[i]);

                    }

                    log.error("run script exception ", ex);

                    return jedis.eval(script.script, params.length, params);

                }

            }

        });

        log.info("lua script: " + scriptKey + " " + (System.currentTimeMillis() - start));

        return object;

    }
}

2.2.3写个RedisService类用于获取与释放锁以及对缓存的基本增删改查过期等操作

@Slf4j
@Service
public class RedisService {
 
    //注入各种redisTemplate
    @Autowired
    @Qualifier("lockRedisTemplate")
    RedisTemplate<String, String> lockRedisTemplate;
    @Autowired
    @Qualifier("stringRedisTemplate")
    RedisTemplate<String, String> cacheRedisTemplateString;
    @Resource
    @Qualifier("cacheRedisTemplate")
    RedisTemplate<String, Object> cacheRedisTemplate;
 
    /**
     * 尝试获取分布式锁
     *
     * @param lockKey
     * @param value
     * @param expireTime 过期时间 毫秒
     * @return
     */
    public boolean getDistributedLock(String lockKey, String value, Integer expireTime) {
        String key = getLockKey(lockKey);
        //传入获取锁的脚本
        Object obj = LuaHelper.runScript(lockRedisTemplate, LuaHelper.DISTRIBUTE_LOCK_GET, key, value, expireTime + "");
        return "ok".equals(obj);
    }
     
    /**
     * 尝试释放分布式锁
     *
     * @param lockKey
     * @param value
     * @return
     */
    public boolean releaseDistributedLock(String lockKey, String value) {
        String key = getLockKey(lockKey);
        //传入释放锁的lua脚本
        Object obj = LuaHelper.runScript(lockRedisTemplate, LuaHelper.DISTRIBUTE_LOCK_RELEASE, key, value);
        return RedisCons.RELEASE_SUCCESS.equals(obj);
    }
}

2.2.4项目实际使用

/**
 * 获取下一个要计算的城市id
 * 这里使用了分布式锁来保证数据一致
 *
 * @param currentKeyPre
 * @return
 */
private Integer getNextWorkCityId(String currentKeyPre) {
    Integer ret = -1;
    //获取分布式锁,只要返回就一定是获取到锁了,否则会阻塞在这里
    long id = getDistributeLockId();
    //获取到锁
    try {
        //执行具体任务
       ...实际业务逻辑
        }
    } catch (Exception e) {
        log.error("generateTask error:{}", e);
    } finally {
        redisService.releaseDistributedLock(RedisCons.LOCK_KEY_GENERATE_TASK, String.valueOf(id));
        log.info("[task], threadId:{},释放分布式锁,id:{}",Thread.currentThread().getId(),id);
    }
 
    return ret;
}
 
/**
 * 阻塞获取分布式锁,如果获取不到就等待一秒再循环获取
 *
 * @return 雪花算法的随机id,以便正确释放琐
 */
private long getDistributeLockId() {
    long id = SnowFlakeIdService.nextId();
    boolean isLocked = redisService.getDistributedLock(RedisCons.LOCK_KEY_GENERATE_TASK, String.valueOf(id), RedisCons.LOCK_EXPIRE_TIME);
    while (!isLocked){
        //等待1秒后,再获取;
        try {
            Thread.sleep(1000);
            String lockValue = redisService.getLockValue(RedisCons.LOCK_KEY_GENERATE_TASK);
            log.warn("[task] ThreadId:{} 没有获取到分布式锁, lockValue:{}",Thread.currentThread().getId(),lockValue);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        id = SnowFlakeIdService.nextId();
        isLocked = redisService.getDistributedLock(RedisCons.LOCK_KEY_GENERATE_TASK, String.valueOf(id), RedisCons.LOCK_EXPIRE_TIME);
    }
    log.info("[task] ThreadId:{} 获取到分布式锁 id:{}",Thread.currentThread().getId(),id);
    return id;
}

三、分布式锁的原理

image.png

3.1加锁机制

当一个客户端要要加redis锁时它对的是一个redis集群,首先需要通过hash算法命中一台机器,然后往这台机器上发送一段lua脚本

lua脚本

--三个参数分别为锁的键、锁的值和过期时间
local lockKey = KEYS[1]
local value = KEYS[2]
local expireTime = KEYS[3]
 
return redis.call("set", lockKey, value, "px", expireTime, 'NX');

对应的java代码

boolean isLocked = redisService.getDistributedLock(RedisCons.LOCK_KEY_GENERATE_TASK, String.valueOf(id), RedisCons.LOCK_EXPIRE_TIME);

这里KEYS[1]代表的是你加锁的那个Key,比如说:RLock lock = redisson.getLock("myLock");这里你自己设置了加锁的那个锁Key就是“myLock”。

  • ARGV[1]代表的就是锁Key的默认生存时间,默认30秒。
  • ARGV[2]代表的是加锁的客户端的ID,类似于下面这样的:8743c9c0-0795-4907-87fd-6c719a6b4586:1。

第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁Key不存在的话,你就进行加锁。如何加锁呢?很简单,用下面的命令:hset myLock。

8743c9c0-0795-4907-87fd-6c719a6b4586:11,通过这个命令设置一个Hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:

上述内容就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端,已经对“myLock”这个锁Key完成了加锁。

接着会执行“pexpiremyLock 30000”命令,设置myLock这个锁Key的生存时间是30秒,加锁完成。

3.2锁释放

lua脚本

--两个参数分别为锁键和锁值(雪花算法生成)
local lockKey = KEYS[1]
local value = KEYS[2]
if redis.call('get', lockKey) == value then
    return redis.call('del', KEYS[1])
else
    return 0
end

对应的java代码

redisService.releaseDistributedLock(RedisCons.LOCK_KEY_GENERATE_TASK, String.valueOf(id));

四、分布式锁的问题及优化

4.1问题

当多个用户同时下单时会串行处理请求,不适用秒杀系统和高并发的场景

4.2优化

参考ConcurrentHashMap的源码和底层原理,ConcurrentHashMap线程安全的原因是内部采用了分段锁的策略,因此可以考虑把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据。不至于说,

同一时间只能有一个线程独占修改ConcurrentHashMap中的数据。

假如你现在iphone有1000个库存,那么你完全可以给拆成20个库存段,要是你愿意,可以在数据库的表里建20个库存字段,比如stock_01,stock_02,类似这样的,也可以在redis之类的地方放20个库存key。

总之,就是把你的1000件库存给他拆开,每个库存段是50件库存,比如stock_01对应50件库存,stock_02对应50件库存。

接着,每秒1000个请求过来了,好!此时其实可以是自己写一个简单的随机算法,每个请求都是随机在20个分段库存里,选择一个进行加锁。

bingo!这样就好了,同时可以有最多20个下单请求一起执行,每个下单请求锁了一个库存分段,然后在业务逻辑里面,就对数据库或者是Redis中的那个分段库存进行操作即可,包括查库存 -> 判断库存是否充足 -> 扣减库存。

这相当于什么呢?相当于一个20毫秒,可以并发处理掉20个下单请求,那么1秒,也就可以依次处理掉20 * 50 = 1000个对iphone的下单请求了。

一旦对某个数据做了分段处理之后,注意:如果某个下单请求,咔嚓加锁,然后发现这个分段库存里的库存不足了,此时咋办?

这时你得自动释放锁,然后立马换下一个分段库存,再次尝试加锁后尝试处理。