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

Spring Cloud 全局锁实现方法探究

2023-02-267.1k 阅读

一、Spring Cloud 微服务架构下全局锁的重要性

在 Spring Cloud 构建的微服务架构中,各个微服务可能会并发访问共享资源,例如数据库中的特定记录、文件系统中的共享文件等。当多个微服务同时对这些共享资源进行读写操作时,就可能引发数据不一致、资源竞争等问题。全局锁作为一种有效的协调机制,能够确保在同一时刻只有一个微服务实例可以访问共享资源,从而保证数据的一致性和系统的稳定性。

1.1 数据一致性保证

以电商系统为例,库存是典型的共享资源。当多个订单微服务同时处理下单请求时,如果没有全局锁,可能会出现超卖现象。假设库存初始值为 100,两个订单几乎同时读取库存,都读到 100,然后都进行减 1 操作,最后库存实际减少了 2,但系统可能只记录减少了 1,这就导致了数据不一致。而通过全局锁,在一个订单微服务获取锁处理库存操作时,其他订单微服务只能等待,直到锁被释放,这样就保证了库存操作的原子性,进而保证了数据的一致性。

1.2 分布式事务中的作用

在分布式事务场景下,全局锁也扮演着关键角色。比如在一个涉及多个微服务的转账操作中,需要从账户 A 扣钱,同时向账户 B 加钱。这涉及到两个不同微服务对各自数据库的操作。为了保证整个转账操作的原子性,即要么全部成功,要么全部失败,就需要全局锁。在执行转账操作前,先获取全局锁,确保在同一时刻只有一个线程在进行这个分布式事务操作,避免并发问题导致的部分成功部分失败情况。

二、基于 Redis 的 Spring Cloud 全局锁实现

Redis 是一个高性能的键值对存储数据库,由于其单线程模型和丰富的数据结构,非常适合用于实现全局锁。在 Spring Cloud 项目中,可以借助 Spring Data Redis 来方便地与 Redis 交互。

2.1 基本原理

Redis 实现全局锁主要基于 SETNX 命令(SET if Not eXists)。该命令在键不存在时,将键的值设为指定值,若键已存在,该命令不做任何操作,并返回 0。利用这一特性,当一个微服务实例尝试获取锁时,使用 SETNX 命令设置一个特定的键值对,如果返回 1,表示获取锁成功;返回 0,则表示锁已被其他实例获取,获取锁失败。

2.2 代码示例

首先,在 pom.xml 文件中添加 Spring Data Redis 的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

然后配置 Redis 连接信息,在 application.yml 文件中:

spring:
  redis:
    host: 127.0.0.1
    port: 6379

接下来创建一个全局锁工具类 RedisLockUtil

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisLockUtil {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public boolean tryLock(String lockKey, String requestId, long expireTime) {
        Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId);
        if (result != null && result) {
            redisTemplate.expire(lockKey, expireTime, TimeUnit.SECONDS);
            return true;
        }
        return false;
    }

    public void unlock(String lockKey, String requestId) {
        if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
            redisTemplate.delete(lockKey);
        }
    }
}

在上述代码中,tryLock 方法尝试获取锁,lockKey 是锁的唯一标识,requestId 用于标识当前请求,避免误删其他实例的锁。expireTime 是锁的过期时间,防止因程序异常导致锁永远不释放。unlock 方法用于释放锁,先判断当前请求的 requestId 是否与锁的持有者一致,一致则删除锁。

2.3 实际应用场景

在微服务的订单创建流程中,假设订单号生成是一个共享资源,需要保证唯一。可以在生成订单号前获取全局锁:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {

    @Autowired
    private RedisLockUtil redisLockUtil;

    @PostMapping("/createOrder")
    public String createOrder() {
        String lockKey = "order_number_lock";
        String requestId = "123456";
        if (redisLockUtil.tryLock(lockKey, requestId, 10)) {
            try {
                // 生成订单号逻辑
                String orderNumber = generateOrderNumber();
                return "订单创建成功,订单号:" + orderNumber;
            } finally {
                redisLockUtil.unlock(lockKey, requestId);
            }
        } else {
            return "获取锁失败,请稍后重试";
        }
    }

    private String generateOrderNumber() {
        // 实际生成订单号逻辑
        return "20231010123456";
    }
}

在这个例子中,当有多个请求尝试创建订单时,只有一个请求能获取到锁并生成订单号,避免了订单号重复的问题。

2.4 Redis 全局锁的问题与解决方案

2.4.1 锁的误释放

如果一个微服务实例获取锁后,因某些原因(如网络抖动、GC 停顿等)导致锁过期,而此时该实例还未完成业务操作,其他实例就可能获取到锁。当原实例恢复后,它会尝试释放锁,这就可能误释放其他实例的锁。解决方案是在释放锁时使用 Lua 脚本,保证释放锁操作的原子性。Lua 脚本可以将判断锁持有者和删除锁的操作合并为一个原子操作,避免误释放。

2.4.2 单点故障

如果 Redis 实例出现故障,全局锁机制将无法正常工作。为了解决这个问题,可以采用 Redis 集群方案,如 Redis Sentinel 或 Redis Cluster。Redis Sentinel 可以监控 Redis 主节点的状态,当主节点出现故障时,自动将一个从节点提升为主节点,保证服务的可用性。Redis Cluster 则提供了分布式的 Redis 解决方案,数据在多个节点间分布,提高了整体的可靠性和性能。

三、基于 ZooKeeper 的 Spring Cloud 全局锁实现

ZooKeeper 是一个分布式协调服务,它的树形数据结构和 Watch 机制使其成为实现全局锁的另一个有力选择。

3.1 基本原理

ZooKeeper 实现全局锁基于临时顺序节点。当一个微服务实例尝试获取锁时,它会在 ZooKeeper 的特定路径下创建一个临时顺序节点。ZooKeeper 会为每个新创建的节点分配一个唯一的序号,序号最小的节点代表获取到了锁。其他节点通过 Watch 机制监听比自己序号小的节点的删除事件,一旦前一个节点删除(即释放锁),下一个序号最小的节点就会收到通知并获取锁。

3.2 代码示例

首先,添加 ZooKeeper 客户端依赖,在 pom.xml 中:

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.3.0</version>
</dependency>
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>5.3.0</version>
</dependency>

然后配置 ZooKeeper 连接信息,在 application.yml 文件中:

zookeeper:
  connect-string: 127.0.0.1:2181

创建一个 ZooKeeper 全局锁工具类 ZkLockUtil

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;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class ZkLockUtil {

    private static final String LOCK_PATH = "/global_lock";
    private CuratorFramework client;
    private InterProcessMutex mutex;

    public ZkLockUtil() {
        client = CuratorFrameworkFactory.builder()
               .connectString("127.0.0.1:2181")
               .retryPolicy(new ExponentialBackoffRetry(1000, 3))
               .build();
        client.start();
        mutex = new InterProcessMutex(client, LOCK_PATH);
    }

    public boolean tryLock(long timeout, TimeUnit unit) {
        try {
            return mutex.acquire(timeout, unit);
        } catch (Exception e) {
            return false;
        }
    }

    public void unlock() {
        try {
            mutex.release();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,CuratorFramework 是 ZooKeeper 的客户端框架,InterProcessMutex 是 Curator 提供的用于实现分布式锁的类。tryLock 方法尝试获取锁,unlock 方法用于释放锁。

3.3 实际应用场景

在分布式文件上传系统中,可能需要对文件存储目录进行操作,确保同一时刻只有一个微服务实例在写入文件。可以使用 ZooKeeper 全局锁来实现:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;

@RestController
public class FileUploadController {

    @Autowired
    private ZkLockUtil zkLockUtil;

    @PostMapping("/uploadFile")
    public String uploadFile(MultipartFile file) {
        if (zkLockUtil.tryLock(10, TimeUnit.SECONDS)) {
            try {
                File targetFile = new File("/uploads/" + file.getOriginalFilename());
                file.transferTo(targetFile);
                return "文件上传成功";
            } catch (IOException e) {
                return "文件上传失败";
            } finally {
                zkLockUtil.unlock();
            }
        } else {
            return "获取锁失败,请稍后重试";
        }
    }
}

在这个例子中,当有多个文件上传请求时,通过 ZooKeeper 全局锁保证同一时刻只有一个请求可以写入文件,避免文件覆盖等问题。

3.4 ZooKeeper 全局锁的特点与问题

3.4.1 可靠性高

ZooKeeper 采用了 Zab 协议保证数据的一致性和可靠性。在集群模式下,即使部分节点出现故障,只要过半节点正常工作,整个集群仍然可以正常提供服务,保证全局锁的正常使用。

3.4.2 性能问题

与 Redis 相比,ZooKeeper 在处理高并发锁请求时性能相对较低。因为每次获取锁和释放锁都需要与 ZooKeeper 集群进行多次交互,包括创建节点、监听节点变化等操作,这会带来一定的网络开销和处理延迟。此外,ZooKeeper 对于节点数量也有一定限制,当节点过多时,性能会进一步下降。

四、基于数据库的 Spring Cloud 全局锁实现

数据库也是实现全局锁的一种选择,尤其对于已经依赖关系型数据库的 Spring Cloud 项目来说,利用数据库实现全局锁可以减少额外的技术栈引入。

4.1 基本原理

基于数据库实现全局锁主要有两种方式:基于表记录和基于排他锁。基于表记录的方式是在数据库中创建一张锁表,表中包含锁的唯一标识和持有锁的实例信息。当一个微服务实例尝试获取锁时,向锁表插入一条记录,如果插入成功,表示获取锁成功;否则获取锁失败。基于排他锁的方式是利用数据库的事务和锁机制,在事务中对特定数据行或表加排他锁,从而实现全局锁。

4.2 基于表记录的代码示例

首先,创建锁表 lock_table

CREATE TABLE lock_table (
    lock_key VARCHAR(255) PRIMARY KEY,
    lock_holder VARCHAR(255),
    lock_time TIMESTAMP
);

然后,在 Spring Boot 项目中,使用 JdbcTemplate 来操作数据库。添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

创建数据库锁工具类 DbLockUtil

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import java.sql.Timestamp;
import java.util.Date;

@Component
public class DbLockUtil {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public boolean tryLock(String lockKey, String lockHolder) {
        String insertSql = "INSERT INTO lock_table (lock_key, lock_holder, lock_time) VALUES (?,?,?)";
        try {
            jdbcTemplate.update(insertSql, lockKey, lockHolder, new Timestamp(new Date().getTime()));
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public void unlock(String lockKey, String lockHolder) {
        String deleteSql = "DELETE FROM lock_table WHERE lock_key =? AND lock_holder =?";
        jdbcTemplate.update(deleteSql, lockKey, lockHolder);
    }
}

在上述代码中,tryLock 方法尝试插入锁记录获取锁,unlock 方法删除锁记录释放锁。

4.3 基于排他锁的代码示例

以 MySQL 为例,假设数据库中有一张 resource_table 表,需要对其中特定 id 的记录加锁:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class DbExclusiveLockUtil {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Transactional
    public void operateResource(int resourceId) {
        String selectForUpdateSql = "SELECT * FROM resource_table WHERE id =? FOR UPDATE";
        jdbcTemplate.queryForObject(selectForUpdateSql, new Object[]{resourceId}, (rs, rowNum) -> {
            // 这里可以进行对资源的操作
            return null;
        });
        // 操作完成,事务提交时锁自动释放
    }
}

在上述代码中,SELECT... FOR UPDATE 语句对 resource_table 表中指定 id 的记录加排他锁,直到事务结束,保证同一时刻只有一个事务可以操作该记录。

4.4 数据库全局锁的优缺点

4.4.1 优点

  • 简单直接:对于已经使用数据库的项目,无需引入新的中间件,利用现有的数据库操作知识和工具即可实现全局锁。
  • 数据一致性:基于数据库事务机制,能够很好地保证与数据库相关的操作的一致性。

4.4.2 缺点

  • 性能瓶颈:数据库通常不是为高并发锁操作设计的,在高并发场景下,锁竞争会导致数据库性能下降,甚至出现死锁。
  • 锁的粒度:基于表记录的方式锁粒度较粗,可能会影响系统的并发性能;基于排他锁的方式虽然可以控制到行级锁,但如果使用不当,也可能引发性能问题。

五、不同实现方式的比较与选择

5.1 性能比较

  • Redis:由于 Redis 是基于内存的高性能存储,并且单线程模型减少了线程上下文切换开销,在处理高并发锁请求时性能表现出色。尤其适用于对性能要求极高,且锁操作频繁的场景。
  • ZooKeeper:ZooKeeper 的性能相对 Redis 较低,因为每次锁操作都涉及与集群的交互,包括创建节点、监听事件等,网络开销较大。不过在可靠性要求极高,对性能要求相对不那么极致的场景下,ZooKeeper 是不错的选择。
  • 数据库:数据库在处理高并发锁请求时性能最差,因为数据库主要用于持久化存储和复杂查询,锁操作会带来较大的性能开销,容易成为系统瓶颈。

5.2 可靠性比较

  • ZooKeeper:采用 Zab 协议保证数据一致性和可靠性,在集群模式下,只要过半节点正常工作,就能保证服务可用,可靠性非常高。
  • Redis:通过 Redis Sentinel 或 Redis Cluster 可以提高可靠性,但相比 ZooKeeper,在极端情况下(如脑裂问题)可能会出现数据不一致的风险。
  • 数据库:数据库本身具有较高的可靠性,通过主从复制、集群等技术可以进一步提高。但在高并发锁场景下,可能会出现死锁等问题影响可靠性。

5.3 适用场景选择

  • 高并发且性能优先场景:如果项目对性能要求极高,如秒杀系统、高频交易系统等,Redis 全局锁是首选。它能够快速处理大量的锁请求,保证系统的高并发性能。
  • 可靠性要求极高场景:在分布式系统中,一些关键操作如分布式事务协调、配置中心等,对可靠性要求极高,ZooKeeper 全局锁更为合适。它能确保在各种复杂情况下,锁机制的稳定运行。
  • 简单场景且已依赖数据库:对于一些简单的微服务场景,且项目已经深度依赖关系型数据库,基于数据库的全局锁实现方式可以减少额外的技术栈引入,降低系统复杂度。

在实际的 Spring Cloud 项目中,应根据具体的业务需求、性能要求和系统架构来综合选择合适的全局锁实现方式,以确保系统的高效、稳定运行。