Correct the misunderstanding: This is the correct way to implement SpringBoot Redis distributed lock

2024.01.24

I'm Brother Ma, you can call me handsome.

Before talking about distributed locks, let's first talk about why distributed locks are needed.

During stand-alone deployment, we can use the JUC lock mechanism provided in Java to avoid security issues caused by multiple threads operating a shared variable at the same time. The JUC lock mechanism can only ensure that only one thread in the same JVM process operates shared resources at the same time.

An application deploys multiple nodes. If multiple processes want to modify the same shared resource, in order to avoid concurrency security issues caused by out-of-order operations, distributed locks need to be introduced at this time. Distributed locks are used to control the same moment. There is only one A thread in the JVM process can access the protected resource.

Distributed locks are important, but many companies' systems may still be running flawed distributed lock solutions, including some large companies.

Therefore, Ma Ge will share a correct Redis distributed lock code practice today, which will make you soar to the sky. This code can be directly used in production and is not a simple demo.

Warm reminder: If you only want to see the practical part of the code, you can directly turn to the SpringBoot practical chapter.

Wrong distributed lock

Before talking about the correct solution, first come up with a wrong one. Only when you know where the mistake is, can you realize how to write it correctly.

Teacher Xiaobai, who works in a bank, uses the Redis SET instruction to implement locking. The instruction satisfies the requirements of setting the value when the key does not exist, setting the timeout at the same time, and satisfying the atomic semantics.

SET lockKey 1 NX PX expireTime
  • 1.
  • lockKey represents the lock resource, and value is set to 1.
  • NX: Indicates that SET can only succeed when the lockKey does not exist, thus ensuring that only one client can obtain the lock.
  • PX expireTime sets the lock timeout in milliseconds; you can also use EX seconds to set the timeout in seconds.

As for the unlocking operation, Teacher Xiaobai decisively used the DEL command to delete it. A distributed lock solution came out, and it was completed in one go. The team members gave a thumbs up without realizing it. The pseudo code is as follows.

//加锁成功
if(jedis.set(lockKey, 1, "NX", "EX", 10) == 1){
  try {
      do work //执行业务

  } finally {
    //释放锁
     jedis.del(key);
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

However, this is a wrong distributed lock. The problem is that the unlocking operation may release someone else's lock.

It is possible to release someone else's lock.

  • Client A acquires the lock successfully and sets the timeout to 10 seconds.
  • Client A executes business logic, but due to some reasons (network problems, FullGC, poor code garbage performance) the execution is very slow and takes more than 10 seconds. The lock is automatically released due to timeout.
  • Client B is locked successfully.
  • Client A executes DEL to release the lock, which is equivalent to releasing client B's lock.

The reason is simple: when the client locks, it does not set a unique identifier. The logic of releasing the lock does not check the ownership of the lock and delete it directly.

Residual blood version distributed lock

Teacher Xiaobai: "Brother Ma, how to solve the problem of releasing other people's locks?"

Solution: When the client locks, set a "unique identifier", which allows value to store the client's unique identifier, such as a random number, UUID, etc.; when releasing the lock, determine whether the unique identifier of the lock matches the client's identifier, and the match can be deleted.

Lock

SET lockKey randomValue NX PX 3000
  • 1.

Unlock

The pseudo code to determine whether the unique identifier matches when deleting the lock is as follows.

if (jedis.get(lockKey).equals(randomValue)) {
    jedis.del(lockKey);
}
  • 1.
  • 2.
  • 3.

The pseudo code for locking and unlocking is as follows.

try (Jedis jedis = pool.getResource()) {
  //加锁成功
  if(jedis.set(lockKey, randomValue, "NX", "PX", 3000) == 1){
    do work //执行业务
  }
} finally {
  //判断是不是当前线程加的锁,是才释放
  if (randomValue.equals(jedis.get(keylockKey {
     jedis.del(lockKey); //释放锁
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

At this point, many companies may use this method to implement distributed locks.

Xiaobai: "Brother Ma, I still have a problem. Determining whether the unique identifier of the lock matches the current client and deleting it are not atomic operations."

clever. This solution also has atomicity problems, and there is the problem of other clients releasing the lock.

  • Client A successfully performed unique identifier matching, but before it had time to perform the DEL release lock operation, the lock expired and was released.
  • Client B acquires the lock successfully, and value sets its own client unique identifier.
  • Client A continues to execute the DEL delete lock operation, which is equivalent to deleting client B's lock.

Bronze version distributed lock

Although it is called the bronze version, it is also one of our most commonly used distributed lock solutions. This version does not have many flaws and is relatively simple.

Teacher Xiaobai: "Brother Ma, what should I do? How can I solve the problem that unlocking is not an atomic operation? There are so many ways to use distributed locks, but I am superficial."

The solution is simple. For the unlocking logic, we can implement the judgment and deletion process through Lua script.

  • KEYS[1] is lockKey.
  • ARGV[1] represents the client's unique identifier requestId.

Returning nil indicates that the lock does not exist and has been deleted. Only if the return value is 1, the locking is successful.

// key 不存在,返回 null
if (redis.call('exists', KEYS[1]) == 0) then
   return nil;
end;
// 获取 KEY[1] 中的 value 与 ARGV[1] 匹配,匹配则 del,返回 1。不匹配 return 0 解锁失败
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1]);
else
    return 0;
end;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

Using the above script, each lock uses a random value as a unique identifier. When the "unique identifier" of the client deleting the lock matches the value of the lock, the deletion operation can be performed. This solution is relatively perfect, and it may be the one we use the most.

After learning the theoretical knowledge, it’s time to practice.

Spring Boot environment preparation

Next, Brother Code, will give you a code based on Spring Boot that can be used in actual production. Before getting into the actual code, let’s first get the environment in which Spring Boot integrates Redis.

Add dependencies

The code is based on Spring Boot 2.7.18 and uses lettuce client to operate Redis. Add spring-boot-starter-data-redis dependency.

<dependencyManagement>
    <dependencies>
        <dependency>
            <!-- Import dependency management from Spring Boot -->
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>2.7.18</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.28</version>
            <optional>true</optional>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!--redis依赖-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!--Jackson依赖-->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • twenty one.
  • twenty two.
  • twenty three.
  • twenty four.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.

SpringBoot configuration

Configure yaml first.

server:
  servlet:
    context-path: /redis
  port: 9011
spring:
  application:
    name: redis
  redis:
    host: 127.0.0.1
    port: 6379
    password: magebyte
    timeout: 6000
    client-type: lettuce
    lettuce:
      pool:
        max-active: 300
        max-idle: 100
        max-wait: 1000ms
        min-idle: 5
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

The default serialization method of RedisTemplate is not readable. We change the configuration and use JSON serialization. Note that this step is an additional operation and has nothing to do with distributed locks. It is an easter egg brought to you by Brother Ma.

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
        redisTemplate.setConnectionFactory(connectionFactory);

        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        redisTemplate.setKeySerializer(stringRedisSerializer); // key的序列化类型

        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        // 方法过期,改为下面代码
//        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); // value的序列化类型
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • twenty one.
  • twenty two.
  • twenty three.
  • twenty four.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.

Define the distributed lock interface, the so-called interface and object-oriented programming, the code is like a god. By the way, use English to show what professionalism is.

/**
 * 分布式锁
 */
public interface Lock {

    /**
     * Tries to acquire the lock with defined <code>leaseTime</code>.
     * Waits up to defined <code>waitTime</code> if necessary until the lock became available.
     * <p>
     * Lock will be released automatically after defined <code>leaseTime</code> interval.
     *
     * @param waitTime  the maximum time to acquire the lock
     * @param leaseTime lease time
     * @param unit      time unit
     * @return <code>true</code> if lock is successfully acquired,
     * otherwise <code>false</code> if lock is already set.
     * @throws InterruptedException - if the thread is interrupted
     */
    boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

    /**
     * Acquires the lock with defined <code>leaseTime</code>.
     * Waits if necessary until lock became available.
     * <p>
     * Lock will be released automatically after defined <code>leaseTime</code> interval.
     *
     * @param leaseTime the maximum time to hold the lock after it's acquisition,
     *                  if it hasn't already been released by invoking <code>unlock</code>.
     *                  If leaseTime is -1, hold the lock until explicitly unlocked.
     * @param unit      the time unit
     */
    void lock(long leaseTime, TimeUnit unit);

    /**
     * Releases the lock.
     *
     * <p><b>Implementation Considerations</b>
     *
     * <p>A {@code Lock} implementation will usually impose
     * restrictions on which thread can release a lock (typically only the
     * holder of the lock can release it) and may throw
     * an (unchecked) exception if the restriction is violated.
     * Any restrictions and the exception
     * type must be documented by that {@code Lock} implementation.
     */
    void unlock();
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • twenty one.
  • twenty two.
  • twenty three.
  • twenty four.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.

Bronze distributed lock practice

DistributedLock implements the Lock interface, and the constructor implements the property setting of resourceName and StringRedisTemplate. The client's unique identifier is composed of uuid:threadId.

DistributedLock

public class DistributedLock implements Lock {

    /**
     * 标识 id
     */
    private final String id = UUID.randomUUID().toString();

    /**
     * 资源名称
     */
    private final String resourceName;

    private final List<String> keys = new ArrayList<>(1);


    /**
     * redis 客户端
     */
    private final StringRedisTemplate redisTemplate;

    public DistributedLock(String resourceName, StringRedisTemplate redisTemplate) {
        this.resourceName = resourceName;
        this.redisTemplate = redisTemplate;
        keys.add(resourceName);
    }

    private String getRequestId(long threadId) {
        return id + ":" + threadId;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • twenty one.
  • twenty two.
  • twenty three.
  • twenty four.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.

Lock tryLock, lock

tryLock attempts to acquire the lock by blocking and waiting for waitTime. If the acquisition is successful, it returns true, otherwise false. The tryAcquire method is equivalent to executing the SET resourceName uuid:threadID NX PX {leaseTime} instruction of Redis.

Different from tryLock, lock keeps trying to spin and block waiting to acquire the distributed lock until the acquisition is successful. And tryLock will only block and wait for waitTime.

In addition, in order to make the program more robust, Code Brother implements blocking and waiting to obtain distributed locks, which makes you more happy and makes it easy to increase your salary without panic during interviews. If you don't need to spin and block waiting to acquire the lock, just delete the while code block.

@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    long time = unit.toMillis(waitTime);
    long current = System.currentTimeMillis();
    long threadId = Thread.currentThread().getId();
    // 获取锁
    Boolean isAcquire = tryAcquire(leaseTime, unit, threadId);
    // lock acquired
    if (Boolean.TRUE.equals(isAcquire)) {
        return true;
    }

    time -= System.currentTimeMillis() - current;
    // 等待时间用完,获取锁失败
    if (time <= 0) {
        return false;
    }
    // 自旋获取锁
    while (true) {
        long currentTime = System.currentTimeMillis();
        isAcquire = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
        if (Boolean.TRUE.equals(isAcquire)) {
            return true;
        }

        time -= System.currentTimeMillis() - currentTime;
        if (time <= 0) {
            return false;
        }
    }
}

@Override
public void lock(long leaseTime, TimeUnit unit) {
    long threadId = Thread.currentThread().getId();
    Boolean acquired;
    do {
        acquired = tryAcquire(leaseTime, unit, threadId);
    } while (Boolean.TRUE.equals(acquired));
}

private Boolean tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    return redisTemplate.opsForValue().setIfAbsent(resourceName, getRequestId(threadId), leaseTime, unit);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • twenty one.
  • twenty two.
  • twenty three.
  • twenty four.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.

unlockunlock

The unlocking logic is implemented by executing the lua script.

@Override
public void unlock() {
    long threadId = Thread.currentThread().getId();

    // 执行 lua 脚本
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(LuaScript.unlockScript(), Long.class);
    Long opStatus = redisTemplate.execute(redisScript, keys, getRequestId(threadId));

    if (opStatus == null) {
        throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                + id + " thread-id: " + threadId);
    }


}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

LuaScript

In fact, this script is the code that explains the principle of the bronze plate distributed lock. The specific logic has already been explained, so I will not repeat the analysis here.

public class LuaScript {

    private LuaScript() {

    }

    /**
     * 分布式锁解锁脚本
     *
     * @return 当且仅当返回 `1`才表示加锁成功.
     */
    public static String unlockScript() {
        return "if (redis.call('exists', KEYS[1]) == 0) then " +
                "return nil;" +
                "end; "+
                "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                "   return redis.call('del',KEYS[1]);" +
                "else" +
                "   return 0;" +
                "end;";
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • twenty one.
  • twenty two.

RedisLockClient

Finally, a client needs to be provided for easy use.

@Component
public class RedisLockClient {

    @Autowired
    private StringRedisTemplate redisTemplate;


    public Lock getLock(String name) {
        return new DistributedLock(name, redisTemplate);
    }

}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

Let’s do a unit test.

@Slf4j
@SpringBootTest(classes = RedisApplication.class)
public class RedisLockTest {

    @Autowired
    private RedisLockClient redisLockClient;


    @Test
    public void testLockSuccess() throws InterruptedException {
        Lock lock = redisLockClient.getLock("order:pay");
        try {
            boolean isLock = lock.tryLock(10, 30, TimeUnit.SECONDS);
            if (!isLock) {
                log.warn("加锁失败");
                return;
            }
            TimeUnit.SECONDS.sleep(3);
            log.info("业务逻辑执行完成");
        } finally {
            lock.unlock();
        }

    }

}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • twenty one.
  • twenty two.
  • twenty three.
  • twenty four.
  • 25.
  • 26.

There are two points to note.

  • The code that releases the lock must be placed in the finally{} block. Otherwise, once an exception is thrown during the execution of business logic, the program will not be able to execute the process of releasing the lock. You can only wait for the lock to be released after timeout.
  • The locking code should be written in the try {} code. If it is placed outside the try, if the lock execution is abnormal (the client network connection times out), but the actual instructions have been sent to the server and executed, there will be no chance to perform unlocking. code.

Xiaobai: "Brother Ma, you call this solution the bronze level. So there are also king and super versions? Our company still uses the wrong version of distributed locks. No wonder there are duplicate orders sometimes. I'm just superficial."

Hurry up and replace the original wrong or residual version of the Redis distributed lock with this solution.