分布式锁实践之一:基于 Redis 的实现

Redis分布式锁实践

什么是分布式锁?

我们日常工作中(以及面试中)经常说到的并发问题,一般都是指进程内的并发问题,JDK 的并发包也是用以解决 JVM 进程内多线程并发问题的工具。但是,进程之间、以及跨服务器进程之间的并发问题,要如何应对?这时,就需要借助分布式锁来协调多进程 / 服务之间的交互。

分布式锁听起来很高冷、很高大上,但它本质上也是锁,因此,它也具有锁的基本特征:

  1. 原子性
  2. 互斥性

除此之外,分布式的锁有什么不一样呢?简单来说就是:

  1. 独立性

    因为分布式锁需要协调其他进程 / 服务的交互,所以它本身应该是一个独立的、职责单一的进程 / 服务。

  2. 可用性

    因为分布式锁是协调多进程 / 服务交互的基础组件,所以它的可用性直接影响了一组进程 / 服务的可用性,同时也要避免:性能、饥饿、死锁这些潜在问题。

进程锁和分布式锁的区别:

图示 -- 进程级别的锁:

图示 -- 分布式锁:

分布式锁的业界最佳实践应该非大名鼎鼎的 ZooKeeper 莫属了。但杀鸡焉用牛刀?在直接使用 ZooKeeper 实现分布式锁方式之前,我们先通过 Redis 来演练一下分布式锁算法,毕竟 Redis 相对来说简单、轻量很多,我们可以通过这个实践来详细探讨分布式锁的特性。这之后再对比地去看 ZooKeeper 的实现方式,相信会更加容易地理解。

怎么实现分布式锁?

由于 Redis 是高性能的分布式 KV 存储器,它本身就具备了分布式特性,所以我们只需要专注于实现锁的基本特征就好了。

首先来看看如何设计锁记录的数据模型:

key value
lock name lock owner

举个例子,“注册表的分布式写锁”:

lock name lock owner
registry_write 10.10.10.110:25349

注意,为保证锁的互斥性,lock owner 标识必需保证全局唯一,不会如例子中显示的那样简单。

原子性

因为 Redis 提供的方法可以认为是并发安全的,所以只要保证加、解锁操作是原子操作就可以了。也就是说,只使用一个Redis方法来完成加、解锁操作的话,那就能够保证原子性。

  • 加锁操作: set(lockName, lockOwner, ...)

    set 是原子的,所以调用一次 set 也是原子的。

  • 解锁操作:eval(deleteScript, ...)

    这里你也许会疑惑,为什么不直接使用 del(key) 来实现解锁?因为解锁的时候,需要先判断你是不是加锁的进程,不是加锁者是无权解锁的。如果任何进程都能够解锁,那锁还有什么意义?

    因为“先判断是不是加锁者、然后再解锁”是两步的复合操作,而 Redis 并没有提供一个可以实现这个复合操作的直接方法,我们只能通过在 delete script 里面进行复合操作来绕过这个问题:因为执行一条脚本的 eval 方法是原子的,所以这个解锁操作的也是原子的。

互斥性

互斥性是说,一旦有一个进程加锁成功能,那么在该进程解锁之前,其他的进程都不能加锁。

在实现互斥性的同时,注意不能打破锁的原子性。

  • 加锁操作:set(lockName, lockOwner, "NX", ...)

    第 3 个参数 NX 的含义:只有当 lockName(key) 不存在时才会设置该键值。

  • 解锁操作:

    eval(
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "return redis.call('del', KEYS[1]) else return 0 end",
        List(lockName),
        List(lockOwner)
    )
    

    当解锁者等于锁的持有者时,才会删除该键值。

超时

解锁权唯一属于锁的持有者,如果持有者进程异常退出,就永远无法解锁了。针对这种情况,我们可以在加锁时设置一个过期时间,超过这个时间没有解锁,锁会自动失效,这样其他进程就能进行加锁了。

  • 加锁操作:set(lockName, lockOwner, "NX", "PX", expireTime)

    "PX" :过期时间单位:"EX" -- 秒,"PX" -- 毫秒

    expireTime : 过期时间

代码片段 1 :加锁、解锁

// 由Scala编写

case class RedisLock(client: JedisClient,
                     lockName: String,
                     locker: String) {
  private val LOCK_SUCCESS = "OK"
  private val SET_IF_NOT_EXISTS = "NX"
  private val EXPIRE_TIME_UNIT = "PX"
  private val RELEASE_SUCCESS = 1L

  def tryLock(expire: Duration): Boolean = {
    val res = client.con.set(
      lockName, // key
      locker, // value
      SET_IF_NOT_EXISTS, // nxxx
      EXPIRE_TIME_UNIT, // expire time unit
      expire.toMillis // expire time
    )
    val isLock = LOCK_SUCCESS.equals(res)
    println(s"${locker} : ${if (isLock) "lock ok" else "lock fail"}")
    isLock
  }

  def unlock: Boolean = {
    val cmd = 
      "if redis.call('get', KEYS[1]) == ARGV[1] then " +
      "return redis.call('del', KEYS[1]) else return 0 end"
    val res = client.con.eval(
      cmd,
      List(lockName), // keys
      List(locker) // args
    )
    val isUnlock = RELEASE_SUCCESS.equals(res)
    println(s"${locker} : ${if (isUnlock) "unlock ok" else "unlock fail"}")
    isUnlock
  }
}

测试加锁:

object TryLockDemo extends App {
  val client = JedisContext.client
  val lock1 = RedisLock(client, "LOCK", "LOCKER_1")

  // Try lock
  lock1.tryLock(1000.millis)
  Thread.sleep(2000.millis.toMillis)

  // Try lock after expired
  lock1.tryLock(1000.millis)

  // Unlock
  lock1.unlock
}

测试结果:

LOCKER_1 : lock ok   # 加锁成功,1秒后锁失效
LOCKER_1 : lock ok   # 2秒之后,锁已过期释放,所以成功加锁
LOCKER_1 : unlock ok # 解锁成功

阻塞加锁

到目前为止,我们实现了简单的加解锁功能:

  • 通过 tryLock() 方法尝试加锁,会立即返回加锁的结果
  • 锁拥有者通过 unlock() 方法解锁

但在实际的加锁场景中,如果加锁失败了(锁被占用或网络错误等异常情况),我们希望锁工具有同步等待(或者说重试)的能力。面对这个需求,一般会想到两种解决方案:

  1. 简单暴力轮询
  2. Pub / Sub 订阅通知模式

因为 Redis 本身有极好的读性能,所以暴力轮询不失为一种简单高效的实现方式,接下来就让我们来尝试下实现阻塞加锁方法。

先来推演一下算法过程:

  1. 设置阻塞加锁的超时时间 timeout
  2. 如果已超时,则返回失败 false
  3. 如果未超时,则通过 tryLock() 方法尝试加锁
  4. 如果加锁成功,返回成功 true
  5. 如果加锁失败,休眠一段时间 frequency 后,重复第 2 步

代码片段 2 :阻塞加锁

def lock(expire: Duration,
         timeout: Duration,
         frequency: Duration = 500.millis): Boolean = {
  var isTimeout = false
  TimeoutUtil.delay(timeout.toMillis).map(_ => isTimeout = true)
  while (!isTimeout) {
    if (tryLock(expire)) {
      return true
    }
    Thread.sleep(frequency.toMillis)
  }
  println(s"${locker} : timeout")
  return false;
}

代码片段 -- 超时工具类:

object TimeoutUtil {

  def delay(millis: Long): Future[Unit] = {
    val promise = Promise[Unit]()
    val timer = new Timer
    timer.schedule(new TimerTask {
      override def run(): Unit = {
        promise.success()
        timer.cancel()
      }
    }, millis)
    promise.future
  }
}

测试阻塞加锁:

object LockDemo extends App {
  val client = JedisContext.client
  val lock1 = RedisLock(client, "LOCK", "LOCKER_1")
  val lock2 = RedisLock(client, "LOCK", "LOCKER_2")

  // Lock
  lock1.lock(3000.millis, 1000.millis)
  lock2.lock(3000.millis, 1000.millis)
  lock2.lock(3000.millis, 3000.millis)

  // Unlock
  lock1.unlock
  lock2.unlock
}

测试结果:

LOCKER_1 : lock ok     # LOCKER_1 加锁成功,3 秒后锁失效
LOCKER_2 : lock fail   # LOCKER_2 尝试加锁失败
LOCKER_2 : lock fail   # LOCKER_2 重试,尝试加锁失败
LOCKER_2 : timeout     # LOCKER_2 重试超时,返回失败

LOCKER_2 : lock fail   # LOCKER_2 尝试加锁失败
LOCKER_2 : lock fail   # LOCKER_2 重试,尝试加锁失败
LOCKER_2 : lock fail
LOCKER_2 : lock fail
LOCKER_2 : lock ok     # 3 秒时间到,锁失效,LOCKER_2 加锁成功

LOCKER_1 : unlock fail # LOCKER_1 解锁失败,因为此时锁被 LOCKER_2 占有
LOCKER_2 : unlock ok   # LOCKER_2 解锁成功

更进一步

这个分布式锁的实现,有一个比较明显的缺陷,就是等待锁的进程无法实时的知道锁状态的变化,从而及时的做出响应。我们不妨思考一下,通过什么方式可以实时、高效的获得锁的状态?

作为分布式锁的业界标准,ZooKeeper 以及相关的工具库提供了更加直接、高效的支持,那么 ZooKeeper 是怎样的思路?具体又是如何实现的?欲知后事如何,且听下回分解:ZooKeeper 分布式锁实践。

0

后端系统的缓存使用浅谈

1. 什么是缓存

缓存有很多种,从 CPU 缓存、磁盘缓存到浏览器缓存等,本文所说的缓存,主要针对后端系统的缓存。也就是将程序或系统经常要使用的对象存在内存中,以便在使用时可以快速调用,也可以避免加载数据或者创建重复的实例,以达到减少系统开销,提高系统效率的目的。

2. 为什么要用缓存

我们一般都会把数据存放在关系型数据库中,不管数据库的性能有多么好,一个简单的查询也要消耗毫秒级的时间,这样我们常说的 QPS 就会被数据库的性能所限制,我们想要提高QPS,只能选择更快的存储设备。

在日常开发有这样的一种场景:某些数据的数据量不大、不经常变动,但访问却很频繁。受限于硬盘 IO 性能或者远程网络等原因,每次都直接获取会消耗大量的资源。可能会导致我们的响应变慢甚至造成系统压力过大,这在一些业务上是不能忍的,而缓存正是解决这类问题的神器。

但是有一点需要注意,就是缓存的占用空间以及缓存的失效策略,下文也会提到。

使用缓存的场景

对于缓存来说,数据不常变更且查询比较频繁是最好的场景,如果查询量不够大或者数据变动太频繁,缓存也就是失去了意义。

3. 缓存的使用

日常工作使用的缓存可以分为内部缓存和外部缓存。

内部缓存一般是指存放在运行实例内部并使用实例内存的缓存,这种缓存可以使用代码直接访问。

外部缓存一般是指存放在运行实例外部的缓存,通常是通过网络获取,反序列化后进行访问。

一般来说对于不需要实例间同步的,都更加推荐内部缓存,因为内部缓存有访问方便,性能好的特点;需要实例间同步的数据可以使用外部缓存。

下面对这两种类型的缓存分别的进行介绍。

3.1 内部缓存

为什么要是用内部缓存

在系统中,有些数据量不大、不常变化,但是访问十分频繁,例如省、市、区数据。针对这种场景,可以将数据加载到应用的内存中,以提升系统的访问效率,减少无谓的数据库和网路的访问。

内部缓存的限制就是存放的数据总量不能超出内存容量,毕竟还是在 JVM 里的。

最简单的内部缓存 - Map

如果只是需要将一些数据缓存起来,避免不必要的数据库查询,那么 Map 就可以满足。

对于字典型的数据,在项目启动的时候加载到 Map 中,程序就可以使用了,也很容易更新。

// 配置存放的Map
Map<String, String> configs = new HashMap<String, String>();

// 初始化或者刷新配置的Map
public void reloadConfigs() {
    Map<String, String> m = loadConfigFromDB();
    configs = m;
}

// 使用
configs.getOrDefault("auth.id", "1");

功能强大的内部缓存 - Guava Cache / Caffeine

如果你需要缓存有强大的性能,或者对缓存有更多的控制,可以使用 Guava 里的 Cache 组件。

它是 Guava 中的缓存工具包,是非常简单易用且功能强大的 JVM 内缓存,支持多种缓存过期策略。

LoadingCache<String, String> configs = CacheBuilder.newBuilder()
        .maximumSize(1000) // 设置最大大小
        .expireAfterWrite(10, TimeUnit.MINUTES) // 设置过期时间, 10分钟
        .build(
            new CacheLoader<String, String>() {
            // 加载缓存内容
                public String load(String key) throws Exception {
                    return getConfigFromDB(key);
                }
                public Map<String, String> loadAll() throws Exception {
                return loadConfigFromDB();
            }
        });

//CacheLoader.loadAll

// 获取某个key的值
try {
    return configs.get(key);
} catch (ExecutionException e) {
    throw new OtherException(e.getCause());
}

// 显式的放入缓存
configs.put(key, value)
// 个别清除缓存
configs.invalidate(key)
// 批量清除缓存
configs.invalidateAll(keys)
// 清除所有缓存项
configs.invalidateAll()

本地缓存的优点:

  • 直接使用内存,速度快,通常存取的性能可以达到每秒千万级
  • 可以直接使用 Java 对象存取

本地缓存的缺点:

  • 数据保存在当前实例中,无法共享
  • 重启应用会丢失
Guava Cache 的替代者 Caffeine

Spring 5 使用 Caffeine 来代替 Guava Cache,应该是从性能的角度考虑的。从很多性能测试来看 Caffeine 各方面的性能都要比 Guava 要好。

Caffeine 的 API 的操作功能和 Guava 是基本保持一致的,并且 Caffeine 为了兼容之前 Guava 的用户,做了一个 Guava 的 Adapter, 也是十分的贴心。

如果想了解更多请参考:是什么让 Spring 5 放弃了使用 Guava Cache?

3.2 外部缓存

最著名的外部缓存 - Redis / Memcached

也许是 Redis 太有名,只要一提到缓存,基本上都会说起 Redis。但其实这类缓存的鼻祖应该是 LiveJournal 开发的 Memcached。

Redis / Memcached 都是使用内存作为存储,所以性能上要比数据库要好很多,再加上Redis 还支持很多种数据结构,使用起来也挺方便,所以作为很多人的首选。

Redis 确实不错,不过即便是使用内存,也还是需要通过网络来访问,所以网络的性能决定了 Reids 的性能;

我曾经做过一些性能测试,在万兆网卡的情况下,对于 Key 和 Value 都是长度为 20 Byte 的字符串的 get 和 set 是每秒10w左右的,如果 Key 或者 Value 的长度更大或者使用数据结构,这个会更慢一些;

作为一般的系统来使用已经绰绰有余了,从目前来看,Redis 确实很适合来做系统中的缓存。

如果考虑多实例或者分布式,可以考虑下面的方式:

  • Jedis 的 ShardedJedis( 调用端自己实现分片 )
  • twemproxy / codis( 第三方组件实现代理 )
  • Redis Cluster( 3.0 之后官方提供的集群方案 )

这些方案各有特点,这次先不展开讨论,有兴趣的可以先研究一下。

Redis有很多优点:

  • 很容易做数据分片、分布式,可以做到很大的容量
  • 使用基数比较大,库比较成熟

同时也有一些缺点:

  • Java 对象需要序列化才能保存
  • 如果服务器重启,再不做持久化的情况下会丢失数据,即使有持久化也容易出现各种各样的问题

4. 缓存的更新策略

使用缓存时,更新策略是非常重要的。最常见的缓存更新策略是 Cache Aside Pattern:

  • 失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从 cache 中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

不管是内部缓存还是外部缓存,都可以使用这样的更新策略,如果缓存系统支持,也可以通过设置过期时间来更新缓存。

更多的更新策略可以参考左耳朵耗子的这篇缓存更新的套路

5. 缓存使用常见误区

序列化方案的选择

序列化的选择,尽量避免使用 Java 原生的机制,因为原生的序列化依赖 serialVersionUID 来判断版本,如果改变就无法正常的反序列化。

一般推荐使用 Json 或者 Hessian、ProtoBuf 等二进制方式。

缓存大对象

在缓存中存放大对象,存取的代价都比较高。实际使用时,往往只是需要其中的一部分,这样会导致每一次读取都消耗更多的网络和内存资源,也会浪费缓存的容量。

当然如果每次都是用完整的对象,这样做是没有问题的。

使用缓存进行数据共享

使用缓存来当作线程甚至进程之间的数据共享方式,会让系统间产生隐形的依赖,并且也可能会产生一些竞争,常常会发生问题。所以不推荐使用这种方式来共享数据。

没有及时更新或者删除缓存中已经过期或失效的数据

这个理解起来就很简单了,如果没有及时更新或者删除,就有可能读取到错误的数据,从而导致业务的错误。

对于支持设置过期时间的缓存系统,可以对每一个数据设置合适的过期时间,来尽量避免这样的情况。


以上,我们简单介绍了后端系统常用的缓存的一些基本知识,欢迎大家和我们讨论。

1+