Redis based distributed lock

Posted by charlesg on Tue, 07 Dec 2021 01:55:28 +0100

        The previous article briefly described the basic principle of Redis implementing distributed locks. This time, we will analyze the source code of distributed locks provided by Redis. (the eleventh day of knowing yourself as a rookie)

RedissonClient:

        Let's talk about the conclusion first. Redisonclient object (client object) provided by Redis itself. The getlock() method of this object can obtain a lock object, then lock.lock() locks it and lock.unlock() unlocks it. The simplest distributed lock is completed. Simple unimaginable, let's talk about the source code.

/*
 * Building connection objects
 */
Config config = new Config();
config.useClusterServers()
    .setScanInterval(2000) // cluster state scan interval in milliseconds
    .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001")
    .addNodeAddress("redis://127.0.0.1:7002");

// Connect to Redis or server client object
RedissonClient redisson = Redisson.create(config);

// Acquire lock
RLock lock = redisson.getLock("anyLock");

// Lock
lock.lock();

try {
    ...
} finally {
    // Unlock
    lock.unlock();
}

getLock:

        As can be seen from the above code, after connecting to the database and obtaining the client object, the first step is to get a lock first, and then use the lock object to complete the actions of locking and unlocking. The lock object is obtained by the getLock(String name) method according to the unique name of the lock. In the previous article, it was said that different threads will judge whether a thread is holding the lock, and then set the expiration time for the lock. However, these operations are not judged in the source code of the getLock method. These judgments are put in the lock adding process below, In other words, in the step of taking the lock, only a lock object is obtained without connecting to the Redis database, which ensures the atomicity of the lock and prevents deadlock.

public RLock getLock(String name) {
    // Get lock object
	return new RedissonLock(this.connectionManager.getCommandExecutor(), name);
}

/**
 * Some properties of locks
 */
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
    // Here is to get a RedisObject
	super(commandExecutor, name);
	this.commandExecutor = commandExecutor;
	this.id = commandExecutor.getConnectionManager().getId();
	this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
}

public RedissonObject(CommandAsyncExecutor commandExecutor, String name) {
    this(commandExecutor.getConnectionManager().getCodec(), commandExecutor, name);
}

lock:

        Without looking at the source code, the process of locking should be roughly guessed according to the conclusion of the previous article. It should first obtain the unique ID of the thread, and then judge whether the thread is holding the lock. If there is no thread holding the lock, add the lock to the Redis database, and then set the expiration time. This process should be a lua script operation, In order to ensure the atomicity of the operation, if there is an exception in the whole process, unlock it. If there is a thread holding, add subscriptions and publications for the thread, and then wait for the holding thread to release the lock. If the intermediate process times out, end the waiting thread in advance.

        Let's guess about this. Let's look at the specific source code. The locked interface has multiple implementation classes for real interfaces. At present, let's look at the code under the redisson package (there will be a large code segment below). Here is a Redis subscription and publishing. We'll talk about it next time.

/**
 * entrance
 */
public void lock() {
	try {
		this.lockInterruptibly();
	} catch (InterruptedException var2) {
		Thread.currentThread().interrupt();
	}

}

/**
 * The second call passes in - 1L and null
 */
public void lockInterruptibly() throws InterruptedException {
	this.lockInterruptibly(-1L, (TimeUnit)null);
}

/**
 * Implementation method of locking
 */
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
	// Gets the unique ID of the thread
    long threadId = Thread.currentThread().getId();
    
    // Judge whether a thread supports a lock and return the lifetime of the lock. If the lock is successfully added, it returns null. Otherwise, it is the lifetime of the lock (that is, another thread is holding the lock)
	Long ttl = this.tryAcquire(leaseTime, unit, threadId);
	if (ttl != null) {
        // Blocking, subscribing to messages
		RFuture<RedissonLockEntry> future = this.subscribe(threadId);
		this.commandExecutor.syncSubscription(future);

		try {
            /*
             * Whether the circular access lock has been released
             */
			while(true) {
				ttl = this.tryAcquire(leaseTime, unit, threadId);
                // Locking succeeded
				if (ttl == null) {
					return;
				}

				if (ttl >= 0L) {
					this.getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
				} else {
					this.getEntry(threadId).getLatch().acquire();
				}
			}
		} finally {
            // Unsubscribe
			this.unsubscribe(future, threadId);
		}
	}
}

tryAcquire():

        First, specify that the tryAcquire method currently needs to return the survival time. The get method called by the code is to parse the RFuture object returned by the tryAcquireAsync method to obtain the survival time, while the tryAcquireAsync method uses the lua script that will call the stored value lock or obtain the survival time of the existing lock (the lua script is used to ensure the atomicity of the operation). Here are some logic for renewal, The specific code details are as follows.

        I didn't understand the lock renewal either. I inferred from the lock process that when thread A obtains the lock, an asynchronous thread will be created for lock renewal, that is, the business execution time will not be greater than the expiration time of the lock, but I didn't find the relevant conclusion on how to judge whether the current thread holds the lock (I guess before locking, we first get the reisson lock object. If the service hangs, the jvm garbage collection mechanism will recycle the lock object, and the asynchronous thread will not get the current lock object and will not renew it, so the lock will naturally expire and then be released.) , when thread B comes in and finds that thread A is using A lock, it will return the lifetime of A lock, and thread B will wait circularly. The specific code of this block should be in the above code block.

      lua script translation:

      Lock lua script:

  1. If the lock does not exist (EXISTS command), store the unique ID of the key and value thread of the lock (hset command), and set the expiration time (milliseconds) with pexpire command.
  2. If the values of key and value exist (hexists command), that is, the thread currently accessed is the thread using the lock (recursive operation, reentry, self adjustment), increase an increment of 1, and set the expiration time (milliseconds) with the pexpire command.
  3. If the above conditions are not satisfied, that is, if a thread is already using the current lock and the lock is not released, and the current thread is not the thread in use, the survival time of the key is returned (the survival time of the pttl command returns the key in milliseconds, and the similar command TTL returns seconds), that is, the survival time of the lock.

      Renewal lua script:

  1. Whether the current thread still exists (hexists command). If so, set the expiration time (milliseconds) with pexpire command again, and return 1, otherwise return 0.
/**
 * entrance
 */
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
	return (Long)this.get(this.tryAcquireAsync(leaseTime, unit, threadId));
}

/**
 * Call save value, renew
 */
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
    // Incoming time is not equal to - 1L
	if (leaseTime != -1) {
	    // Call save lock
		return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
	}

	// Call save lock to return the listening object
	RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);

	// Renewal operation
	ttlRemainingFuture.addListener(new FutureListener<Long>() {
		@Override
		public void operationComplete(Future<Long> future) throws Exception {
			if (!future.isSuccess()) {
				return;
			}

			Long ttlRemaining = future.getNow();
			// lock acquired
			if (ttlRemaining == null) {
                // Call renewal
				scheduleExpirationRenewal(threadId);
			}
		}
	});
	
	// Return listening object
	return ttlRemainingFuture;
}

/**
 * Storage lock
 */
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
	// Conversion time
	internalLockLeaseTime = unit.toMillis(leaseTime);

	// lua script executes the Save command
	return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
			  "if (redis.call('exists', KEYS[1]) == 0) then " +
				  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
				  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
				  "return nil; " +
			  "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 nil; " +
			  "end; " +
			  "return redis.call('pttl', KEYS[1]);",
				Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

/**
 * Lock renewal
 */
private void scheduleExpirationRenewal(final long threadId) {
	// In fact, this is an idempotent processing. If there is a lock mode scheduling of the current thread, there is no need to add a pair again
	if (expirationRenewalMap.containsKey(getEntryName())) {
		return;
	}

	// Open an asynchronous thread after default 30/3 = 10S.
    // Renew the contract. If the thread does not hold the lock, it will stop scheduling
	Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
		@Override
		public void run(Timeout timeout) throws Exception {
			// As can be seen from the name, this is the asynchronous update expiration time
			RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                            "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                            "return 1; " +
                        "end; " +
                        "return 0;",
                          Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
			
			future.addListener(new FutureListener<Boolean>() {
				@Override
				public void operationComplete(Future<Boolean> future) throws Exception {
					expirationRenewalMap.remove(getEntryName());
					if (!future.isSuccess()) {
						log.error("Can't update lock " + getName() + " expiration", future.cause());
						return;
					}
					//0 false 1 true means that the contract renewal will be scheduled again only after success
					if (future.getNow()) {
						// reschedule itself
						// Here, after the renewal is completed, the renewal method will be called again
						scheduleExpirationRenewal(threadId);
					}
				}
			});
		}
		// Expiration time / 3 = 30 by default / 3 = 10s
	}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

	if (expirationRenewalMap.putIfAbsent(getEntryName(), new ExpirationEntry(threadId, task)) != null) {
		task.cancel();
	}
}

        The above is the whole process of locking. In a word, we must ensure the atomicity of the operation.

unlock:

        After locking, you must remember to unlock it. After the thread ends, Redis will release the lock according to the expiration time of the lock, but this process must be > 30 seconds, because the lock will be automatically renewed, and the renewal will not end until the jvm reclaims the lock object. Therefore, it is necessary to add the unlock method to release the lock.

        Compared with locking, unlocking is quite simple. Just ensure that the thread ends running, and the unlocked thread must be the locked thread. Look at the code.

/**
 * Unlock
 */
public void unlock() {
	// Judge whether the current thread is a locked thread and unlock it (note recursive call and recursive unlocking)
	Boolean opStatus = get(unlockInnerAsync(Thread.currentThread().getId()));
	
	// Unlocking failed
	if (opStatus == null) {
		// report errors
		throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
				+ id + " thread-id: " + Thread.currentThread().getId());
	}
	
	// Unlock succeeded
	if (opStatus) {
		// Delete renewal listening
		cancelExpirationRenewal();
	}
}

        The above is a simple source code analysis of distributed locks. Later, we will talk about the subscription, publishing and lock requirements of Redis in detail.

Topics: Java Redis Spring