How to implement locking to ensure data consistency under concurrency conditions? | JD Cloud Technical Team

Implementation plan of locking under monolithic architecture

1. ReentrantLock global lock

ReentrantLock (reentrant lock) refers to a reentrant request that will succeed when a thread again accesses a critical resource protected by the lock it holds.

Simply compare with our commonly used Synchronized:

  ReentrantLock Synchronized
Lock implementation mechanism Depend on AQS monitor mode
flexibility Supports response timeouts, interruptions, and attempts to acquire locks not flexible
release form You must explicitly call unlock() to release the lock Auto release monitor
lock type Fair lock & unfair lock unfair lock
conditional queue Can be associated with multiple condition queues Associate a conditional queue
Reentrancy reentrant reentrant

AQS mechanism : If the requested shared resource is idle, then the thread currently requesting the resource is set as a valid working thread, and the shared resource compareAndSetStateis set to the locked state through CAS; if the shared resource is occupied, a certain blocking and waiting wake-up mechanism is used ( FIFO deque of CLH variant) to guarantee lock allocation.

Reentrancy : Whether it is a fair lock or an unfair lock, the locking process will use a state value

private volatile int state

  • The state value is 0 when initialized, indicating that no thread holds the lock.
  • When a thread requests the lock, the state value will increase by 1. If the same thread acquires the lock multiple times, it will increase by 1 multiple times. This is the concept of reentrancy.
  • Unlocking also decrements the state value by 1 until it reaches 0, and this thread releases the lock.
public class LockExample {

    static int count = 0;
    static ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {

        Runnable runnable = new Runnable() {
            @Override
            public void run() {

                try {
                    // 加锁
                    lock.lock();
                    for (int i = 0; i < 10000; i++) {
                        count++;
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
                finally {
                    // 解锁,放在finally子句中,保证锁的释放
                    lock.unlock();
                }
            }
        };

        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("count: " + count);
    }
}

/**
 * 输出
 * count: 20000
 */

2. Mysql row lock, optimistic lock

Optimistic locking is a lock-free idea, which is generally implemented based on the CAS idea. In MySQL, optimistic locking is implemented through the version number + CAS lock-free form; for example, when two transactions T1 and T2 are executed concurrently, when the T2 transaction is executed After successful submission, the version will be +1, so the version condition of T1 transaction execution cannot be established.

Locking SQL statements and operating state machines can also avoid data inconsistencies caused by simultaneous access to the count value by different threads.

// 乐观锁 + 状态机
update
    table_name
set
    version = version + 1,
    count = count + 1
where
    id = id AND version = version AND count = [修改前的count值];

// 行锁 + 状态机
 update
    table_name
set
    count = count + 1
where
    id = id AND count = [修改前的count值]
for update;

3. Fine-grained ReetrantLock lock

If we directly use ReentrantLock to lock globally, then in this case one thread acquires the lock, and all threads in the entire program will be blocked when they come here; but in the project we want to implement mutual exclusion logic for each user during operation , so we need more fine-grained locks.

public class LockExample {
    private static Map<String, Lock> lockMap = new ConcurrentHashMap<>();
    
    public static void lock(String userId) {
        // Map中添加细粒度的锁资源
        lockMap.putIfAbsent(userId, new ReentrantLock());
        // 从容器中拿锁并实现加锁
        lockMap.get(userId).lock();
    }
    public static void unlock(String userId) {
        // 先从容器中拿锁,确保锁的存在
        Lock locak = lockMap.get(userId);
        // 释放锁
        lock.unlock();
    }
}

Disadvantages : If each user requests a shared resource, it will be locked once. The user will not log in to the platform again, but the lock object will always exist in the memory. This is equivalent to a memory leak, so the lock timeout and The elimination mechanism mechanism needs to be implemented.

4. Fine-grained Synchronized global lock

The above locking mechanism uses a lock container ConcurrentHashMap. For the sake of thread safety, Synchronizedthe mechanism is still used at the bottom layer. Therefore, in some cases, using lockMap requires adding two layers of locks.

So can we use it directly Synchronizedto implement a fine-grained locking mechanism ?

public class LockExample {
    public static void syncFunc1(Long accountId) {
        String lock = new String(accountId + "").intern();

        synchronized (lock) {

            System.out.println(Thread.currentThread().getName() + "拿到锁了");
            // 模拟业务耗时
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(Thread.currentThread().getName() + "释放锁了");
        }
    }

    public static void syncFunc2(Long accountId) {
        String lock = new String(accountId + "").intern();

        synchronized (lock) {

            System.out.println(Thread.currentThread().getName() + "拿到锁了");
            // 模拟业务耗时
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(Thread.currentThread().getName() + "释放锁了");
        }
    }

    // 使用 Synchronized 来实现更加细粒度的锁
    public static void main(String[] args) {
        new Thread(()-> syncFunc1(123456L), "Thread-1").start();
        new Thread(()-> syncFunc2(123456L), "Thread-2").start();
    }
}

/**
 * 打印
 * Thread-1拿到锁了
 * Thread-1释放锁了
 * Thread-2拿到锁了
 * Thread-2释放锁了
 */

  • From the code, we found that the object that implements locking is actually a string object related to the user ID. There may be questions here. Every time a new thread comes in, the new one is a new string object, but If the string contents are the same, how can we ensure that shared resources can be locked safely?
  • This actually needs to be attributed to the functions of the following intern()functions;
  • intern()The function is used to add a string to the string constant pool in the heap space at runtime. If the string already exists, return a reference to the string constant pool.

Implementation plan of locking under distributed architecture

Core problem : We need to find an area visible to all threads between multiple processes to define this mutex.

An excellent distributed lock implementation solution should meet the following characteristics:

  1. In a distributed environment, thread mutual exclusion between different processes can be guaranteed
  2. At the same time, only one thread is allowed to successfully acquire the lock resource.
  3. Where mutexes are guaranteed, high availability needs to be ensured
  4. To ensure that locks can be acquired and released with high performance
  5. Can support lock reentrancy for the same thread
  6. Have a reasonable blocking mechanism, and threads that fail to compete for locks must have corresponding solutions.
  7. Supports non-blocking lock acquisition. Threads that fail to acquire the lock can return directly
  8. Having a reasonable lock failure mechanism, such as timeout failure, etc., can ensure that deadlock situations are avoided.

Redis implements distributed lock

  • Redis is middleware and can be deployed independently;
  • It is visible to different Java processes, and the performance is also very impressive.
  • Rely on the instructions provided by redis itself setnx key valueto implement distributed locks; the difference from ordinary setinstructions is that the setting is successful only when the key does not exist, and the setting failure is returned when the key exists.

Code example:

// 扣库存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
    // 获取锁
    String lockKey = "lock-" + inventory.getInventoryId();
    int timeOut = 100;
    Boolean flag = stringRedisTemplate.opsForValue()
            .setIfAbsent(lockKey, "竹子-熊猫",timeOut,TimeUnit.SECONDS);
    // 加上过期时间,可以保证死锁也会在一定时间内释放锁
    stringRedisTemplate.expire(lockKey,timeOut,TimeUnit.SECONDS);
    
    if(!flag){
        // 非阻塞式实现
        return "服务器繁忙...请稍后重试!!!";
    }
    
    // ----只有获取锁成功才能执行下述的减库存业务----        
    try{
        // 查询库存信息
        Inventory inventoryResult =
            inventoryService.selectByPrimaryKey(inventory.getInventoryId());
        
        if (inventoryResult.getShopCount() <= 0) {
            return "库存不足,请联系卖家....";
        }
        
        // 扣减库存
        inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
        int n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
    } catch (Exception e) { // 确保业务出现异常也可以释放锁,避免死锁
        // 释放锁
        stringRedisTemplate.delete(lockKey);
    }
    
    if (n > 0)
        return "端口-" + port + ",库存扣减成功!!!";
    return "端口-" + port + ",库存扣减失败!!!";
}

作者:竹子爱熊猫
链接:https://juejin.cn/post/7038473714970656775

Reasonable analysis of expiration time:

Because for different businesses, the length of the expiration time we set will be different. If it is too long, it is inappropriate, and if it is too short, it is inappropriate;

So the solution we thought of was to set up a sub-thread to extend the life of the current lock resource. The specific implementation is that the sub-thread checks whether the key has expired every 2-3 seconds. If it has not expired, it means that the business thread is still executing the business, then 5 seconds are added to the expiration time of the key.

However, in order to prevent the main thread from accidentally dying, the sub-thread will continue to live for it, causing the phenomenon of "immortality lock", so the sub-thread is turned into the daemon thread of the main (business) thread, so that the sub-thread will follow the main thread. die.

// 续命子线程
public class GuardThread extends Thread { 
    private static boolean flag = true;

    public GuardThread(String lockKey, 
        int timeOut, StringRedisTemplate stringRedisTemplate){
        ……
    }

    @Override
    public void run() {
        // 开启循环续命
        while (flag){
            try {
                // 先休眠一半的时间
                Thread.sleep(timeOut / 2 * 1000);
            }catch (Exception e){
                e.printStackTrace();
            }
            // 时间过了一半之后再去续命
            // 先查看key是否过期
            Long expire = stringRedisTemplate.getExpire(
                lockKey, TimeUnit.SECONDS);
            // 如果过期了,代表主线程释放了锁
            if (expire <= 0){
                // 停止循环
                flag = false;
            }
            // 如果还未过期
            // 再为则续命一半的时间
            stringRedisTemplate.expire(lockKey,expire
                + timeOut/2,TimeUnit.SECONDS);
        }
    }
}


// 创建子线程为锁续命
GuardThread guardThread = new GuardThread(lockKey,timeOut,stringRedisTemplate);
// 设置为当前 业务线程 的守护线程
guardThread.setDaemon(true);
guardThread.start();

作者:竹子爱熊猫 
链接:https://juejin.cn/post/7038473714970656775

The problem of lock failure under Redis master-slave architecture

In order to ensure the high availability of Redis during the development process, the master-slave replication architecture will be used to separate reading and writing, thereby improving the throughput and availability of Redis. However, if a thread successfully acquires the lock on the redis master node, the master node crashes before it has time to copy to the slave node. At this time, another thread accesses redis and will access the slave node, and at the same time acquires the lock successfully. This Security issues may arise when accessing critical resources.

Solution:

  • Red lock algorithm (official solution): Multiple independent Redis writes data at the same time. Within the lock expiration time, if more than half of the machines succeed in writing, the lock acquisition success will be returned. When they fail, the successful machines will be released. lock. However, the disadvantage of this approach is that it is costly and requires independent deployment of multiple Redis nodes.
  • Additional recording of lock status: Record the lock status through other independently deployed middleware (such as DB). Before a new thread acquires the lock, it needs to query the lock holding record in the DB. As long as the lock status is not held, then Try to acquire distributed lock. However, the disadvantages of this situation are obvious. The process of acquiring the lock is difficult to implement and the performance overhead is also very high. In addition, it is necessary to cooperate with the timer function to update the lock status in the DB to ensure a reasonable lock failure mechanism.
  • Implemented using Zookepper

Zookeeper implements distributed locks

Zookeeper data is different from redis data. The data is synchronized in real time. After the master node writes, more than half of the nodes need to write before it returns successfully. Therefore, if projects such as e-commerce and education pursue high performance, they can give up a certain degree of stability, and it is recommended to use redis; for example, projects such as finance, banking, government, etc., pursue high stability and can sacrifice part of the performance. It is recommended Implemented using Zookeeper.

Distributed lock performance optimization

The above locking indeed solves the problem of thread safety in concurrency situations, but how should we solve the scenario where 1 million users rush to purchase 1,000 products at the same time?

  • You can warm up the shared resources in advance and store one copy in segments. The rush purchase time is 15:00 in the afternoon. The quantity of goods will be divided into 10 parts around 14:30 in advance, and each piece of data will be locked separately to prevent concurrency anomalies.
  • In addition, 10 keys need to be written in redis. Each new thread comes in and randomly allocates a lock, and then performs the subsequent inventory reduction logic. After completion, the lock is released for use by subsequent threads.
  • The idea of ​​this kind of distributed lock is to implement the function of multi-threaded synchronous access to shared resources that can be realized with one lock originally. In order to improve the access speed of multi-threads under instantaneous conditions, it is also necessary to ensure concurrency and safety. .

Reference article:

  1. https://juejin.cn/post/7236213437800890423

  2. https://juejin.cn/post/7038473714970656775

  3. https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html

Author: Jiao Zebin of JD Technology

Source: JD Cloud Developer Community Please indicate the source when reprinting

IntelliJ IDEA 2023.3 & JetBrains Family Bucket annual major version update new concept "defensive programming": make yourself a stable job GitHub.com runs more than 1,200 MySQL hosts, how to seamlessly upgrade to 8.0? Stephen Chow's Web3 team will launch an independent App next month. Will Firefox be eliminated? Visual Studio Code 1.85 released, floating window Yu Chengdong: Huawei will launch disruptive products next year and rewrite the history of the industry. The US CISA recommends abandoning C/C++ to eliminate memory security vulnerabilities. TIOBE December: C# is expected to become the programming language of the year. A paper written by Lei Jun 30 years ago : "Principle and Design of Computer Virus Determination Expert System"
{{o.name}}
{{m.name}}

Guess you like

Origin my.oschina.net/u/4090830/blog/10319213