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

探究 Spring Cloud 全局锁的实现

2021-11-241.9k 阅读

微服务架构下全局锁的重要性

在微服务架构中,随着业务的不断发展和系统规模的扩大,多个服务实例可能会同时对共享资源进行操作。为了保证数据的一致性和业务逻辑的正确性,就需要引入全局锁机制。全局锁能够在分布式环境下,对跨服务实例的资源访问进行有效控制,避免并发冲突。例如,在电商系统中,库存扣减操作可能会由多个订单服务实例同时触发,如果没有全局锁,就可能出现超卖的情况。

Spring Cloud 全局锁实现的常用方式

  1. 基于 Redis 的全局锁
    • 原理:Redis 是一个高性能的键值对存储数据库,它提供了一些原子操作,如 SETNX(Set if Not eXists)。基于 Redis 的全局锁就是利用 SETNX 命令来实现的。当一个服务实例尝试获取锁时,它会向 Redis 发送 SETNX 命令,如果该键不存在,则设置成功,代表获取到了锁;如果键已存在,则获取锁失败。
    • 代码示例
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class RedisLock {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public boolean tryLock(String key, String value, long expireTime) {
        Boolean result = redisTemplate.opsForValue().setIfAbsent(key, value);
        if (result != null && result) {
            // 设置锁的过期时间,防止死锁
            redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
            return true;
        }
        return false;
    }

    public void unlock(String key) {
        redisTemplate.delete(key);
    }
}
  • 优点:Redis 性能高,支持分布式部署,能够满足高并发场景下的全局锁需求。而且 Redis 的原子操作保证了锁的获取和释放的原子性。
  • 缺点:存在锁过期时间设置的问题。如果设置的过期时间过短,可能导致业务还未执行完锁就过期了,从而出现并发问题;如果设置过长,在锁持有者出现故障时,会导致其他实例长时间无法获取锁。另外,Redis 的主从复制可能会导致锁的短暂丢失,在主从切换过程中,如果主节点上的锁还未同步到从节点,而此时从节点晋升为主节点,新的主节点上就不存在该锁,可能会导致其他实例获取到重复的锁。
  1. 基于 ZooKeeper 的全局锁
    • 原理:ZooKeeper 是一个分布式协调服务,它以树形结构存储数据。基于 ZooKeeper 的全局锁是通过创建临时顺序节点来实现的。当一个服务实例尝试获取锁时,它会在 ZooKeeper 的某个指定节点下创建一个临时顺序节点。然后,获取该节点下所有子节点,并判断自己创建的节点是否是序号最小的节点。如果是,则获取到锁;否则,监听比自己序号小的前一个节点的删除事件,当前一个节点删除时,再次判断自己是否是最小序号节点,从而获取锁。
    • 代码示例
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 ZooKeeperLock {
    private static final String ZK_SERVERS = "localhost:2181";
    private static final int SESSION_TIMEOUT = 5000;
    private static final int CONNECTION_TIMEOUT = 3000;
    private static final String LOCK_PATH = "/lock";

    private CuratorFramework client;

    public ZooKeeperLock() {
        client = CuratorFrameworkFactory.builder()
               .connectString(ZK_SERVERS)
               .sessionTimeoutMs(SESSION_TIMEOUT)
               .connectionTimeoutMs(CONNECTION_TIMEOUT)
               .retryPolicy(new ExponentialBackoffRetry(1000, 3))
               .build();
        client.start();
    }

    public boolean tryLock(String lockKey, long time, TimeUnit unit) {
        InterProcessMutex mutex = new InterProcessMutex(client, LOCK_PATH + "/" + lockKey);
        try {
            return mutex.acquire(time, unit);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    public void unlock(String lockKey) {
        InterProcessMutex mutex = new InterProcessMutex(client, LOCK_PATH + "/" + lockKey);
        try {
            mutex.release();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
  • 优点:ZooKeeper 基于其数据一致性协议(ZAB 协议),能够保证锁的强一致性。而且通过临时节点的特性,在服务实例崩溃或网络故障时,锁会自动释放,避免死锁。
  • 缺点:由于 ZooKeeper 采用的是树形结构存储数据,在高并发场景下,频繁的节点创建和删除操作会给 ZooKeeper 服务器带来较大的压力。而且与 Redis 相比,ZooKeeper 的性能相对较低,因为它需要进行更多的网络交互和一致性协调操作。
  1. 基于数据库的全局锁
    • 原理:利用数据库的事务特性和唯一索引来实现全局锁。通常的做法是在数据库中创建一张锁表,表中包含一个唯一索引字段。当一个服务实例尝试获取锁时,它会向锁表中插入一条记录,如果插入成功,代表获取到了锁;如果因为唯一索引冲突插入失败,则获取锁失败。锁的释放则是通过删除这条记录来实现。
    • 代码示例
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

@Component
public class DatabaseLock {
    @Autowired
    private JdbcTemplate jdbcTemplate;

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

    public void unlock(String lockKey) {
        String deleteSql = "DELETE FROM lock_table WHERE lock_key =?";
        jdbcTemplate.update(deleteSql, lockKey);
    }
}
  • 优点:实现相对简单,对于已经使用数据库的项目来说,不需要额外引入其他中间件。而且数据库本身具备数据持久化的能力,能够保证锁的状态在重启后依然有效。
  • 缺点:性能相对较低,数据库的写入操作性能瓶颈会影响全局锁的获取和释放效率。同时,数据库事务的开销较大,如果锁的持有时间较长,会占用数据库连接资源,影响其他业务操作。另外,在高并发场景下,数据库的压力会显著增加,可能导致数据库性能下降甚至崩溃。

Spring Cloud 整合全局锁的实践

  1. Spring Cloud Alibaba Sentinel 与全局锁的结合
    • Sentinel 简介:Spring Cloud Alibaba Sentinel 是一个流量控制、熔断降级的框架。它可以在微服务架构中对服务的流量进行有效控制,保证系统的稳定性。
    • 结合方式:在一些需要对流量和资源访问同时进行控制的场景下,可以将 Sentinel 与全局锁结合使用。例如,在一个抢购业务中,首先通过 Sentinel 对请求流量进行限流,确保在高并发情况下系统不会因为过多的请求而崩溃。然后,对于通过限流的请求,使用全局锁来保证库存扣减等关键操作的原子性。
    • 示例代码
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class抢购Controller {
    @Autowired
    private RedisLock redisLock;

    @GetMapping("/抢购")
    public String抢购() {
        String key = "抢购锁";
        String value = System.currentTimeMillis() + "";
        try (Entry entry = SphU.entry("抢购资源")) {
            if (redisLock.tryLock(key, value, 10)) {
                try {
                    // 执行抢购业务逻辑,如库存扣减等
                    return "抢购成功";
                } finally {
                    redisLock.unlock(key);
                }
            } else {
                return "抢购失败,已无库存或锁获取失败";
            }
        } catch (BlockException e) {
            return "抢购失败,系统繁忙,请稍后重试";
        }
    }
}
  1. Spring Cloud Gateway 与全局锁的应用
    • Spring Cloud Gateway 简介:Spring Cloud Gateway 是 Spring Cloud 生态中的网关组件,它可以对请求进行路由、过滤等操作。
    • 应用场景:在微服务架构中,Spring Cloud Gateway 可以作为全局锁的前置防线。例如,对于一些需要进行身份验证和全局锁控制的请求,可以先通过 Gateway 的过滤器进行身份验证,验证通过后再尝试获取全局锁。如果获取锁成功,则将请求转发到相应的微服务;如果获取锁失败,则返回相应的错误信息。
    • 配置示例
spring:
  cloud:
    gateway:
      routes:
        - id: 资源路由
          uri: lb://资源服务
          predicates:
            - Path=/资源路径/**
          filters:
            - name: GlobalLockFilter
              args:
                lockKey: 资源锁键
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
public class GlobalLockFilter implements GlobalFilter, Ordered {
    @Autowired
    private RedisLock redisLock;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        String lockKey = "资源锁键";
        String value = System.currentTimeMillis() + "";
        if (redisLock.tryLock(lockKey, value, 10)) {
            try {
                return chain.filter(exchange);
            } finally {
                redisLock.unlock(lockKey);
            }
        } else {
            response.setStatusCode(HttpStatus.SERVICE_UNAVAILABLE);
            return response.setComplete();
        }
    }

    @Override
    public int getOrder() {
        return -1;
    }
}
  1. 在分布式事务中的全局锁应用
    • 分布式事务背景:在微服务架构中,一个业务操作可能会涉及多个微服务之间的数据交互,这就需要引入分布式事务来保证数据的一致性。例如,在一个订单创建的业务中,可能会涉及订单服务、库存服务、支付服务等多个服务。
    • 全局锁作用:在分布式事务中,全局锁可以用于保证事务操作的原子性和隔离性。以两阶段提交(2PC)为例,在准备阶段,各个参与事务的微服务可以先获取全局锁,确保在提交阶段不会有其他实例对相关资源进行修改。这样可以避免在分布式事务执行过程中出现数据不一致的情况。
    • 代码示例(以 Seata 分布式事务框架结合 Redis 全局锁为例)
import io.seata.spring.annotation.GlobalTransactional;
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 RedisLock redisLock;
    @Autowired
    private OrderService orderService;
    @Autowired
    private StockService stockService;
    @Autowired
    private PaymentService paymentService;

    @PostMapping("/createOrder")
    @GlobalTransactional
    public String createOrder(Order order) {
        String lockKey = "订单创建锁";
        String value = System.currentTimeMillis() + "";
        if (redisLock.tryLock(lockKey, value, 10)) {
            try {
                orderService.createOrder(order);
                stockService.deductStock(order.getProductId(), order.getQuantity());
                paymentService.pay(order.getOrderId(), order.getTotalAmount());
                return "订单创建成功";
            } finally {
                redisLock.unlock(lockKey);
            }
        } else {
            return "订单创建失败,锁获取失败";
        }
    }
}

全局锁实现中的常见问题及解决方案

  1. 死锁问题
    • 死锁原因:死锁是全局锁实现中常见的问题之一。在基于 Redis 的全局锁中,如果没有设置锁的过期时间,当持有锁的服务实例出现故障或异常,无法主动释放锁时,就会导致其他实例永远无法获取锁,从而形成死锁。在基于 ZooKeeper 的全局锁中,如果在监听节点删除事件时出现异常,导致监听逻辑无法正确执行,也可能出现死锁情况。
    • 解决方案:对于基于 Redis 的全局锁,设置合理的锁过期时间是关键。可以根据业务执行时间的预估来设置过期时间,例如,如果一个业务操作通常在 5 秒内完成,可以将锁的过期时间设置为 10 秒。同时,在业务代码中,要确保在获取锁后及时释放锁,无论业务执行成功还是失败。对于基于 ZooKeeper 的全局锁,要确保监听逻辑的健壮性,对可能出现的异常进行捕获和处理,保证监听能够正常工作,及时获取锁。
  2. 锁的性能问题
    • 性能瓶颈:在高并发场景下,全局锁的性能可能会成为系统的瓶颈。基于数据库的全局锁由于数据库的写入性能限制,在大量并发请求获取锁时,会导致数据库压力增大,响应时间变长。基于 ZooKeeper 的全局锁,由于频繁的节点创建和删除操作,也会给 ZooKeeper 服务器带来较大压力,影响锁的获取和释放效率。
    • 解决方案:对于基于数据库的全局锁,可以考虑使用数据库连接池来提高数据库连接的复用率,减少连接创建和销毁的开销。同时,可以对锁表进行分库分表操作,分散数据库压力。对于基于 ZooKeeper 的全局锁,可以优化 ZooKeeper 的配置,如调整会话超时时间、心跳间隔等参数,提高其性能。另外,可以采用缓存机制,将一些频繁访问的锁信息缓存到内存中,减少对 ZooKeeper 的直接访问。
  3. 锁的一致性问题
    • 一致性挑战:在分布式环境下,保证全局锁的一致性是一个挑战。基于 Redis 的主从复制模式下,可能会出现锁的短暂丢失问题,导致一致性被破坏。基于 ZooKeeper 的全局锁,虽然其自身具备较强的一致性保证,但在网络分区等异常情况下,也可能出现一致性问题。
    • 解决方案:对于基于 Redis 的全局锁,可以采用 Redlock 算法。Redlock 算法通过向多个独立的 Redis 实例获取锁,只有当大多数实例都获取到锁时,才认为获取锁成功。这样可以在一定程度上提高锁的一致性。对于基于 ZooKeeper 的全局锁,要确保 ZooKeeper 集群的稳定性,及时处理网络分区等异常情况。同时,可以通过增加 ZooKeeper 节点数量,提高其容错能力,保证锁的一致性。

不同场景下全局锁实现的选择策略

  1. 高并发且对性能要求极高的场景
    • 选择建议:在这种场景下,基于 Redis 的全局锁是一个较好的选择。Redis 的高性能和对分布式部署的支持,能够满足高并发情况下快速获取和释放锁的需求。例如,在一些实时性要求较高的金融交易系统中,大量的交易请求需要快速获取锁来保证交易的原子性,Redis 的高性能可以有效减少锁的获取时间,提高系统的吞吐量。
    • 优化措施:为了进一步提高性能,可以采用集群模式部署 Redis,利用 Redis Cluster 来分担负载,提高系统的可用性和性能。同时,合理设置锁的过期时间,避免因过期时间设置不当导致的并发问题。
  2. 对数据一致性要求极高的场景
    • 选择建议:基于 ZooKeeper 的全局锁更适合这种场景。ZooKeeper 的数据一致性协议(ZAB 协议)能够保证锁的强一致性,在一些对数据一致性要求极高的业务场景,如银行转账、库存管理等,使用 ZooKeeper 全局锁可以有效避免数据不一致的问题。
    • 优化措施:为了减轻 ZooKeeper 服务器的压力,可以对锁的使用进行优化,减少不必要的锁操作。例如,尽量合并一些可以批量处理的业务操作,减少锁的获取和释放次数。同时,合理配置 ZooKeeper 集群的节点数量和参数,提高其性能和稳定性。
  3. 系统简单且对成本敏感的场景
    • 选择建议:基于数据库的全局锁在这种场景下具有一定的优势。其实现相对简单,不需要额外引入复杂的中间件,对于一些规模较小、业务逻辑相对简单的系统来说,使用数据库全局锁可以降低开发和运维成本。
    • 优化措施:为了提高数据库全局锁的性能,可以对数据库进行优化,如创建合适的索引,优化 SQL 语句等。同时,合理控制锁的持有时间,避免长时间占用数据库连接资源。

总结不同全局锁实现方式的特点

  1. 基于 Redis 的全局锁
    • 性能:性能高,能够满足高并发场景下快速获取和释放锁的需求。
    • 一致性:在主从复制模式下存在一致性问题,但可以通过 Redlock 算法等方式提高一致性。
    • 实现复杂度:实现相对简单,利用 Redis 的原子操作即可实现。
    • 适用场景:适用于高并发且对性能要求极高的场景,如实时性要求较高的交易系统。
  2. 基于 ZooKeeper 的全局锁
    • 性能:性能相对较低,在高并发场景下频繁的节点操作会给 ZooKeeper 服务器带来压力。
    • 一致性:具备强一致性,能够有效保证数据的一致性。
    • 实现复杂度:实现相对复杂,需要对 ZooKeeper 的原理和操作有深入了解。
    • 适用场景:适用于对数据一致性要求极高的场景,如银行转账、库存管理等业务。
  3. 基于数据库的全局锁
    • 性能:性能较低,数据库的写入操作性能瓶颈会影响锁的效率。
    • 一致性:通过数据库的事务特性保证一致性。
    • 实现复杂度:实现简单,利用数据库的事务和唯一索引即可实现。
    • 适用场景:适用于系统简单且对成本敏感的场景,如小型业务系统。

通过对 Spring Cloud 全局锁不同实现方式的深入探究,我们可以根据具体的业务场景和需求,选择合适的全局锁实现方式,从而保证微服务架构下系统的稳定性、一致性和高性能。在实际应用中,还需要不断优化和调整全局锁的使用,以适应业务的发展和变化。