The reentrant distributed lock commanded by the boss was finally perfectly implemented ~

Redo is always easier than transform

Recently, I am working on a project to fully integrate an implementation system from another company (hereinafter referred to as the old system) into my own company's system (hereinafter referred to as the new system), which requires that the functions implemented by the other party be fully implemented in their own system.

The old system also has a lot of inventory merchants. In order not to affect the experience of the inventory merchants, the external interface provided by the new system must also be the same as before.Finally, after the complete system switching, the function only runs in the new system, which requires the data of the old system to be migrated to the new system completely.

Of course, these were expected before the project was done, and it was difficult to think about the process, but it was unexpectedly so difficult.Originally I felt that I had to schedule for half a year, but I still had plenty of time. Now I feel like a big pit and have to fill it a little bit.

Ouch, it's all tears, don't spit any more, wait until the next time you finish to give you a real experience under the review.

Back to the body, last article Redis Distributed Lock Let's implement a distributed lock based on Redis.The basic functionality of this distributed lock is fine, but it lacks the reentrant feature, so this article shows you how to implement a reentrant distributed lock.

This article will cover the following:

  • Reentrant

  • ThreadLocal-based implementation

  • Redis Hash-based implementation

Reentrant

When it comes to re-locking, let's first look at a paragraph from wiki Reentrant explanations above:

A program or subroutine is called re entrant or re-entrant if it can be "interrupted at any time and the operating system schedules another piece of code to execute, which in turn calls the subroutine without error".That is, when the subroutine is running, the execution thread can enter and execute it again, still achieving the results expected at design time.Unlike thread security for multithreaded concurrent execution, reentrant emphasizes that it is still safe to reenter the same subroutine while executing on a single thread.

When a thread executes a piece of code that successfully acquires a lock and continues executing, and encounters locked code, reentrancy guarantees that the thread can continue executing. Either it cannot reentrant or it needs to wait for the lock to be released before it succeeds in acquiring the lock again to continue executing.

Interpret reentrant with a piece of Java code:

public synchronized void a() {
    b();
}

public synchronized void b() {
    // pass
}

Assume that the X-ray process continues to execute method b after method a acquires the lock, and if it is not reentrant at this time, the thread must wait for the lock to be released and contend for the lock again.

It seems strange that Lock Ming is owned by X-ray program, but I still need to wait for myself to release the lock before I go to lock it. I release myself ~

Reentrancy solves this embarrassing problem by adding 1 to the number of locks a thread encounters later, and then executing the method logic.After exiting the locking method, the number of locks is further reduced by 1. When the number of locks is 0, the lock is actually released.

You can see that the maximum feature of reentrant locks is counting, counting the number of locks.So when reentrant locks need to be implemented in a distributed environment, we also need to count the number of locks.

There are two ways to implement a distributed reentrant lock:

  • ThreadLocal-based implementation
  • Redis Hash-based implementation

First, let's look at the ThreadLocal-based implementation.

ThreadLocal-based implementation

Implementation

ThreadLocal in Java allows each thread to have its own copy of the instance, and we can use this feature to technically reentrant threads.

Below we define a ThreadLocal global variable, LOCKS, that stores Map instance variables in memory.

private static ThreadLocal<Map<String, Integer>> LOCKS = ThreadLocal.withInitial(HashMap::new);

Each thread can get its own Map instance through ThreadLocal, where key stores the name of the lock and value stores the number of times the lock has been reentered.

The code for locking is as follows:

/**
 * Re-lockable
 *
 * @param lockName  Lock name, indicating critical resource contention
 * @param request   Unique identifier, you can use uuid to determine whether reentry is possible
 * @param leaseTime Lock release time
 * @param unit      Lock release time unit
 * @return
 */
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
    Map<String, Integer> counts = LOCKS.get();
    if (counts.containsKey(lockName)) {
        counts.put(lockName, counts.get(lockName) + 1);
        return true;
    } else {
        if (redisLock.tryLock(lockName, request, leaseTime, unit)) {
            counts.put(lockName, 1);
            return true;
        }
    }
    return false;
}

ps: redisLock#tryLock is a distributed lock implemented for the previous article.

Since the public name outer chain cannot be jumped directly, follow the Program Notice and reply to the Distributed Lock to get the source code.

The locking method first determines if the current thread already owns the lock, and if it does, adds 1 to the number of direct re-entries of the lock.

If you do not already have the lock, try Redis to lock, and after successful locking, add 1 to the number of reentrances.

The code to release the lock is as follows:

/**
 * Unlock needs to determine different thread pools
 *
 * @param lockName
 * @param request
 */
public void unlock(String lockName, String request) {
    Map<String, Integer> counts = LOCKS.get();
    if (counts.getOrDefault(lockName, 0) <= 1) {
        counts.remove(lockName);
        Boolean result = redisLock.unlock(lockName, request);
        if (!result) {
            throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
                    + request);
        }

    } else {
        counts.put(lockName, counts.get(lockName) - 1);
    }
}

When a lock is released, the number of re-entries is first determined. If it is greater than 1, it means that the lock is owned by the thread, so it is sufficient to directly reduce the number of lock re-entries by 1.

If the current reentrant count is less than or equal to 1, remove the key corresponding to the lock in the Map first, and then release the lock in Redis.

It is important to note that when a lock is not owned by the thread and is unlocked directly, the number of reentrances is less than or equal to 1, and this time the direct unlock may not succeed.

When using ThreadLocal, remember to clean up internal storage instance variables in time to prevent memory leaks, contextual data strings, and so on.

Next time, let's talk about Bug s recently written in ThreadLocal.

Related Issues

Although it is really simple and efficient to use ThreadLocal as a local record reentry count, there are also some problems.

Expiration time issue

As you can see from the above locked code, when you re-enter a lock, you add only one to the local count.This may result in a situation where Redis has expired releasing the lock due to long business execution.

When the lock is re-entered again, it is not practical to assume that the lock is still held because there is data available locally.

If you want to increase the expiration time locally, you also need to consider local and Redis expiration time consistency, which can make your code complex.

Different threads/processes reentrant problem

Narrowly reentrant should only be reentrant for the same thread, but the actual business may require that the same lock can be reentrant between different application threads.

ThreadLocal's scheme only satisfies the same thread reentry and does not solve the reentry problem between different threads/processes.

Different thread/process reentry problems need to be solved using the Redis Hash scheme described below.

Redis Hash-based Re-lockable

Implementation

In the ThreadLocal scenario, we use Map to record the number of reentrant locks, and Redis also provides Hash, a data structure that stores key-value pairs.So we can use Redis Hash to store the number of times a lock is reentered and then use the lua script to determine the logic.

The locked lua script is as follows:

---- 1 representative true
---- 0 representative false

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
return 0;

If KEYS:[lock],ARGV[1000,uuid]

Don't be afraid if you are not familiar with lua. The above logic is simple.

The locking code first uses the Redis exists command to determine if the current lock exists.

If a lock does not exist, use hincrby directly to create a lock hash table with a key initialized to 0 for the uuid key in the Hash table, then add 1 again, and finally set the expiration time.

If the current lock exists, use hexists to determine if the uuid key exists in the hash table corresponding to the current lock. If so, use hincrby plus 1 again, and finally set the expiration time again.

Finally, if both of the above logics do not match, return directly.

The lock code is as follows:

// setup code

String lockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:lock.lua").openStream(), Charsets.UTF_8);
lockScript = new DefaultRedisScript<>(lockLuaScript, Boolean.class);

/**
 * Re-lockable
 *
 * @param lockName  Lock name, indicating critical resource contention
 * @param request   Unique identifier, you can use uuid to determine whether reentry is possible
 * @param leaseTime Lock release time
 * @param unit      Lock release time unit
 * @return
 */
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
    long internalLockLeaseTime = unit.toMillis(leaseTime);
    return stringRedisTemplate.execute(lockScript, Lists.newArrayList(lockName), String.valueOf(internalLockLeaseTime), request);
}

Spring-Boot 2.2.7.RELEASE

Java code implementation is simple as long as you understand the Lua script locking logic, using the String RedisTemplate provided by SpringBoot.

The unlocked Lua script is as follows:

-- judge hash set Reentrant key Is the value of 0 equal to
-- If 0 means reentrant key Non-existent
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    return nil;
end ;
-- Calculate the current reentrant count
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
-- Less than or equal to 0 means unlockable
if (counter > 0) then
    return 0;
else
    redis.call('del', KEYS[1]);
    return 1;
end ;
return nil;

First use hexists to determine if the Redis Hash table has a given field.

If lock does not exist for the Hash table, or if the Hash table does not exist for the uuid key, return nil directly.

If present, the current lock is held by it. First, hincrby is used to reduce the number of reentrants by 1, then the number of reentrants after calculation is determined. If less than or equal to 0, the lock is deleted by del.

The unlocked Java code is as follows:

// Initialization code:


String unlockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:unlock.lua").openStream(), Charsets.UTF_8);
unlockScript = new DefaultRedisScript<>(unlockLuaScript, Long.class);

/**
 * Unlock
 * If the number of reentrant keys is greater than 1, decrease the number of reentrant keys by 1 <br>
 * Unlock lua script returns meaning: <br>
 * 1:Represents successful unlock <br>
 * 0:Represents that the lock has not been released and the number of reentrances decreases by 1 <br>
 * nil: Attempting to unlock on behalf of another thread <br>
 * <p>
 * If DefaultRedisScript <Boolean> is used, <br>due to Spring-data-redis eval type conversion
 * When Redis returns Nil bulk, the default is converted to false, which affects the unlocking semantics, so use the following: <br>
 * DefaultRedisScript<Long>
 * <p>
 * See the specific transformation code: <br>
 * JedisScriptReturnConverter<br>
 *
 * @param lockName Lock Name
 * @param request  Unique ID, you can use uuid
 * @throws IllegalMonitorStateException Lock before unlocking.Unlock throws the error if it is locked
 */
public void unlock(String lockName, String request) {
    Long result = stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request);
    // If no value is returned, try to unlock on behalf of another thread
    if (result == null) {
        throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
                + request);
    }
}

Unlock code executes in a similar manner to locking, except that the execution result return type of unlocking uses Long.Boolean is not used here as with locking because the three return values in the unlock lua script mean the following:

  • 1 means the unlock was successful and the lock was released
  • 0 means the reentrant count is reduced by 1
  • null attempted to unlock on behalf of another thread and failed to unlock

If the return value uses Boolean, Spring-data-redis converts null to false when converting the type, which affects our logical judgment, so we have to use Long for the return type.

The following code is from JedisScript ReturnConverter:

Related Issues

Low version of spring-data-redis

If Spring-Boot uses Jedis as the connecting client and Redis Cluster Cluster mode, you need to use a version of spring-boot-starter-data-redis above 2.1.9 or it will be thrown during execution:

org.springframework.dao.InvalidDataAccessApiUsageException: EvalSha is not supported in cluster environment.

If the current application cannot upgrade spring-data-redis, you can execute the lua script directly using the native Jedis connection as follows.

Take the locking code as an example:

public boolean tryLock(String lockName, String reentrantKey, long leaseTime, TimeUnit unit) {
    long internalLockLeaseTime = unit.toMillis(leaseTime);
    Boolean result = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> {
        Object innerResult = eval(connection.getNativeConnection(), lockScript, Lists.newArrayList(lockName), Lists.newArrayList(String.valueOf(internalLockLeaseTime), reentrantKey));
        return convert(innerResult);
    });
    return result;
}

private Object eval(Object nativeConnection, RedisScript redisScript, final List<String> keys, final List<String> args) {

    Object innerResult = null;
    // Cluster mode and single-point mode execute scripts the same way, but they do not have a common interface and can only be executed separately
    // colony
    if (nativeConnection instanceof JedisCluster) {
        innerResult = evalByCluster((JedisCluster) nativeConnection, redisScript, keys, args);
    }
    // Single point
    else if (nativeConnection instanceof Jedis) {
        innerResult = evalBySingle((Jedis) nativeConnection, redisScript, keys, args);
    }
    return innerResult;
}

Data Type Conversion Problems

If you execute Lua scripts using Jedis native connections, you may encounter data type conversion pits again.

You can see that Jedis#eval returns Object s, and we need to translate them according to the return values of the Lua script.This involves converting Lua data types to Redis data types.

Here are a few of the Lua Data Conversion Redis rules that are easier to tread on:

1. Lua number and Redis data type conversion

The numberType in Lua is a double-precision floating-point number, but Redis only supports integer types, so this conversion will discard decimal places.

2. Lua boolean and Redis type conversion

This conversion is easier to trample on. There is no boolean type in Redis, so true in Lua will be converted to Redis integer 1.Instead of converting integers, false in Lua returns transformed null s to the client.

3. Lua nil and Redis Type Conversion

Lua nil can be treated as a null value and can be equated to null in Java.If nil appears in a conditional expression in Lua, it will be treated as false.

So Lua nil will also return null to the client.

Other conversion rules are simple, please refer to:

http://doc.redisfans.com/script/eval.html

summary

The key to reentrant distributed locks is the count of locks reentrant. This article mainly gives two solutions, one based on ThreadLocal implementation, which is simple to implement and more efficient to run.However, to deal with lock expiration, code implementation is more complex.

Another implementation uses Redis Hash data structure, which solves the shortcomings of ThreadLocal, but the code implementation is slightly more difficult, requiring familiarity with Lua scripts and some Redis commands.Additionally, you will encounter various problems inadvertently when operating Redis using spring-data-redis, and so on.

Help

https://www.sofastack.tech/blog/sofa-jraft-rheakv-distributedlock/

https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html

Last two sentences (ask for attention)

After reading the article, brothers and sisters ordered to see it. Zhou was really overtired. He wrote two more days unconsciously. He refused to express his opinions and gave positive feedback.

Finally, thank you for your reading. You are ignorant and have some mistakes. If you find something wrong, you can leave a message to point out.If there are other things you don't understand after reading the article, welcome to add me, learn from each other and grow together ~

Last thanks for your support~

Last but not least, let's say one more important thing ~

Come and pay attention to me ~
Come and pay attention to me ~
Come and pay attention to me ~

Tags: Redis Spring Jedis Java

Posted on Sun, 14 Jun 2020 20:26:06 -0400 by BillyMako