分布式锁解决超卖问题

在单体应用场景下处理多线程并发问题时,我们常会用到Synchronized和Lock锁。而在分布式场景中,则需要一种更高级的锁机制来处理跨机器的进程之间的数据同步问题,这就是分布式锁。

1. 商品超卖场景

分布式锁解决的最典型问题就是商品超卖问题了,即商品库存为1,但是由于并发下单,导致产生了多笔订单。

2. 单体应用下的解决方法

使用事务注解的坑

由于创建商品订单涉及扣减库存、创建订单两个操作,所以需要用到事务。
但是如果采用事务注解@Transactional,事务提交是在方法结束的时候执行,此时方法也会释放锁,导致并发的下一个线程会与事务提交并行执行,也会导致库存扣减异常。

2.1. Synchronized + 编程式事务


public Long createOrder() throws Exception {
    Product product = null;
    //synchronized (this) {
    //synchronized (object) {
    synchronized (DBOrderService2.class) {
        TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);
        product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product == null) {
            platformTransactionManager.rollback(transaction1);
            throw new Exception("购买商品:" + purchaseProductId + "不存在");
        }

        //商品当前库存
        Integer currentCount = product.getCount();
        System.out.println(Thread.currentThread().getName() + "库存数:" + currentCount);
        
        //校验库存
        if (purchaseProductNum > currentCount) {
            platformTransactionManager.rollback(transaction1);
            throw new Exception("商品" + purchaseProductId + "仅剩" + currentCount + "件,无法购买");
        }
		
		// 更新库存
        productMapper.updateProductCount(purchaseProductNum, new Date(), product.getId());
        platformTransactionManager.commit(transaction1);
    }

    TransactionStatus transaction2 = platformTransactionManager.getTransaction(transactionDefinition);

    Order order = new Order();
    // ... 省略 Set
    orderMapper.insertSelective(order);

    OrderItem orderItem = new OrderItem();
    // ... 省略 Set
    orderItemMapper.insertSelective(orderItem);
    platformTransactionManager.commit(transaction2);
    return order.getId();
}

2.2. Lock + 编程式事务


private Lock lock = new ReentrantLock();

public Long createOrder() throws Exception{  
    Product product = null;

    lock.lock();

    TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);
    try {
        product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){
            throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }

        //商品当前库存
        Integer currentCount = product.getCount();
        System.out.println(Thread.currentThread().getName()+"库存数:"+currentCount);
        
        //校验库存
        if (purchaseProductNum > currentCount){
            throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
        }
		
		// 更新库存
        productMapper.updateProductCount(purchaseProductNum,new Date(),product.getId());
        platformTransactionManager.commit(transaction1);
    } catch (Exception e) {
        platformTransactionManager.rollback(transaction1);
    } finally {
        lock.unlock(); // lock锁的释放需要放在finally中,确保异常情况下也能成功释放锁
    }

    TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition);
    Order order = new Order();
    // ... 省略 Set
    orderMapper.insertSelective(order);

    OrderItem orderItem = new OrderItem();
    // ... 省略 Set
    orderItemMapper.insertSelective(orderItem);
    platformTransactionManager.commit(transaction);
    return order.getId();
}

3. 分布式锁

当项目采用集群分布式部署,单机锁就会失效,此时需要采用分布式锁解决该问题。
常见的分布式锁的实现方式有如下几种:

3.1. 基于Innodb引擎的数据库

数据库实现分布式锁分两种:

3.1.1. select … for update

在这里插入图片描述

使用数据库表唯一键作为限制,向表中插入一条数据,抢锁的时候,使用select for update查询锁对应的key,如果查询到了,代表抢占锁成功,会给数据上表锁,此时其他线程的SQL执行会被阻塞。当这条数据被删除后,锁被释放。

// 加上事务就是为了 for update 的锁可以一直生效到事务执行结束
@Transactional(rollbackFor = Exception.class)
public String singleLock() throws Exception {
    log.info("我进入了方法!");
    DistributeLock distributeLock = distributeLockMapper.
        selectDistributeLock("demo");
    if (distributeLock==null) {
        throw new Exception("分布式锁找不到");
    }
    log.info("我进入了锁!");
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "我已经执行完成!";
}

<select id="selectDistributeLock" resultType="com.deltaqin.distribute.model.DistributeLock">
  select * from distribute_lock
  where businessCode = #{businessCode,jdbcType=VARCHAR}
  for update
</select>
3.1.2. insert lock

即维护一张锁表,插入数据代表获取锁,删除数据代表释放锁

@Autowired
private MethodlockMapper methodlockMapper;

@Override
public boolean tryLock() {
    try {
        //插入一条数据   insert into
        methodlockMapper.insert(new Methodlock("lock"));
    }catch (Exception e){
        //插入失败
        return false;
    }
    return true;
}

@Override
public void waitLock() {
    try {
        Thread.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

@Override
public void unlock() {
    //删除数据   delete
    methodlockMapper.deleteByMethodlock("lock");
    System.out.println("-------释放锁------");
}

3.2. 基于Redis的setnx

获取锁的命令

SET resource_name my_random_value NX PX 30000
  • source_name:资源名称,可根据不同的业务区分不同的锁
  • my_random_value:随机值,每个线程的随机值都不同,用于释放锁时的校验
  • NX:key不存在的时候设置成功,key存在的时候设置不成功(redis执行命令是单线程的,所以命令的执行是原子操作)
  • PX:自动失效时间(防止程序异常导致没释放锁),过期后锁会失效

获取锁的实现步骤

利用NX的原子性,多个线程并发时,只有一个线程可以设置成功
设置成功即获得锁,执行后续的业务处理
如果出现异常,过了锁的有效期,锁自动释放

释放锁的步骤

释放锁时校验之前设置的随机数,相同才释放(保证释放的是自己的锁)
释放锁采用LUA脚本(因为redis的delete命令不支持删除的时候校验值)

释放锁的LUA脚本

if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

封装基于redis的setnx实现的分布式锁对象 RedisLock

/**
 * 封装redis分布式锁
 * 实现AutoCloseable接口,重写close()方法,可以添加finally或关闭流的操作
 * @author kyrielx
 * @since 2023/2/6
 */
@Slf4j
public class RedisLock implements AutoCloseable {
    private RedisTemplate redisTemplate;
    private String key;
    private String value;
    private int expireTime; // 单位:秒

    public RedisLock(RedisTemplate redisTemplate, String key, int expireTime) {
        this.redisTemplate = redisTemplate;
        this.key = key;
        this.value = UUID.randomUUID().toString();
        this.expireTime = expireTime;
    }

    public boolean getLock(){
        RedisCallback<Boolean> redisCallback = redisConnection -> {
            // 设置NX
            RedisStringCommands.SetOption setOption = RedisStringCommands.SetOption.ifAbsent();
            // 设置EX
            Expiration expiration = Expiration.seconds(30);
            // 序列化key
            byte[] redisKey = redisTemplate.getKeySerializer().serialize(key);
            // 序列化value
            byte[] redisValue = redisTemplate.getValueSerializer().serialize(value);
            // 执行setnx操作
            Boolean result = redisConnection.set(redisKey, redisValue, expiration, setOption);
            return result;
        };

        // 获取分布式锁
        Boolean lock = (Boolean) redisTemplate.execute(redisCallback);
        return lock;
    }

    public boolean unLock(){
        String script = "if redis.call(\"get\", KEYS[1]) == ARGV[1] then\n" +
                "  return redis.call(\"del\", KEYS[1])\n" +
                "else\n" +
                "  return 0\n" +
                "end";
        RedisScript<Boolean> redisScript = RedisScript.of(script, Boolean.class);
        List<String> keys = Arrays.asList(key);
        Boolean result = (Boolean) redisTemplate.execute(redisScript, keys, value);
        log.info("释放锁的结果:" + result);
        return result;
    }

    // 关闭的操作(redis中写入的数据过期时会自动调用此方法,JDK1.7之后支持自动关闭)
    @Override
    public void close() throws Exception {
        unLock();
    }
}

在Springboot项目中使用RedisLock

  1. 先引入starter依赖
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. 在配置文件中,添加相关配置
spring.redis.host=localhost
  1. 使用(每次获取锁的时候,自己线程需要new一个对应的RedisLock)
public String redisLock(){
    log.info("我进入了方法!");
    try (RedisLock redisLock = new RedisLock(redisTemplate,"redisKey",30)){
        if (redisLock.getLock()) {
            log.info("我进入了锁!!");
            Thread.sleep(15000);
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (Exception e) {
        e.printStackTrace();
    }
    log.info("方法执行完成");
    return "方法执行完成";
}

3.3. 基于zk的临时节点+watcher监听机制

zk的临时节点会在客户端与zk连接的会话断开时自动删除
zk的临时节点不能有子节点
zk的临时节点创建后会得到有序的序列,每个节点都会有一个序号
zk的watcher机制只能监听一次,如果需要继续监听,可以自行设置添加watcher

基于zk的临时顺序节点实现分布式锁的原理

  1. 多线程并发创建瞬时节点的时候,得到有序的序列,序号最小的线程可以获得锁;
  2. 其他的线程监听自己序号的前一个序号。前一个线程执行结束之后删除自己序号的节点;
  3. 下一个序号的线程得到通知,继续执行;
  4. 以此类推,创建节点的时候,就确认了线程执行的顺序。

实现代码


/**
 * 如果创建的节点是第一个节点,就获得锁;否则监听自己的前序节点
 * 自己本身就是一个watcher,可以得到通知
 * AutoCloseable 资源不使用的时候,实现自动关闭
 */
@Slf4j
public class ZkLock implements AutoCloseable, Watcher {

    private ZooKeeper zooKeeper;

    /**
     * 记录当前锁的名字
     */
    private String znode;

    public ZkLock() throws IOException {
        this.zooKeeper = new ZooKeeper("localhost:2181",
                10000,this);
    }

    public boolean getLock(String businessCode) {
        try {
            //创建业务 根节点
            Stat stat = zooKeeper.exists("/" + businessCode, false);
            if (stat==null){
                zooKeeper.create("/" + businessCode,businessCode.getBytes(),
                        ZooDefs.Ids.OPEN_ACL_UNSAFE,
                        CreateMode.PERSISTENT);
            }

            //创建瞬时有序节点  /order/order_00000001
            znode = zooKeeper.create("/" + businessCode + "/" + businessCode + "_", businessCode.getBytes(),
                    ZooDefs.Ids.OPEN_ACL_UNSAFE,
                    CreateMode.EPHEMERAL_SEQUENTIAL);

            //获取业务节点下 所有的子节点
            List<String> childrenNodes = zooKeeper.getChildren("/" + businessCode, false);
            //获取序号最小的(第一个)子节点
            Collections.sort(childrenNodes);
            String firstNode = childrenNodes.get(0);
            //如果创建的节点是第一个子节点,则获得锁
            if (znode.endsWith(firstNode)){
                return true;
            }
            //如果不是第一个子节点,则监听前一个节点
            String lastNode = firstNode;
            for (String node:childrenNodes){
                if (znode.endsWith(node)){
                    zooKeeper.exists("/"+businessCode+"/"+lastNode,true);
                    break;
                }else {
                    lastNode = node;
                }
            }
            synchronized (this){
                wait();
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public void close() throws Exception {
        zooKeeper.delete(znode,-1);
        zooKeeper.close();
        log.info("我已经释放了锁!");
    }

    @Override
    public void process(WatchedEvent event) {
        if (event.getType() == Event.EventType.NodeDeleted){
            synchronized (this){
                notify();
            }
        }
    }
}

3.4. 基于curator客户端

  1. 引入curator客户端依赖
<dependency>
  <groupId>org.apache.curator</groupId>
  <artifactId>curator-recipes</artifactId>
  <version>4.2.0</version>
</dependency>
  1. 在启动类中创建bean
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Bean(initMethod = "start", destroyMethod = "close")
    public CuratorFramework getCuratorFramework(){
        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
        CuratorFramework client = CuratorFrameworkFactory.newClient("localhost:2181", retryPolicy);
        return client;
    }
}
  1. 直接使用其实现的分布式锁
@RestController
@Slf4j
public class ZkLockController {

    @Autowired
    private CuratorFramework client;

    /**
     * 使用基于zookeeper的curator客户端,实现分布式锁
     */
    @GetMapping("curatorLock")
    public String curatorLock(){
        log.info("我进入了方法!");
        InterProcessMutex lock = new InterProcessMutex(client, "/order");
        try {
            if ( lock.acquire(30, TimeUnit.SECONDS) ) {
                log.info("我获得了锁!");
                Thread.sleep(10000);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            try {
                lock.release();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        log.info("方法执行完成!");
        return "方法执行完成!";
    }

}

3.5. 基于redisson

Spring项目中使用

  1. 引入依赖
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.19.3</version>
</dependency>
  1. 测试用例
@Test
public void RedissonLockTest(){
    Config config = new Config();
    config.useSingleServer().setAddress("redis://127.0.0.1:6379");
    RedissonClient redissonClient = Redisson.create(config);
    RLock rLock = redissonClient.getLock("order");
    log.info("我进入了方法!");
    try {
        rLock.lock(30, TimeUnit.SECONDS);
        log.info("我获得了锁!");
        Thread.sleep(10000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        log.info("我释放了锁!");
        rLock.unlock();
    }
    log.info("方法执行完成!");
}

Springboot中使用

相当于通过引入redisson starter,简化了redissionClient的初始化过程。

  1. 引入依赖
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.11.2</version>
</dependency>
  1. 添加配置
# 单节点
redisson:
  single_server_config:
    password: null
    address: "redis://127.0.0.1:6379"


# 集群
redisson:
  sentinel-servers-config:
    master-name: "mymaster"
    sentinel-address:
      - "redis://192.168.2.170:26377"
      - "redis://192.168.2.170:26378"
      - "redis://192.168.2.170:26379"
    password: bxkc2016
  1. 使用
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class RedissonSpringBootStarterApplicationTests {
    
    @Autowired
    private RedissonClient redissonClient;

    @Test
    public void start() {
        RLock rLock = redissonClient.getLock("order");
        log.info("我进入了方法!");
        try {
            rLock.lock(30, TimeUnit.SECONDS);
            log.info("我获得了锁!");
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            log.info("我释放了锁!");
            rLock.unlock();
        }
        log.info("方法执行完成!");
    }
}

4. 总结

对于几种分布式锁的实现方案,进行优缺点的分析
在这里插入图片描述

推荐使用Redisson和Curator上实现的分布式锁
不推荐自己编码实现分布式锁

5. 参考资料

分布式锁实现原理与最佳实践 - 阿里云开发者(微信公众号)