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

分布式协调中的分布式锁选型指南

2021-12-111.7k 阅读

分布式锁的基本概念

在分布式系统中,多个进程或服务可能需要访问共享资源。为了避免数据不一致或竞态条件,就需要引入分布式锁。分布式锁的核心功能与单机环境下的锁类似,即保证在同一时刻只有一个客户端能够获取到锁,从而安全地访问共享资源。

与单机锁不同的是,分布式锁需要跨越多个节点进行协调。这意味着它要解决网络延迟、节点故障等分布式系统特有的问题。例如,在一个电商系统中,库存是共享资源,多个订单处理服务可能同时尝试扣减库存。使用分布式锁可以确保同一时间只有一个服务能够执行扣减库存操作,避免超卖现象。

分布式锁的特性

  1. 互斥性:这是分布式锁最基本的特性,在任何时刻,只有一个客户端能持有锁。如果一个客户端获取到了锁,其他客户端在锁被释放前不能获取到相同的锁。
  2. 可重入性:同一个客户端在持有锁的情况下,可以再次获取锁而不会被阻塞。例如,一个递归调用的方法,在持有锁的过程中可能需要多次获取锁来执行递归操作。如果不支持可重入,可能会导致死锁。
  3. 高可用性:分布式锁服务应该具备高可用性,即使部分节点出现故障,也不应影响锁的正常使用。例如,使用多个节点组成的集群来提供锁服务,当某个节点故障时,其他节点仍能继续提供锁的获取和释放功能。
  4. 容错性:在网络分区、节点崩溃等异常情况下,分布式锁需要保证数据的一致性和可用性。例如,在网络分区时,不能出现两个不同分区中的客户端同时获取到相同的锁。

常见的分布式锁实现方案

  1. 基于数据库的分布式锁
    • 原理:通过在数据库中创建一个表,表中记录锁的信息,如锁的名称、持有锁的客户端标识等。当一个客户端尝试获取锁时,向表中插入一条记录,如果插入成功,则表示获取到锁;如果插入失败(因为唯一索引冲突等原因),则表示锁已被其他客户端持有。释放锁时,删除相应的记录。
    • 优点:实现简单,几乎所有的应用都使用数据库,不需要额外引入中间件。
    • 缺点:性能较低,每次获取和释放锁都需要进行数据库的读写操作,在高并发场景下容易成为性能瓶颈。而且如果持有锁的客户端崩溃而未及时释放锁,可能需要通过定时任务等方式来清理无效的锁记录。
    • 代码示例(以MySQL和Java为例)
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class DatabaseDistributedLock {
    private static final String URL = "jdbc:mysql://localhost:3306/yourdatabase";
    private static final String USER = "root";
    private static final String PASSWORD = "password";
    private static final String LOCK_TABLE = "distributed_locks";
    private static final String LOCK_NAME = "example_lock";
    private static final String CLIENT_ID = "client_1";

    public static boolean tryLock() {
        Connection connection = null;
        PreparedStatement statement = null;
        try {
            connection = DriverManager.getConnection(URL, USER, PASSWORD);
            String insertSql = "INSERT INTO " + LOCK_TABLE + " (lock_name, client_id) VALUES (?,?)";
            statement = connection.prepareStatement(insertSql);
            statement.setString(1, LOCK_NAME);
            statement.setString(2, CLIENT_ID);
            int result = statement.executeUpdate();
            return result == 1;
        } catch (SQLException e) {
            return false;
        } finally {
            if (statement != null) {
                try {
                    statement.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void unlock() {
        Connection connection = null;
        PreparedStatement statement = null;
        try {
            connection = DriverManager.getConnection(URL, USER, PASSWORD);
            String deleteSql = "DELETE FROM " + LOCK_TABLE + " WHERE lock_name =? AND client_id =?";
            statement = connection.prepareStatement(deleteSql);
            statement.setString(1, LOCK_NAME);
            statement.setString(2, CLIENT_ID);
            statement.executeUpdate();
        } catch (SQLException e) {
            e.printStackTrace();
        } finally {
            if (statement != null) {
                try {
                    statement.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
  1. 基于Redis的分布式锁
    • 原理:利用Redis的单线程特性和SETNX(SET if Not eXists)命令。SETNX命令在指定的键不存在时,为键设置指定的值。当一个客户端尝试获取锁时,使用SETNX命令设置一个特定的键值对,如果设置成功,则获取到锁;如果设置失败,说明锁已被其他客户端持有。释放锁时,删除相应的键。
    • 优点:性能高,Redis是基于内存的数据库,读写速度快,适合高并发场景。而且Redis的集群模式可以提供较高的可用性。
    • 缺点:如果使用普通的Redis单节点,存在单点故障问题。虽然可以通过Redis集群来解决,但集群的维护相对复杂。另外,如果持有锁的客户端崩溃而未及时释放锁,可能需要设置锁的过期时间来避免死锁,但这又可能导致锁提前释放的问题。
    • 代码示例(以Java和Jedis为例)
import redis.clients.jedis.Jedis;

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

    public static boolean tryLock(Jedis jedis) {
        String result = jedis.set(LOCK_KEY, LOCK_VALUE, "NX", "EX", EXPIRE_TIME);
        return "OK".equals(result);
    }

    public static void unlock(Jedis jedis) {
        jedis.del(LOCK_KEY);
    }
}
  1. 基于Zookeeper的分布式锁
    • 原理:Zookeeper是一个分布式协调服务,利用其树形结构和临时顺序节点的特性来实现分布式锁。当一个客户端尝试获取锁时,在Zookeeper的某个指定节点下创建一个临时顺序节点。然后获取该指定节点下所有的子节点,并判断自己创建的节点是否是序号最小的节点。如果是,则获取到锁;如果不是,则监听比自己序号小的前一个节点的删除事件。当监听到前一个节点被删除时,再次判断自己是否是最小序号的节点,若是则获取到锁。释放锁时,删除自己创建的临时节点。
    • 优点:可靠性高,Zookeeper采用了ZAB协议来保证数据的一致性和可用性。而且它的监听机制可以避免客户端频繁轮询锁的状态,减少不必要的开销。
    • 缺点:性能相对Redis较低,因为Zookeeper主要用于协调,其读写性能不如Redis。另外,Zookeeper的使用相对复杂,需要对其原理和API有深入的了解。
    • 代码示例(以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 ZookeeperDistributedLock {
    private static final String ZOOKEEPER_SERVERS = "localhost:2181";
    private static final String LOCK_PATH = "/example_lock";

    public static void main(String[] args) throws Exception {
        CuratorFramework client = CuratorFrameworkFactory.builder()
              .connectString(ZOOKEEPER_SERVERS)
              .retryPolicy(new ExponentialBackoffRetry(1000, 3))
              .build();
        client.start();
        InterProcessMutex lock = new InterProcessMutex(client, LOCK_PATH);
        try {
            if (lock.acquire(10, java.util.concurrent.TimeUnit.SECONDS)) {
                try {
                    // 获取到锁,执行业务逻辑
                    System.out.println("Got the lock");
                } finally {
                    lock.release();
                }
            } else {
                System.out.println("Failed to get the lock");
            }
        } finally {
            client.close();
        }
    }
}

分布式锁选型考虑因素

  1. 性能需求
    • 如果应用场景是高并发的读写操作,如电商的秒杀活动,Redis的分布式锁可能是更好的选择。因为Redis基于内存,读写速度极快,能够在短时间内处理大量的锁获取和释放请求。而Zookeeper虽然也能处理并发,但由于其设计理念更侧重于协调,性能相对Redis会低一些。基于数据库的分布式锁性能最低,在高并发场景下容易成为瓶颈,一般不适合此类场景。
    • 例如,在一个每秒有数千个请求的抢购系统中,使用Redis分布式锁可以快速响应锁的获取和释放操作,保证系统的高吞吐量。
  2. 可靠性要求
    • 对于可靠性要求极高的场景,如金融交易系统,Zookeeper的分布式锁可能更合适。Zookeeper通过ZAB协议保证数据的一致性和可用性,即使部分节点出现故障,也能确保锁的状态正确。而Redis在单节点模式下存在单点故障问题,虽然可以通过集群模式解决,但相比Zookeeper,其一致性保证相对较弱。数据库的分布式锁如果没有合适的故障处理机制,如持有锁的客户端崩溃后无法及时清理锁记录,可能会导致锁无法正常使用。
    • 比如在银行转账操作中,确保只有一个服务能够处理转账逻辑,Zookeeper的分布式锁可以提供更可靠的保障。
  3. 复杂度和维护成本
    • 基于数据库的分布式锁实现相对简单,对于已经使用数据库且对性能要求不是特别高的应用来说,是一个容易上手的选择。但其维护需要注意锁记录的清理等问题。
    • Redis的分布式锁使用相对简单,尤其是在单节点模式下。但如果使用集群模式,需要对Redis集群有一定的了解,以处理节点故障、数据同步等问题。
    • Zookeeper的分布式锁实现相对复杂,需要深入理解Zookeeper的原理和API,如临时顺序节点、监听机制等。其维护也需要更多的专业知识,例如对Zookeeper集群的配置和管理。
    • 如果团队对数据库操作熟悉,且应用场景并发量不高,基于数据库的分布式锁可能是一个低维护成本的选择。而如果团队有Redis或Zookeeper的使用经验,根据性能和可靠性需求选择相应的分布式锁方案会更合适。
  4. 业务场景特点
    • 如果业务对锁的持有时间较短,且对性能要求高,如一些缓存更新操作,Redis分布式锁可以满足需求。因为其快速的读写性能可以在短时间内完成锁的获取和释放。
    • 如果业务对锁的一致性和可靠性要求极高,且锁的持有时间相对较长,如分布式事务中的锁,Zookeeper分布式锁更能保证数据的正确性。
    • 对于一些简单的业务场景,如某些定时任务中防止重复执行,基于数据库的分布式锁可能就足以满足需求,且实现成本低。
    • 例如,在一个新闻网站的缓存更新逻辑中,为了避免多个服务器同时更新缓存,使用Redis分布式锁可以快速获取和释放锁,提高缓存更新的效率。

分布式锁的高级应用和注意事项

  1. 锁的续约
    • 在一些场景下,客户端获取锁后执行的业务逻辑可能比较耗时,超过了锁的过期时间。如果锁提前过期,可能会导致其他客户端获取到锁,从而引发数据不一致等问题。为了解决这个问题,可以采用锁续约机制。
    • 以Redis分布式锁为例,可以在客户端持有锁的过程中,启动一个定时任务,在锁过期时间的一半左右,检查锁是否仍然被自己持有。如果是,则使用SET命令延长锁的过期时间。
    • 代码示例(Java和Jedis)
import redis.clients.jedis.Jedis;

public class RedisLockRenewal {
    private static final String LOCK_KEY = "example_lock";
    private static final String LOCK_VALUE = "client_1";
    private static final int EXPIRE_TIME = 60; // 初始过期时间,单位秒
    private static final int RENEWAL_INTERVAL = 30; // 续约间隔时间,单位秒

    public static void main(String[] args) {
        Jedis jedis = new Jedis("localhost");
        if (tryLock(jedis)) {
            try {
                // 启动续约线程
                Thread renewalThread = new Thread(() -> {
                    while (true) {
                        try {
                            Thread.sleep(RENEWAL_INTERVAL * 1000);
                            if (isLockHeld(jedis)) {
                                jedis.expire(LOCK_KEY, EXPIRE_TIME);
                            }
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                            break;
                        }
                    }
                });
                renewalThread.setDaemon(true);
                renewalThread.start();

                // 执行业务逻辑
                System.out.println("执行业务逻辑,假设耗时较长");
                try {
                    Thread.sleep(120 * 1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } finally {
                unlock(jedis);
            }
        } else {
            System.out.println("未能获取到锁");
        }
        jedis.close();
    }

    private static boolean tryLock(Jedis jedis) {
        String result = jedis.set(LOCK_KEY, LOCK_VALUE, "NX", "EX", EXPIRE_TIME);
        return "OK".equals(result);
    }

    private static boolean isLockHeld(Jedis jedis) {
        String value = jedis.get(LOCK_KEY);
        return LOCK_VALUE.equals(value);
    }

    private static void unlock(Jedis jedis) {
        jedis.del(LOCK_KEY);
    }
}
  1. 分布式锁与分布式事务
    • 在分布式事务中,分布式锁起着重要的作用。例如,在一个涉及多个服务的转账操作中,需要保证各个服务的操作要么全部成功,要么全部失败。可以使用分布式锁来确保在执行事务的过程中,相关的共享资源不会被其他事务访问。
    • 以基于Zookeeper的分布式锁为例,在启动分布式事务时,先获取Zookeeper上的分布式锁。只有获取到锁的客户端才能执行事务中的各个操作。在事务提交或回滚后,释放锁。这样可以保证在同一时间只有一个事务能够操作相关的共享资源,避免数据不一致。
    • 但是,需要注意分布式锁与分布式事务的协调。例如,在事务执行过程中如果锁意外释放,可能会导致事务无法正常完成。因此,在设计分布式事务与分布式锁结合的方案时,要充分考虑各种异常情况,如网络故障、节点崩溃等,确保系统的一致性和可靠性。
  2. 锁的公平性
    • 在某些场景下,锁的公平性是很重要的。公平锁意味着先请求锁的客户端先获取到锁。基于Zookeeper的分布式锁天然具有公平性,因为它通过临时顺序节点来实现锁,客户端按照创建节点的顺序获取锁。
    • 而Redis的分布式锁默认是非公平的,即所有客户端都有相同的机会获取锁。在一些对公平性要求较高的场景,如资源分配场景,如果使用Redis分布式锁,可能需要额外的机制来实现公平性。例如,可以在获取锁时记录客户端的请求时间,在释放锁时按照请求时间的先后顺序让等待的客户端获取锁。
    • 例如,在一个分布式文件系统中,文件的访问权限分配可能需要公平锁,以确保先来的请求先获得文件的访问权限,使用Zookeeper分布式锁可以很好地满足这种需求。

不同场景下的最佳实践案例

  1. 电商秒杀场景
    • 需求分析:电商秒杀活动具有高并发、短时间内大量请求的特点。在秒杀过程中,需要保证库存的扣减操作准确无误,避免超卖现象。因此,对分布式锁的性能要求极高,同时要保证一定的可靠性。
    • 方案选择:Redis分布式锁是比较合适的选择。其基于内存的高速读写性能可以快速处理大量的锁获取和释放请求。为了提高可靠性,可以使用Redis集群模式,避免单点故障。
    • 实现要点:在实现过程中,要合理设置锁的过期时间,既要避免锁过期导致超卖,又要防止锁长时间不释放影响其他请求。可以采用前面提到的锁续约机制来保证锁在业务逻辑执行期间不会过期。同时,要处理好Redis集群中的数据同步问题,确保锁的状态在各个节点之间保持一致。
    • 代码示例(简化的Java和Jedis实现)
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisCluster;

public class SeckillRedisLock {
    private static final String LOCK_KEY = "seckill_lock";
    private static final String LOCK_VALUE = "client_" + System.currentTimeMillis();
    private static final int EXPIRE_TIME = 10; // 初始过期时间,单位秒

    public static boolean tryLock(JedisCluster jedisCluster) {
        String result = jedisCluster.set(LOCK_KEY, LOCK_VALUE, "NX", "EX", EXPIRE_TIME);
        return "OK".equals(result);
    }

    public static void unlock(JedisCluster jedisCluster) {
        jedisCluster.del(LOCK_KEY);
    }

    public static void main(String[] args) {
        // 假设已经创建好了JedisCluster对象
        JedisCluster jedisCluster = new JedisCluster(new HostAndPort("localhost", 7000));
        if (tryLock(jedisCluster)) {
            try {
                // 执行秒杀业务逻辑,如扣减库存
                System.out.println("执行秒杀业务逻辑,扣减库存");
            } finally {
                unlock(jedisCluster);
            }
        } else {
            System.out.println("未能获取到锁,秒杀失败");
        }
        jedisCluster.close();
    }
}
  1. 金融交易场景
    • 需求分析:金融交易对数据的一致性和可靠性要求极高,任何数据不一致都可能导致严重的后果。在交易过程中,可能涉及多个步骤和多个系统之间的协调,需要确保在同一时间只有一个交易操作能够对相关的账户等资源进行操作。
    • 方案选择:Zookeeper分布式锁更适合这个场景。其通过ZAB协议保证的一致性和可靠性能够满足金融交易的严格要求。虽然Zookeeper的性能相对Redis较低,但在金融交易场景中,可靠性更为重要。
    • 实现要点:在实现时,要充分利用Zookeeper的临时顺序节点和监听机制。例如,在开始一笔金融交易时,获取Zookeeper上的分布式锁,创建临时顺序节点。如果当前节点是最小序号的节点,则开始执行交易操作。在交易过程中,监听前一个节点的删除事件,以确保在其他交易完成后能够及时获取锁。交易完成后,及时删除自己创建的临时节点释放锁。
    • 代码示例(基于Curator的Java实现)
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 FinancialTransactionZkLock {
    private static final String ZOOKEEPER_SERVERS = "localhost:2181";
    private static final String LOCK_PATH = "/financial_transaction_lock";

    public static void main(String[] args) throws Exception {
        CuratorFramework client = CuratorFrameworkFactory.builder()
              .connectString(ZOOKEEPER_SERVERS)
              .retryPolicy(new ExponentialBackoffRetry(1000, 3))
              .build();
        client.start();
        InterProcessMutex lock = new InterProcessMutex(client, LOCK_PATH);
        try {
            if (lock.acquire(10, java.util.concurrent.TimeUnit.SECONDS)) {
                try {
                    // 执行金融交易业务逻辑
                    System.out.println("执行金融交易业务逻辑");
                } finally {
                    lock.release();
                }
            } else {
                System.out.println("未能获取到锁,交易失败");
            }
        } finally {
            client.close();
        }
    }
}
  1. 定时任务场景
    • 需求分析:在定时任务场景中,可能会存在多个服务器同时运行相同的定时任务,为了避免重复执行任务,需要使用分布式锁。这种场景对锁的性能要求不是特别高,但要求实现简单,维护成本低。
    • 方案选择:基于数据库的分布式锁是一个不错的选择。实现简单,只需要在数据库中创建相应的表来记录锁的状态。而且对于已经使用数据库的应用来说,不需要额外引入中间件。
    • 实现要点:在实现时,要注意处理好锁记录的清理问题。例如,可以在定时任务执行完成后及时删除锁记录,或者设置锁记录的过期时间,通过定时任务来清理过期的锁记录。同时,要考虑数据库的事务处理,确保锁的获取和释放操作在事务中执行,以保证数据的一致性。
    • 代码示例(以MySQL和Java为例)
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class ScheduledTaskDatabaseLock {
    private static final String URL = "jdbc:mysql://localhost:3306/yourdatabase";
    private static final String USER = "root";
    private static final String PASSWORD = "password";
    private static final String LOCK_TABLE = "scheduled_task_locks";
    private static final String LOCK_NAME = "example_scheduled_task_lock";
    private static final String CLIENT_ID = "server_1";

    public static boolean tryLock() {
        Connection connection = null;
        PreparedStatement statement = null;
        try {
            connection = DriverManager.getConnection(URL, USER, PASSWORD);
            String insertSql = "INSERT INTO " + LOCK_TABLE + " (lock_name, client_id) VALUES (?,?)";
            statement = connection.prepareStatement(insertSql);
            statement.setString(1, LOCK_NAME);
            statement.setString(2, CLIENT_ID);
            int result = statement.executeUpdate();
            return result == 1;
        } catch (SQLException e) {
            return false;
        } finally {
            if (statement != null) {
                try {
                    statement.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void unlock() {
        Connection connection = null;
        PreparedStatement statement = null;
        try {
            connection = DriverManager.getConnection(URL, USER, PASSWORD);
            String deleteSql = "DELETE FROM " + LOCK_TABLE + " WHERE lock_name =? AND client_id =?";
            statement = connection.prepareStatement(deleteSql);
            statement.setString(1, LOCK_NAME);
            statement.setString(2, CLIENT_ID);
            statement.executeUpdate();
        } catch (SQLException e) {
            e.printStackTrace();
        } finally {
            if (statement != null) {
                try {
                    statement.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        if (tryLock()) {
            try {
                // 执行定时任务业务逻辑
                System.out.println("执行定时任务业务逻辑");
            } finally {
                unlock();
            }
        } else {
            System.out.println("未能获取到锁,定时任务已在其他服务器执行");
        }
    }
}

通过对不同分布式锁方案的原理、特性、代码示例以及选型考虑因素和实际场景应用的介绍,希望能帮助开发者在分布式系统中更合理地选择和使用分布式锁,确保系统的稳定性和数据的一致性。