MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

分布式系统中的分布式锁实现方式

2023-02-252.4k 阅读

分布式锁的重要性

在分布式系统中,多个节点可能会同时尝试访问和修改共享资源。例如,在一个电商系统中,库存是一个共享资源,多个订单处理服务可能同时尝试减少库存。如果没有适当的控制,可能会出现超卖等问题。分布式锁就是为了解决这类问题而引入的机制,它确保在分布式环境下,同一时间只有一个节点能够访问共享资源,就如同在单机环境中使用普通锁来保护临界区一样。

基于数据库的分布式锁实现方式

数据库表方式

  1. 原理: 通过在数据库中创建一张表,表中包含锁的标识(如业务相关的 key)、持有锁的节点信息、锁的过期时间等字段。当一个节点想要获取锁时,向表中插入一条记录,如果插入成功,说明获取锁成功;如果插入失败,说明锁已被其他节点持有。例如,假设有一个电商系统,不同的订单处理服务分布在多个节点上,要对库存进行操作,就可以使用这种方式来获取锁。当某个订单处理节点尝试插入与库存操作相关的锁记录时,若成功则可以进行库存减少操作,否则等待。
  2. 示例代码(以 MySQL 和 Java 为例)
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class DatabaseLock {
    private static final String URL = "jdbc:mysql://localhost:3306/your_database";
    private static final String USER = "your_username";
    private static final String PASSWORD = "your_password";

    public boolean tryLock(String lockKey, String clientId, long expireTime) {
        try (Connection connection = DriverManager.getConnection(URL, USER, PASSWORD)) {
            String insertSql = "INSERT INTO distributed_locks (lock_key, client_id, expire_time) " +
                    "VALUES (?,?,?) ON DUPLICATE KEY UPDATE client_id = VALUES(client_id), expire_time = VALUES(expire_time)";
            try (PreparedStatement insertStmt = connection.prepareStatement(insertSql)) {
                insertStmt.setString(1, lockKey);
                insertStmt.setString(2, clientId);
                insertStmt.setLong(3, expireTime);
                int rowsAffected = insertStmt.executeUpdate();
                return rowsAffected == 1;
            }
        } catch (SQLException e) {
            e.printStackTrace();
            return false;
        }
    }

    public void unlock(String lockKey, String clientId) {
        try (Connection connection = DriverManager.getConnection(URL, USER, PASSWORD)) {
            String deleteSql = "DELETE FROM distributed_locks WHERE lock_key =? AND client_id =?";
            try (PreparedStatement deleteStmt = connection.prepareStatement(deleteSql)) {
                deleteStmt.setString(1, lockKey);
                deleteStmt.setString(2, clientId);
                deleteStmt.executeUpdate();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    public boolean isLocked(String lockKey) {
        try (Connection connection = DriverManager.getConnection(URL, USER, PASSWORD)) {
            String selectSql = "SELECT COUNT(*) FROM distributed_locks WHERE lock_key =?";
            try (PreparedStatement selectStmt = connection.prepareStatement(selectSql)) {
                selectStmt.setString(1, lockKey);
                try (ResultSet resultSet = selectStmt.executeQuery()) {
                    if (resultSet.next()) {
                        return resultSet.getInt(1) > 0;
                    }
                }
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return false;
    }
}
  1. 优缺点
    • 优点:实现简单,对技术栈要求不高,只要会操作数据库就能实现。而且数据库本身有持久化功能,即使系统重启,锁的状态也能保留。
    • 缺点:性能较低,每次获取和释放锁都需要进行数据库的读写操作,在高并发场景下,数据库可能成为性能瓶颈。同时,锁的可靠性依赖于数据库的可用性,如果数据库出现故障,锁机制就会失效。另外,由于数据库操作的原子性基于事务,在某些情况下,事务的隔离级别可能会影响锁的正确性。

排他锁方式

  1. 原理: 利用数据库的排他锁(如 MySQL 中的 SELECT... FOR UPDATE 语句)来实现分布式锁。当一个节点执行 SELECT... FOR UPDATE 语句时,数据库会对满足条件的记录加上排他锁,其他节点如果再执行相同条件的 SELECT... FOR UPDATE 语句,就会被阻塞,直到持有锁的节点提交事务释放锁。例如,在一个分布式文件系统中,不同的节点可能同时尝试修改某个文件的元数据,通过这种方式可以确保同一时间只有一个节点能进行修改操作。
  2. 示例代码(以 MySQL 和 Java 为例)
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class DatabaseExclusiveLock {
    private static final String URL = "jdbc:mysql://localhost:3306/your_database";
    private static final String USER = "your_username";
    private static final String PASSWORD = "your_password";

    public boolean tryLock(String lockKey, String clientId) {
        Connection connection = null;
        try {
            connection = DriverManager.getConnection(URL, USER, PASSWORD);
            connection.setAutoCommit(false);
            String selectSql = "SELECT id FROM distributed_locks WHERE lock_key =? FOR UPDATE";
            try (PreparedStatement selectStmt = connection.prepareStatement(selectSql)) {
                selectStmt.setString(1, lockKey);
                try (ResultSet resultSet = selectStmt.executeQuery()) {
                    if (resultSet.next()) {
                        // 锁已被持有,可根据情况处理,这里简单返回失败
                        return false;
                    } else {
                        // 插入锁记录
                        String insertSql = "INSERT INTO distributed_locks (lock_key, client_id) VALUES (?,?)";
                        try (PreparedStatement insertStmt = connection.prepareStatement(insertSql)) {
                            insertStmt.setString(1, lockKey);
                            insertStmt.setString(2, clientId);
                            insertStmt.executeUpdate();
                            connection.commit();
                            return true;
                        }
                    }
                }
            }
        } catch (SQLException e) {
            if (connection!= null) {
                try {
                    connection.rollback();
                } catch (SQLException ex) {
                    ex.printStackTrace();
                }
            }
            e.printStackTrace();
            return false;
        } finally {
            if (connection!= null) {
                try {
                    connection.setAutoCommit(true);
                    connection.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public void unlock(String lockKey, String clientId) {
        Connection connection = null;
        try {
            connection = DriverManager.getConnection(URL, USER, PASSWORD);
            connection.setAutoCommit(false);
            String deleteSql = "DELETE FROM distributed_locks WHERE lock_key =? AND client_id =?";
            try (PreparedStatement deleteStmt = connection.prepareStatement(deleteSql)) {
                deleteStmt.setString(1, lockKey);
                deleteStmt.setString(2, clientId);
                deleteStmt.executeUpdate();
                connection.commit();
            }
        } catch (SQLException e) {
            if (connection!= null) {
                try {
                    connection.rollback();
                } catch (SQLException ex) {
                    ex.printStackTrace();
                }
            }
            e.printStackTrace();
        } finally {
            if (connection!= null) {
                try {
                    connection.setAutoCommit(true);
                    connection.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
  1. 优缺点
    • 优点:相比数据库表方式,在并发性能上有所提升,因为只需要一次数据库操作(先查询加锁,再插入记录)。同时,利用数据库的事务机制,保证了锁操作的原子性和一致性。
    • 缺点:依然依赖数据库,数据库故障会影响锁的可用性。并且长时间持有排他锁可能会导致其他节点长时间等待,容易引发死锁问题。另外,在高并发场景下,数据库的压力依然较大。

基于缓存的分布式锁实现方式

Redis 实现分布式锁

  1. 原理: Redis 是一个高性能的键值对存储系统,支持原子操作。利用 Redis 的 SETNX(SET if Not eXists)命令可以实现分布式锁。SETNX key value 命令会在键 key 不存在时,为键设置指定的值 value,并返回 1,表示设置成功;如果键 key 已经存在,则不做任何操作,返回 0,表示设置失败。在分布式系统中,不同节点尝试使用 SETNX 命令设置同一个键,如果某个节点设置成功,就表示该节点获取到了锁。为了防止死锁,通常会给锁设置一个过期时间。例如,在一个分布式任务调度系统中,不同的节点可能同时尝试获取任务执行权,就可以使用 Redis 锁来保证同一任务同一时间只有一个节点执行。
  2. 示例代码(以 Java 和 Jedis 为例)
import redis.clients.jedis.Jedis;

public class RedisLock {
    private static final String LOCK_KEY = "your_lock_key";
    private static final String LOCK_VALUE = "your_lock_value";
    private static final int EXPIRE_TIME = 10; // 锁过期时间,单位秒

    public boolean tryLock(Jedis jedis) {
        Long result = jedis.setnx(LOCK_KEY, LOCK_VALUE);
        if (result == 1) {
            // 设置成功,获取到锁,设置过期时间
            jedis.expire(LOCK_KEY, EXPIRE_TIME);
            return true;
        }
        return false;
    }

    public void unlock(Jedis jedis) {
        if (jedis.exists(LOCK_KEY)) {
            jedis.del(LOCK_KEY);
        }
    }
}
  1. 优缺点
    • 优点:性能高,Redis 是基于内存的,操作速度非常快,能够支持高并发场景。并且实现相对简单,利用 Redis 的基本命令即可完成。
    • 缺点:存在锁丢失的风险。例如,在设置锁成功后,还未来得及设置过期时间时,节点发生故障,锁就会一直存在,其他节点无法获取。另外,如果 Redis 采用主从复制架构,在主节点设置锁后,还未同步到从节点时主节点发生故障,从节点晋升为主节点,新的主节点没有该锁记录,其他节点就可能再次获取到锁,导致锁的安全性问题。

Redisson 实现分布式锁

  1. 原理: Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它对 Redis 的分布式锁进行了更完善的封装和扩展。Redisson 的分布式锁采用了 Lua 脚本保证操作的原子性,并且支持可重入锁、公平锁等特性。在获取锁时,Redisson 会向 Redis 发送 Lua 脚本,脚本中包含了复杂的锁获取逻辑,如检查锁是否存在、设置锁的持有时间、处理可重入等。例如,在一个分布式微服务系统中,不同的微服务可能需要调用共享资源,Redisson 可以提供可靠的分布式锁机制。
  2. 示例代码(以 Java 和 Redisson 为例)
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonLockExample {
    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        RedissonClient redissonClient = Redisson.create(config);
        RLock lock = redissonClient.getLock("your_lock_key");
        try {
            boolean isLocked = lock.tryLock();
            if (isLocked) {
                // 获取到锁,执行业务逻辑
                System.out.println("Got the lock, doing business...");
            } else {
                System.out.println("Failed to get the lock.");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
            redissonClient.shutdown();
        }
    }
}
  1. 优缺点
    • 优点:功能强大,支持多种类型的锁,如可重入锁、公平锁等,满足不同业务场景需求。同时,通过 Lua 脚本保证了操作的原子性,提高了锁的可靠性,解决了 Redis 原生实现中可能出现的锁丢失等问题。
    • 缺点:引入了 Redisson 框架,增加了项目的依赖和复杂度。并且 Redisson 的性能在一定程度上依赖于 Redis 的性能,虽然 Redis 本身性能较高,但在极端高并发场景下,依然可能存在性能瓶颈。

基于 ZooKeeper 的分布式锁实现方式

  1. 原理: ZooKeeper 是一个分布式协调服务,它基于树形结构存储数据。利用 ZooKeeper 的临时顺序节点特性可以实现分布式锁。当一个节点想要获取锁时,在 ZooKeeper 的某个指定目录下创建一个临时顺序节点。所有节点创建的临时顺序节点按照创建顺序编号,编号最小的节点表示获取到了锁。如果当前节点没有获取到锁,就监听比它编号小的前一个节点,当前一个节点删除时(即释放锁),ZooKeeper 会通知监听的节点,该节点就可以尝试获取锁。例如,在一个分布式数据同步系统中,不同的节点需要按照顺序同步数据,就可以使用这种锁机制来保证同步的有序性。
  2. 示例代码(以 Java 和 Curator 为例)
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;

public class ZooKeeperLockExample {
    private static final String ZK_SERVERS = "127.0.0.1:2181";
    private static final String LOCK_PATH = "/your_lock_path";

    public static void main(String[] args) {
        CuratorFramework client = CuratorFrameworkFactory.builder()
               .connectString(ZK_SERVERS)
               .retryPolicy(new ExponentialBackoffRetry(1000, 3))
               .build();
        client.start();
        InterProcessMutex lock = new InterProcessMutex(client, LOCK_PATH);
        try {
            boolean acquired = lock.acquire(10, java.util.concurrent.TimeUnit.SECONDS);
            if (acquired) {
                // 获取到锁,执行业务逻辑
                System.out.println("Got the lock, doing business...");
            } else {
                System.out.println("Failed to get the lock.");
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (lock.isAcquiredInThisProcess()) {
                    lock.release();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
            client.close();
        }
    }
}
  1. 优缺点
    • 优点:可靠性高,ZooKeeper 采用了集群模式,通过选举产生 leader 节点,即使部分节点故障,依然能够保证服务的可用性。并且由于采用临时顺序节点,锁的获取和释放具有顺序性,适合一些对顺序有要求的场景。
    • 缺点:性能相对较低,ZooKeeper 的写操作(如创建和删除节点)会涉及到集群间的同步,在高并发场景下,性能不如基于 Redis 的分布式锁。同时,ZooKeeper 的部署和维护相对复杂,需要考虑集群的搭建、节点的选举等问题。

分布式锁实现方式的对比与选择

  1. 性能对比: 基于缓存(如 Redis)的分布式锁性能最高,因为 Redis 基于内存操作,速度极快,能够满足高并发场景的需求。基于数据库的分布式锁性能较低,尤其是在高并发下,数据库的读写操作容易成为瓶颈。ZooKeeper 由于写操作需要集群同步,性能介于两者之间,但在高并发写场景下不如 Redis。
  2. 可靠性对比: ZooKeeper 的可靠性较高,其集群模式和节点选举机制保证了即使部分节点故障,服务依然可用。Redis 如果采用主从复制架构,可能存在锁丢失的风险,不过通过 Redisson 等框架可以在一定程度上提高可靠性。基于数据库的分布式锁可靠性依赖于数据库的可用性,数据库故障会导致锁机制失效。
  3. 功能特性对比: Redisson 对 Redis 进行封装后,支持多种类型的锁,如可重入锁、公平锁等,功能丰富。ZooKeeper 基于临时顺序节点,天然支持顺序锁等特性。基于数据库的分布式锁功能相对单一,主要实现基本的锁功能。
  4. 选择建议
    • 如果系统对性能要求极高,对锁的功能要求相对简单,且能够接受一定的锁丢失风险,可以选择基于 Redis 原生实现的分布式锁。
    • 如果系统对锁的功能要求丰富,如需要可重入锁、公平锁等,且对可靠性有一定要求,可以选择 Redisson 实现的分布式锁。
    • 如果系统对可靠性要求极高,对顺序有要求,如分布式任务调度需要按照顺序执行任务,且对性能要求不是极端高,可以选择基于 ZooKeeper 的分布式锁。
    • 如果系统技术栈简单,对性能要求不是特别高,且对数据库的依赖度较高,可以选择基于数据库的分布式锁。

在实际应用中,还需要根据系统的具体业务场景、性能需求、可靠性要求等多方面因素综合考虑,选择最适合的分布式锁实现方式。同时,无论选择哪种方式,都需要对可能出现的异常情况进行充分的考虑和处理,以确保分布式系统的稳定性和数据的一致性。