架构设计

微服务架构设计- 13分布式缓存

2024-01-18  本文已影响0人  holmes000

锁是一种在并发编程中广泛使用的工具,用于保护共享资源,防止多个线程同时访问而引起的竞争问题。在JVM的发展中,锁机制逐渐演化,提供了多种锁类型和优化方式。

当前,我们可以通过不同类型的锁来满足不同的需求:

JVM还通过引入偏向锁、轻量级锁和重量级锁等机制来优化锁的性能,以满足不同并发场景的需求。

此外,通过使用CAS原子操作包(java.util.concurrent.atomic),我们可以实现无锁编程,也称为乐观锁,以提高并发性能。

在并发编程中,我们经常需要确保多个线程安全地访问共享资源。在一个简单的扣款示例中,我们首先检查账户余额是否足够以执行扣款操作。这种情况下,我们可以使用不同类型的锁来确保线程安全性,如公平锁、非公平锁、可重入锁等。另外,使用CAS原子操作可以实现无锁编程,提高并发性能。这种多样性的锁机制和优化方式使得我们能够根据具体需求选择最适合场景的锁策略,以保证程序的正确性和性能。

def balance = db.account.getBalance(id)
if (balance < amount) {
    return error("余额少于扣款金额")
}
db.account.updateBalance(id, -amount)

假定账户余额100元,有两个并发请求同时扣款,在没有锁的情况可能会导致余额为负数:


image.png

简单做法使用synchronized加锁,如:

// 定义一个对象用于作为锁
def lock = new Object()
// ...
// 在需要同步的地方使用 synchronized 块
synchronized (lock) {
    def balance = db.account.getBalance(id)
    if (balance >= amount) {
        db.account.updateBalance(id, -amount)
    } else {
        return error("余额少于扣款金额")
    }
}

在分布式系统中,跨多个节点实现分布式锁确实面临更大的挑战,包括性能、一致性、可靠性等方面的考虑。在探讨分布式锁之前,强调了使用锁时需要明确其必要性,因为锁可能导致并行逻辑转化为串行,从而影响性能,同时还需要考虑锁的容错性,防止死锁的发生。
以下是两种替代分布式锁方案:

// 从数据库中获取账户余额和版本号
def (balance, currentVersion) = db.account.getBalanceAndVersion(id)
// 检查余额是否足够扣款
if (balance < amount) {
    return error("余额少于扣款金额")
}
// 执行扣款操作,更新余额和版本号
// 对应的SQL: UPDATE account SET balance = balance - <amount>, update_version = update_version + 1 WHERE id = <id> AND update_version = <currentVersion>
if (db.account.updateBalance(id, -amount, currentVersion) == 0) {
    return error("扣款失败") // 或递归执行此代码进行重试
}

在某些情况下,可能不得不使用锁,尤其是在需要同时锁定多个对象(例如用户、订单、商品、SKU等)时,锁可能成为更好的选择。当然,前提是需要审查业务操作的合理性,以及系统设计是否存在缺陷。在这种情况下,锁可以用来确保操作的原子性和一致性。
此外,在高并发的场景中,悲观锁可能比乐观锁更有效率。悲观锁在整个操作期间锁住资源,防止其他线程进行并发修改,适用于并发量较高且需要保持数据一致性的情况。

如果需要引入分布式锁,必须注意以下问题:

  1. 锁的范围: 确定锁的粒度和范围,以防止过度锁定或过度细粒度的问题。
  2. 分布式锁的实现: 选择合适的分布式锁实现,例如基于数据库、ZooKeeper、Redis等的分布式锁。
  3. 性能和延迟:分布式锁引入了网络通信和远程节点的延迟,可能影响系统的性能。需要仔细考虑性能和延迟的平衡。
  4. 死锁和容错:考虑锁的容错机制,防止死锁的发生,并确保系统在各种异常情况下的可靠性。

1. 锁释放与超时

正常情况下,只要我们在使用完锁后在finally中加上锁释放的代码就可以了,比如下面的代码:

val lock=new Lock()
  if(lock.tryLock()){
      try{
          // 业务处理
      }catch(Exception ex){
          // 业务异常处理
      }finally{
          //释放锁
          lock.unlock();
      }
  }

在单机环境中,我们通常会使用try-finally块确保每次加锁都能正确释放,即使在业务处理或异常处理中发生了OOM也不会导致死锁。然而,在分布式环境中,由于网络不可靠、节点宕机等原因,可能出现无法正常释放锁的情况。例如,OOM发生时可能导致锁对象被持有,正常执行了unlock代码但网络传输时丢失了unlock信号也可能导致死锁。

为了在分布式环境中更可靠地处理锁的释放,我们需要考虑以下因素:

  1. 超时机制:
    在分布式环境中,引入超时机制是很常见的做法。通过使用tryLock(<等待锁的时长>, <锁占用的最大时长>),我们可以设置一个等待锁的时长和锁占用的最大时长。这样即使锁的释放发生异常,也能在一定时间后自动释放,避免死锁的发生。需要注意平衡等待时间和最大占用时间的设定,以兼顾性能和可靠性。
  2. 心跳超时设置:
    更优雅但复杂的方法是使用心跳机制。占有锁的服务与锁服务保持心跳,一旦心跳超时,说明锁服务可能存在问题,占有锁的服务可以主动释放锁。这样的机制可以更精准地检测到锁的状态,但需要额外的实现和管理。

这两种方式都旨在提高在分布式环境中处理锁释放的可靠性,以防止死锁的发生。在选择适当的方式时,需要根据系统需求、性能要求以及维护成本来做出权衡。

2. 性能及高可用

在分布式系统中,通常会选择非公平锁以提高性能。然而,如果需要保证加锁顺序并选择使用公平锁,就需要谨慎考虑对性能的影响。加解锁操作本身必须保证高性能和可用性,避免成为系统的单点故障。此外,锁的信息应当被持久化,而使用自旋时需要慎重,以避免浪费CPU资源。

3. 数据一致性

确保数据一致性在分布式锁的设计中至关重要。为了实现这一目标,需要合理设置锁标记以区分不同实例和线程的操作。对于可重入锁,必须确保计数正确,并在每一次解锁时适当减少计数。此外,为了维护数据的一致性,选择支持CP特性(一致性和分区容忍性)的服务作为分布式锁的中间件是至关重要的。

1. 设置锁标记以区分实例和线程:
在设计分布式锁时,必须合理设置锁标记,以便清楚地区分是哪个实例、哪个线程在进行操作。这样可以确保锁的正确性和精确性。
2. 可重入锁计数和解锁次数:
对于可重入锁,需要做好计数,确保每一次加锁都能正确计数,而每一次解锁都能适当减少计数。这是为了防止在嵌套调用中出现错误。
3. 选择CP特性的服务作为中间件:
为了保证数据一致性,选择支持CP特性(一致性和分区容忍性)的服务作为分布式锁的中间件是至关重要的。CP特性确保了在网络分区的情况下仍能保持一致性,尽管可能会牺牲可用性。

目前,主流的分布式锁实现有以下几种:

1. 关系型数据库:
使用关系型数据库的某些特性来实现分布式锁,比如利用主键的唯一性约束和数据一致性来确保在同一时间只有一个请求能够获得锁。虽然这种方案实现简单,但在高并发场景或可重入场景中可能存在较大的性能瓶颈。

2. Redis:
利用Redis的单线程执行和原子操作(例如setnx)来实现分布式锁。这种方案相对简单,但由于缺乏原子化的值比较方法,难以实现对锁的占用者是否是当前实例的当前线程的原子确认,因此较难实现重入锁。此外,Redis单节点存在高可用问题,而引入RedLock多节点方案也引起了一些争议。然而,在绝大多数情况下,Redis仍然是一个被广泛使用且可靠的分布式锁解决方案。

3. ZooKeeper:
利用ZooKeeper的特性,如持久节点(PERSISTENT)、临时节点(EPHEMERAL)、时序节点(SEQUENTIAL)以及Watcher接口来实现分布式锁。这种方案能够保证最为严格的数据一致性,在性能和高可用性方面也表现出色。推荐在对一致性要求极高、并发量大的场景中使用。

上一篇 下一篇

猜你喜欢

热点阅读