消息队列学习笔记5——缓存、锁、原子硬件原语、数据压缩

本文深入探讨了缓存的类型,如只读缓存和读写缓存,以及它们在保持数据一致性和提高性能方面的策略。同时,文章详细介绍了锁的概念,包括死锁的避免和读写锁的使用,以及硬件同步原语CAS和FAA在并发控制中的应用。此外,还讨论了数据压缩的场景选择、算法和分段策略。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


一、只读缓存 和 读写缓存

1.读写缓存

只读缓存和读写缓存的唯一区别就是,在更新数据的时候,是否经过缓存。

操作系统会利用系统空闲的物理内存来给文件读写做缓存,这个缓存叫做 PageCache,是一个非常典型的读写缓存。应用程序在写文件的时候,操作系统会先把数据写入到 PageCache 中,数据在成功写到 PageCache 之后,对于用户代码来说,写入就结束了。

然后,操作系统再异步地把数据更新到磁盘的文件中。应用程序在读文件的时候,操作系统也是先尝试从 PageCache 中寻找数据,如果找到就直接返回数据,找不到会触发一个缺页中断,然后操作系统把数据从文件读取到 PageCache 中,再返回给应用程序。

读写缓存的这种设计,它天然就是不可靠的,如果服务器突然掉电了,没落盘的数据就丢失了。是一种牺牲数据一致性换取性能的设计。

另外,写缓存的实现是非常复杂的。应用程序不停地更新 PageCache 中的数据,操作系统需要记录哪些数据有变化,同时还要在另外一个线程中,把缓存中变化的数据更新到磁盘文件中。在提供并发读写的同时来异步更新数据,这个过程中要保证数据的一致性,并且有非常好的性能。

所以说,一般情况下,不推荐你来使用读写缓存。

Kafka 可以使用 PageCache 来提升它的性能:

  • 消息队列它的读写比例大致是 1:1,因为,大部分我们用消息队列都是一收一发这样使用。这种读写比例,只读缓存既无法给写加速,读的加速效果也有限,并不能提升多少性能。
  • Kafka 它并不是只靠磁盘来保证数据的可靠性,它更依赖的是,在不同节点上的多副本来解决数据可靠性问题,这样即使某个服务器掉电丢失一部分文件内容,它也可以从其他节点上找到正确的数据,不会丢消息。

2.只读缓存

大部分业务类应用程序,读数据的频次会都会远高于写数据的频次。这种情况下,应该使用只读缓存。

在构建只读缓存时,应该侧重考虑:

保持数据一致性

一种比较简单的方法就是,定时将磁盘上的数据同步到缓存中。一般的情况下,每次同步时直接全量更新就可以了。缺点是缓存更新不那么及时,优点是实现起来非常简单。

还有一种更简单的方法,我们从来不去更新缓存中的数据,而是给缓存中的每条数据设置一个比较短的过期时间,数据过期以后即使它还存在缓存中,我们也认为它不再有效,需要从磁盘上再次加载这条数据,这样就变相地实现了数据更新。

缓存置换策略

当应用程序要访问某些数据的时候,如果这些数据在缓存中,这种情况我们称为一次缓存命中;如果这些数据不在缓存中,那只能去磁盘中访问数据,这种情况我们称为缓存穿透。显然,缓存的命中率越高,应用程序的总体性能就越好。

命中率最高的置换策略,一定是根据你的业务逻辑,定制化的策略。

另外一个选择,就是使用通用的 LRU 算法。

Kafka 使用的 PageCache,是由 Linux 内核实现的,它的置换算法的就是一种 LRU 的变种算法:LRU 2Q。

JMQ 根据消息这种流数据存储的特点,在淘汰时增加了一个考量维度:页面位置与尾部的距离。因为越是靠近尾部的数据,被访问的概率越大。


二、锁

如果能不用锁,就不用锁;如果你不确定是不是应该用锁,那也不要用锁。

  • 加锁和解锁过程都是需要 CPU 时间的,这是一个性能的损失。另外,使用锁就有可能导致线程等待锁,等待锁过程中线程是阻塞的状态,过多的锁等待会显著降低程序的性能。
  • 如果对锁使用不当,很容易造成死锁。本来多线程的程序就非常难于调试,如果再加上锁,出现并发问题或者死锁问题,你的程序将更加难调试。

只有在并发环境中,共享资源不支持并发访问,或者说并发访问共享资源会导致系统错误的情况下,才需要使用锁。

锁的两种用法:

private Lock lock = new ReentrantLock();

public void visitShareResWithLock() {
  lock.lock();
  try {
    // 在这里安全的访问共享资源
  } finally {
    lock.unlock();
  }
}
private Object lock = new Object();

public void visitShareResWithLock() {
  synchronized (lock) {
    // 在这里安全的访问共享资源
  }
}

1.如何避免死锁

锁的重入问题,我们来看下面这段代码:

public void visitShareResWithLock() {
  lock.lock(); // 获取锁
  try {
    lock.lock(); // 再次获取锁,会导致死锁吗?
  } finally {
    lock.unlock();
  }

在这段代码中,当前的线程获取到了锁 lock,然后在持有这把锁的情况下,再次去尝试获取这把锁,会不会死锁取决于,你获取的这把锁它是不是可重入锁。

如果是可重入锁,那就没有问题,否则就会死锁。

如何避免死锁:

  • 避免滥用锁,程序里用的锁少,写出死锁 Bug 的几率自然就低。
  • 对于同一把锁,加锁和解锁必须要放在同一个方法中,这样一次加锁对应一次解锁,代码清晰简单,便于分析问题。
  • 尽量避免在持有一把锁的情况下,去获取另外一把锁,就是要尽量避免同时持有多把锁。
  • 如果需要持有多把锁,一定要注意加解锁的顺序,解锁的顺序要和加锁顺序相反。比如,获取三把锁的顺序是 A、B、C,释放锁的顺序必须是 C、B、A。
  • 在所有需要加锁的地方,按照同样的顺序加解锁。

2.使用读写锁要兼顾性能和安全性

对于共享数据来说,只是去读取,并不更新数据,还是需要加锁的。

  • 读访问可以并发执行。
  • 写的同时不能并发读,也不能并发写。

这样就兼顾了性能和安全性。读写锁就是为这一需求设计的。

ReadWriteLock rwlock = new ReentrantReadWriteLock();

public void read() {
  rwlock.readLock().lock();
  try {
    // 在这儿读取共享数据
  } finally {
    rwlock.readLock().unlock();
  }
}
public void write() {
  rwlock.writeLock().lock();
  try {
    // 在这儿更新共享数据
  } finally {
    rwlock.writeLock().unlock();
  }
}

三、硬件同步原语

硬件同步原语(Atomic Hardware Primitives)是由计算机硬件提供的一组原子操作,我们比较常用的原语主要是 CAS 和 FAA 这两种。

1.CAS(Compare and Swap)

伪代码如下:

function cas(p : pointer to int, old : int, new : int) returns bool {
    if *p ≠ old {
        return false
    }
    *p ← new
    return true
}

先比较一下变量 p 当前的值是不是等于 old,如果等于,那就把变量 p 赋值为 new,并返回 true,否则就不改变变量 p,并返回 false。

2.FAA(Fetch and Add)

function faa(p : pointer to int, inc : int) returns int {
    int value <- *location
    *p <- value + inc
    return value
}

FAA 原语的语义是,先获取变量 p 当前的值 value,然后给变量 p 增加 inc,最后返回变量 p 之前的值 value。

3.CAS 版本的转账服务

假设我们有一个共享变量 balance,它保存的是当前账户余额,然后我们模拟多个线程并发转账的情况,看一下如何使用 CAS 原语来保证数据的安全性。

go语言代码:

func transferCas(balance *int32, amount int, done chan bool) {
  for {
    old := atomic.LoadInt32(balance)
    new := old + int32(amount)
    if atomic.CompareAndSwapInt32(balance, old, new) {
      break
    }
  }
  done <- true
}

首先,它用 for 来做了一个没有退出条件的循环。在这个循环的内部,反复地调用 CAS 原语,来尝试给账户的余额 +1。先取得账户当前的余额,暂时存放在变量 old 中,再计算转账之后的余额,保存在变量 new 中,然后调用 CAS 原语来尝试给变量 balance 赋值。我们刚刚讲过,CAS 原语它的赋值操作是有前置条件的,只有变量 balance 的值等于 old 时,才会将 balance 赋值为 new。

在 for 循环中执行了 3 条语句,在并发的环境中执行,这里面会有两种可能情况:

  • 执行到第 3 条 CAS 原语时,没有其他线程同时改变了账户余额,那我们是可以安全变更账户余额的,这个时候执行 CAS 的返回值一定是 true,转账成功,就可以退出循环了。并且,CAS 这一条语句,它是一个原子操作,赋值的安全性是可以保证的。
  • 在这个过程中,有其他线程改变了账户余额,这个时候是无法保证数据安全的,不能再进行赋值。由于返回值为 false,不会退出循环,所以会继续重试,直到转账成功退出循环。

这样,每一次转账操作,都可以通过若干次重试,在保证安全性的前提下,完成并发转账操作。

4.FAA 版本的转账服务

func transferFaa(balance *int32, amount int, done chan bool) {
  atomic.AddInt32(balance, int32(amount))
  done <- true
}

在这个例子里面,使用 FAA 原语更合适,但是使用 CAS 原语的方法,它的适用范围更加广泛一些。类似于这样的逻辑:先读取数据,做计算,然后更新数据,无论这个计算是什么样的,都可以使用 CAS 原语来保护数据安全,但是 FAA 原语,这个计算的逻辑只能局限于简单的加减法。

使用 CAS 原语反复重试赋值的方法,它是比较耗费 CPU 资源的,因为在 for 循环中,如果赋值不成功,是会立即进入下一次循环没有等待的。如果线程之间的碰撞非常频繁,经常性的反复重试,这个重试的线程会占用大量的 CPU 时间,随之系统的整体性能就会下降。

缓解这个问题的一个方法是使用 Yield(),让出当前线程占用的 CPU 给其他线程使用。每次循环结束前调用一下 Yield() 方法,可以在一定程度上减少 CPU 的使用率,缓解这个问题。


四、数据压缩

1.适合数据压缩的场景

在使用压缩之前,首先你需要考虑,当前这个场景是不是真的适合使用数据压缩。

  • 不压缩直接传输需要的时间是: 传输未压缩数据的耗时。
  • 使用数据压缩需要的时间是: 压缩耗时 + 传输压缩数据耗时 + 解压耗时。

影响的因素非常多,比如数据的压缩率、网络带宽、收发两端服务器的繁忙程度等等。

  • 压缩和解压的操作都是计算密集型的操作,非常耗费 CPU 资源。如果你的应用处理业务逻辑就需要耗费大量的 CPU 资源,就不太适合再进行压缩和解压。
  • 如果你的系统的瓶颈是磁盘的 IO 性能,CPU 资源又很闲,这种情况就非常适合在把数据写入磁盘前先进行压缩。
  • 如果你的系统读写比严重不均衡,你还要考虑,每读一次数据就要解压一次是不是划算。

压缩它的本质是资源的置换,是一个时间换空间,或者说是 CPU 资源换存储资源的游戏。

2.压缩算法的选择

目前常用的压缩算法包括:ZIP,GZIP,SNAPPY,LZ4 等等。选择压缩算法的时候,主要需要考虑数据的压缩率和压缩耗时

一般来说,压缩率越高的算法,压缩耗时也越高。如果是对性能要求高的系统,可以选择压缩速度快的算法,比如 LZ4;如果需要更高的压缩比,可以考虑 GZIP 或者压缩率更高的 XZ 等算法。

压缩样本对压缩速度和压缩比的影响也是比较大的,所以,用系统的样例业务数据做一个测试,可以帮助你找到最合适的压缩算法。

3.选择合适的压缩分段

在压缩时,给定的被压缩数据它必须有确定的长度,或者说,是有头有尾的,不能是一个无限的数据流。

如果要对流数据进行压缩,那必须把流数据划分成多个帧,一帧一帧的分段压缩。

主要原因是,压缩算法在开始压缩之前,一般都需要对被压缩数据从头到尾进行一次扫描,扫描的目的是确定如何对数据进行划分和编码,一般的原则是重复次数多、占用空间大的内容,使用尽量短的编码,这样压缩率会更高。另外,被压缩的数据长度越大,重码率会更高,压缩比也就越高。

分段也不是越大越好,实际上分段大小超过一定长度之后,再增加长度对压缩率的贡献就不太大了,这是一个原因。另外,过大的分段长度,在解压缩的时候,会有更多的解压浪费。比如,一个 1MB 大小的压缩文件,即使你只是需要读其中很短的几个字节,也不得不把整个文件全部解压缩,造成很大的解压浪费。

压缩的过程就是用编码来替换原始数据的过程。压缩之后的压缩包就是由这个编码字典和用编码替换之后的数据组成的。解压的时候,先读取编码字典,然后按照字典把压缩编码还原成原始的数据就可以了。


思考题

1、实现一个采用 LRU 置换算法的缓存。

/**
 * KV存储抽象
 */
public interface Storage<K,V> {
    /**
     * 根据提供的key来访问数据
     * @param key 数据Key
     * @return 数据值
     */
    V get(K key);
}

/**
 * LRU缓存。你需要继承这个抽象类来实现LRU缓存。
 * @param <K> 数据Key
 * @param <V> 数据值
 */
public abstract class LruCache<K, V> implements Storage<K,V>{
    // 缓存容量
    protected final int capacity;
    // 低速存储,所有的数据都可以从这里读到
    protected final Storage<K,V> lowSpeedStorage;

    public LruCache(int capacity, Storage<K,V> lowSpeedStorage) {
        this.capacity = capacity;
        this.lowSpeedStorage = lowSpeedStorage;
    }
}

你需要继承 LruCache 这个抽象类,实现你自己的 LRU 缓存。lowSpeedStorage 是提供给你可用的低速存储,你不需要实现它。


2、用锁、CAS 和 FAA 这三种方法,都完整地实现转账业务。


参考资料:李玥——消息队列高手课

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值