高效并发是从 JDK 1.5 到 JDK 1.6 的一个重要改进,HotSpot 虚拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)等,这些技术都是为了在线程之间高效地共享数据,以及解决竞争问题,从而提交程序的执行效率。
上一篇文章中,我们针对 Java 并发编程进行了了解,如线程以及线程安全概念、Java 内存模型等基础性知识。本章,我们针对 Java 提供的种类丰富的锁,为读者介绍主流锁的知识点,以及不同的锁的适用场景。
悲观锁:对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java 中,synchronized 关键字和 Lock 的实现类都是悲观锁。因此,悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁:对于同一个数据的并发操作,乐观锁认为在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。乐观锁在 Java 中是通过使用无锁编程来实现,最常采用的是 CAS 算法,Java 原子类中的递增操作就通过 CAS 自旋实现的。因此,乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
通过上图的流程图,我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?我们通过介绍乐观锁的主要实现方式 “CAS” 的技术原理来为大家解惑。
CAS 全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent 包中的原子类就是通过 CAS 来实现了乐观锁。CAS 指令需要有 3 个操作数,分别是内存位置(在 Java 中可以简单理解为变量的内存地址,用 V 表示)、旧的预期值(用 A 表示)和新值(用 B 表示)。CAS 指令执行时,当且仅当 V 符合旧预期值 A 时,处理器用新值 B 更新 V 的值,否则它就不执行更新,但是无论是否更新了 V 的值,都会返回 V 的旧值,上述的处理过程是一个原子操作。
在 JDK 1.5 之后,Java 程序中才可以使用 CAS 操作,该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt() 和 compareAndSwapLong() 等几个方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器 CAS 指令,没有方法调用的过程,或者可以认为是无条件内联进去了。
CAS 虽然很高效,但是它也存在三大问题:
ABA 问题。CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA 问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从 “A-B-A” 变成了“1A-2B-3A”。JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在 compareAndSet() 中。compareAndSet() 首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。不过目前来说这个类比较鸡肋,大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步的可能会比原子类更高效。
循环时间长开销大。CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来非常大的开销。
只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证操作的原子性的。JDK 从 1.5 开始提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。
我们知道互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。同时,在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程 “稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋锁的实现原理同样是 CAS,AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是 10 次,用户可以使用参数 -XX:PreBlockSpin 来更改。
在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越准确。
无锁、偏向锁、轻量级锁、重量级锁,这四种锁是指锁的状态,专门针对 Synchronized 的。在介绍这四种锁状态之前还需要介绍一些额外的知识。首先为什么 Synchronized 能实现线程同步?在回答这个问题之前我们需要了解两个重要的概念:“Java 对象头”、“Monitor”。
Java 对象头:Synchronized 是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在 Java 对象头里的,而 Java 对象头又是什么呢?我们以 Hotspot 虚拟机为例,Hotspot 的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Monitor:Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
现在话题回到 Synchronized,Synchronized 通过 Monitor 来实现线程同步,Monitor 是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的线程同步。Synchronized 最初实现同步的方式,就是这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”,JDK 1.6 中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁” 和“轻量级锁”。所以目前锁一共有 4 种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的 CAS 原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁是 JDK 1.6 中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为 “01”,即偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中,如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机 都可以不再进行任何同步操作(例如 Locking、Unlocking 以及对 Mark Word 的 Update 等)。
当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(标志位为“01”)或者轻量级锁定(标志位为“00”)。
偏向锁在 JDK 1.6 及以后的 JVM 里是默认启用的。可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
轻量级锁是 JDK 1.6 中引入的一项锁优化,它的目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统 互斥产生的性能消耗。
轻量级锁是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
轻量级锁流程:
轻量级锁能提升程序同步性能的依据是 “对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
重量级锁是依赖对象内部的 Monitor 锁来实现的,而 Monitor 又依赖操作系统的 MutexLock(互斥锁) 来实现的,所以重量级锁也称为互斥锁。升级为重量级锁时,锁标志的状态值变为“10”,此时 Mark Word 中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
升级为重量级锁,就会向操作系统申请资源,线程挂起,进入到操作系统内核态的等待队列中,等待操作系统调度,然后映射回用户态。重量级锁中,由于需要做内核态到用户态的转换,而这个过程中需要消耗较多时间,也就是“重”的原因之一。
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
对于 Java ReentrantLock 而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于 Synchronized 而言,也是一种非公平锁。由于其并不像 ReentrantLock 是通过 AQS 的来实现线程调度,所以并没有任何办法使其变成公平锁。
接下来我们通过 ReentrantLock 的源码来讲解公平锁和非公平锁。
根据代码可知,ReentrantLock 里面有一个内部类 Sync,Sync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。它有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。ReentrantLock 默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
通过上图中的源代码对比,我们可以明显的看出公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。hasQueuedPredecessors() 方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回 true,否则返回 false。
综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。Java 中 ReentrantLock 和 synchronized 都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
之前我们说过 ReentrantLock 和 synchronized 都是重入锁,那么我们通过重入锁 ReentrantLock 以及非可重入锁 NonReentrantLock 的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁:
首先 ReentrantLock 和 NonReentrantLock 都继承父类 AQS,其父类 AQS 中维护了一个同步状态 status 来计数重入次数,status 初始值为 0。
当线程尝试获取锁时,可重入锁先尝试获取并更新 status 值,如果 status == 0 表示没有其他线程在执行同步代码,则把 status 置为 1,当前线程开始执行。如果 status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行 status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前 status 的值,如果 status != 0 的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前 status 的值,在当前线程是持有锁的线程的前提下。如果 status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将 status 置为 0,将锁释放。
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排它锁后,则其他线程不能再对 A 加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK 中的 synchronized 和 JUC 中 Lock 的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独享锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享。
接下来我们通过 ReentrantReadWriteLock 的源码来介绍独享锁和共享锁。
ReentrantReadWriteLock 有两把锁:ReadLock 和 WriteLock,由词知意,一个读锁一个写锁,合称 “读写锁”。再进一步观察可以发现 ReadLock 和 WriteLock 是靠内部类 Sync 实现的锁。Sync 是 AQS 的一个子类,这种结构在 CountDownLatch、ReentrantLock、Semaphore 里面也都存在。在 ReentrantReadWriteLock 里面,读锁和写锁的锁主体都是 Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以 ReentrantReadWriteLock 的并发性相比一般的互斥锁有了很大提升。
我们知道,AQS 类中 state 字段(int 类型,32 位),该字段用来描述有多少线程获持有锁。在独享锁中这个值通常是 0 或者 1(如果是重入锁的话 state 值就是重入的次数),在共享锁中 state 就是持有锁的数量。但是在 ReentrantReadWriteLock 中有读、写两把锁,所以需要在一个整型变量 state 上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将 state 变量 “按位切割” 切分成了两个部分,高 16 位表示读锁状态(读锁个数),低 16 位表示写锁状态(写锁个数)。
了解了概念之后我们再来看代码,先看写锁的加锁源码:
1 | protected final boolean tryAcquire(int acquires) { |
tryAcquire() 除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与 ReentrantLock 的释放过程基本类似,每次释放均减少写状态,当写状态为 0 时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。
接下来我们再看看读锁的加锁源码:
1 | protected final int tryAcquireShared(int unused) { |
可以看到在 tryAcquireShared(int unused) 方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠 CAS 保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是 “1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
锁消除:指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断一段代码中,堆上的所有数据都不会逃逸出去从而被其它线程访问到,就可以把它们当做栈上数据对待,认为它们是线程私有的而无须同步。
锁粗化:原则上,我们在编写代码的时候,需要将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中进行同步,这是为了使等待锁的线程尽快拿到锁。但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,即使没有线程竞争也会导致不必要的性能消耗。因此如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部。
[1]. 不可不说的Java“锁”事
[2]. 《深入理解Java虚拟机:JVM高级特性与最佳实践》,第五部分 高效并发
在本系列的前一篇博文中,笔者对消息队列的概述、特点等进行讲解,然后对消息队列使用场景进行分析,最后对市面上比较常见的消息队列产品进行技术对比。
经过上一篇博客介绍,相信大家对消息队列已经有了一个大致了解。RabbitMQ 是 MQ 产品的典型代表,是一款基于 AMQP 协议可复用的企业消息系统。业务上,可以实现服务提供者和消费者之间的数据解耦,提供高可用性的消息传输机制,在实际生产中应用相当广泛。本文意在介绍 RabbitMQ 的基本原理,包括 RabbitMQ 基本框架、概念、通信过程等,介绍一下 RabbitMQ 安装教程,最后介绍一下 RabbitMQ 在项目中实际应用场景。
RabbitMQ 是采用 Erlang 语言实现的 AMQP[1] 协议的消息中间件,最初起源于金融系统,用于在分布式系统中存储转发消息。RabbitMQ 发展到今天,被越来越多的人认可,这和它在可靠性、可用性、扩展性、功能丰富等方面的卓越表现是分不开的。RabbitMQ 实现了 AQMP 协议,AQMP 协议定义了消息路由规则和方式。生产端通过路由规则发送消息到不同 queue,消费端根据 queue 名称消费消息。此外 RabbitMQ 是向消费端推送消息,订阅关系和消费状态保存在服务端。
通常我们谈到消息队列时会有三个概念:生产者、队列、消费者,RabbitMQ 在这个基本概念之上,多做了一层抽象,在生产者和队列之间,加入了交换器(Exchange)。这样生产者和队列就没有直接联系,转而变成发生产者把消息给交换器,交换器根据调度策略再把消息再给队列。因此在 RabbitMQ 的消息传递模型中,他的核心思想是生产者永远不会将任何消息直接发送到队列上,甚至不知道消息是否被传递到任何队列。生产者向 Exchanges 发送消息。 Exchanges 负责生产者消息的接收,将消息推送到队列。Exchanges 通过 exchange type 指定的类型明确要如何处理消息,比如附加到特定队列或者所有队列,或者将消息丢弃。
交换器,用来接收生产者发送的消息并将这些消息路由给服务器中的队列。当我们发送一条消息时,首先会发给交换器(exchange),交换器根据规则(路由键:routing_key)将会确定消息投递到那个队列(queue)。交换机不存储消息,如果没有 Queue Binding 到 Exchange 的话,它会直接丢弃掉 Publisher 发送过来的消息;在启用 ack 模式后,交换机找不到队列会返回错误。Exchange 有 4 种类型: direct(默认)、fanout、topic 和 headers ,不同类型的 Exchange 转发消息的策略有所区别。
1、Direct Exchange
Direct Exchange:直接交换器,Direct Exchange 是 RabbitMQ 默认的交换机模式,也是最简单的交换机模式,根据 ROUTING_KEY 全文匹配去寻找队列。其工作方式类似于单播,Exchange 会将消息发送完全匹配 ROUTING_KEY 的 Queue。
Direct 模式,可以使用 RabbitMQ 自带的 Exchange:default Exchange 。所以不需要将 Exchange 进行任何绑定(binding)操作 。消息传递时,ROUTING_KEY 必须完全匹配,才会被队列接收,否则该消息会被抛弃。
2、Fanout Exchange
Fanout Exchange:扇形交换器,所有发送到 Fanout Exchange 的消息都会被转发到与该 Exchange 绑定 (Binding) 的所有 Queue 上。
Fanout 模式,Fanout Exchange 不需要处理 ROUTING_KEY。只需要简单的将队列绑定到 Exchange 上,这样发送到 Exchange 的消息都会被转发到与该交换机绑定的所有队列上。类似子网广播,每台子网内的主机都获得了一份复制的消息。所以,Fanout Exchange 转发消息是最快的。
3、Topic Exchange
Topic Exchange:主题交换器,工作方式类似于组播,Exchange 会将消息转发和 ROUTING_KEY 匹配模式相同的所有队列。
Topic 模式,Exchange 将 ROUTING_KEY 和某 Topic 进行模糊匹配。此时队列需要绑定一个 Topic,可以使用通配符进行模糊匹配,符号 “#” 匹配一个或多个词,符号 “” 匹配不多不少一个词。因此 “log.#” 能够匹配到“log.info.oa”,但是“log.” 只会匹配到“log.error”。所以,Topic Exchange 使用非常灵活。
4、Headers Exchange
Headers Exchange:首部交换器和扇形交换器都不需要路由键 ROUTING_KEY,首部交换器和主题交换机有点相似,但是不同于主题交换机的路由是基于路由键,头交换机的路由值基于消息的 header 数据,主题交换机路由键只有是字符串,而头交换机可以是整型和哈希值。
Headers 模式,Headers 是一个键值对,可以定义成 Hashtable。发送者在发送的时候定义一些键值对,接收者也可以再绑定时候传入一些键值对,两者匹配的话,则对应的队列就可以收到消息。匹配有两种方式 all 和 any。这两种方式是在接收端必须要用键值 “x-mactch” 来定义。all 代表定义的多个键值对都要满足,而 any 则代码只要满足一个就可以了。fanout,direct,topic exchange 的 ROUTING_KEY 都需要要字符串形式的,而 headers exchange 则没有这个要求,因为键值对的值可以是任何类型。
本文统一使用软件包管理器的方式安装 RabbitMQ,减少环境变量的配置,更加方便快捷。RabbitMQ 官网也有详细的安装教程,感兴趣的同学,可以参考下。Downloading and Installing RabbitMQ
CentOS7 中使用 yum 安装 RabbitMQ 的方法,RabbitMQ 是采用 Erlang 语言实现,因此安装 RabbitMQ 前,需要先安装 Erlang。直接用 yum install erlang 安装的版本是 R16B-03.18.el7,不满足要求,为此,RabbitMQ 贴心提供了一个 erlang.repo,将以下内容添加到 /etc/yum.repos.d/rabbitmq-erlang.repo。
1 | # 1. 将 erlang 新版本源添加到 /etc/yum.repos.d/rabbitmq-erlang.repo |
Mac 中使用 brew 安装 RabbitMQ 的方法
1 | # 1. 使用 RabbitMQ 安装 |
Windows 中使用 choco 安装 RabbitMQ 的方法
1 | # 1. 使用 RabbitMQ 安装 |
1 | 1. 开启 rabbitmq_management 以便通过浏览器访问控制台 |
管理控制台的地址默认为 http://server-name:15672 (将其中 server-name 替换为你自己的 ip 地址)。RabbitMQ 默认有个 guest 账号,密码也为 guest,但是如果不是从 RabbitMQ 所在机器上试图用这个账号登陆管理控制台的话,会报错误:“User can only log in via localhost”。RabbitMQ 3.0 开始禁止使用 guest/guest 权限通过除 localhost 外的访问。因此,我们需要添加一个超级管理员。
1 | # 1. 添加一个账户吧,用户名 admin 密码 admin |
在 RabbitMQ 中,用户角色可分为五类:
1、 导入 RabbitMQ 的客户端依赖
1 | <dependency> |
2、 定义创建连接工具类
1 | public class ConnectionUtils { |
创建一个生产者项目用来向消息队列发送数据,创建一个消费者项目用来从消息队列里接收数据,消费者需要注册到指定到 MQ 队列中,如下图所示:
1、Publisher :消息生产者 Publisher 向交换器(AMQP default)发送消息,交换器类型为 Direct Exchange,消息传递时,ROUTING_KEY 必须完全匹配,才会被队列接收,否则该消息会被抛弃。
1 | public class Send { |
2、Consumer :消息消费者 Consumer 从消息队列 hello 中取得消息
1 | public class Recv { |
Work queues 工作队列也称为任务队列,主要思想是避免立即执行资源密集型的任务。将需要执行的任务放到消息队列中,等待资源空闲时消费者从消息队列中取出消息并逐个执行。使用任务队列的优点之一是能够轻松并行化工作,如果我们正在积压工作,我们可以增加更多的消费者,这样就可以轻松扩展。工作队列适用于很多场景,一般的使用方式也都是采用任务队列,如下图所示:
1、Publisher :消息生产者 Publisher 向交换器(AMQP default)发送消息,交换器类型为 Direct Exchange。
1 | public class Send { |
2、Consumer:消息消费者 Consumer 从消息队列 hello 中取得消息,通过 Thread.sleep() 函数来伪造资源密集型的任务
1 | public class Recv { |
3、Consumer:当任务积压时,我们只需要启动多个消费者,这样就可以轻松扩展,完成消费。
注意:默认情况下,RabbitMQ 将每个消息依次发送给下一个使用者。平均而言,每个消费者都会收到相同数量的消息。这种分发消息的方式称为循环。
Publish/Subscribe 发布订阅模式,将消息广播发送给所有消费者。这里以日志系统为例,假设生产者程序将消息发送给两个消费者,其中一个消费者负责将日志输出到控制台,另外一个消费者负责将日志写入到磁盘。Publish/Subscribe 发布订阅模式中交换器类型为 Fanout Exchange。扇形交换器,所有发送到 Fanout Exchange 的消息都会被转发到与该 Exchange 绑定 (Binding) 的所有 Queue 上,如下图所示:
1、Publisher :消息生产者 Publisher 向交换器 logs 发送消息,交换器类型为 Fanout Exchange。
1 | public class EmitLog { |
2、Consumer:消息消费者 Consumer 从 channel 中获取一个随机的非持久化自动删除队列(客户端退出就自动删除),然 绑定消息队列和 exchange。
1 | public class ReceiveLogs { |
3、Consumer:当启动多个消费者,每个消费者会创建队列并绑定至交换器 logs 上,消息生产者向交换器 logs 发送消息,交换器 logs 将消息转发到与该 Exchange 绑定 (Binding) 的所有 Queue 上。
注意:消费者必须先绑定到 exchange 上,然后生产者再发送消息,否则 exchange 无法将消息路由到任何队列。
Routing 路由,进行有选择的接收消息,可以订阅某个消息队列的子集。例如,我们将只能将严重错误消息定向到日志文件(以节省磁盘空间),同时仍然能够在控制台上打印所有日志消息。Routing 路由模式中交换器类型为 Fanout Exchange。直接交换器,Direct Exchange 是 RabbitMQ 默认的交换机模式,也是最简单的交换机模式,根据 ROUTING_KEY 全文匹配去寻找队列。其工作方式类似于单播,Exchange 会将消息发送完全匹配 ROUTING_KEY 的 Queue,如下图所示:
1、Publisher :消息生产者 Publisher 向交换器 direct_logs 发送消息,交换器类型为 Direct Exchange。
1 | public class EmitLogDirect { |
2、Consumer:消息消费者 Consumer 从 channel 中获取一个随机的非持久化自动删除队列(客户端退出就自动删除),绑定消息队列、exchange、路由 key。
1 | public class ReceiveLogsDirect { |
3、Consumer:消费者可以为一个队列绑定多个 routingKey。
1 | public class ReceiveLogsDirect { |
Topics 主题,基于主题交换的策略接收消息,基于 Topics 的方式可以让消息队列的使用更加灵活,为消息的发送和订阅提供更加细粒度的控制。例如,在我们的日志记录系统中,我们可能不仅要根据严重性订阅日志,还要根据发出日志的源订阅日志。Topics 主题模式中交换器类型为 Topic Exchange。主题交换器,工作方式类似于组播,Exchange 会将消息转发和 ROUTING_KEY 匹配模式相同的所有队列,如下图所示:
1、Publisher :消息生产者 Publisher 向交换器 topic_logs 发送消息,交换器类型为 Topic Exchange。首先需要指定 exchange 的类型为 topic,在生产者发送消息时设置 routingKey 为一个符号表达式。
1 | public class EmitLogTopic { |
2、Consumer:消息消费者 Consumer 从 channel 中获取一个随机的非持久化自动删除队列(客户端退出就自动删除),通过符号表达式绑定消息队列、exchange、路由 key。
1 | public class ReceiveLogsTopic { |
RPC 远程过程调用,RabbitMQ 也支持这种同步调用的特性,调用之后等待调用结果返回。该模式使用率较少,在实际项目中应用场景较小。如下图所示:
客户端通过 RabbitMQ 的 RPC 调用服务端,等待服务端返回结果,示例程序如下:
1、为了说明如何使用 RPC 服务,我们将创建一个简单的客户端类。它将公开一个名为 call 的方法,该方法 发送 RPC 请求并阻塞,直到收到答案为止:
1 | public class RPCClient implements AutoCloseable { |
2、由于我们没有值得分配的耗时任务,因此我们将创建一个虚拟 RPC 服务,该服务返回斐波那契数:
1 | public class RPCServer { |
Spring Boot 集成 RabbitMQ 非常简单,如果只是简单的使用配置非常少,Spring Boot 提供了spring-boot-starter-amqp 项目对消息各种支持。
1、配置 Pom 包,主要是添加 spring-boot-starter-amqp 的支持
1 | <dependency> |
2、配置 RabbitMQ 的安装地址、端口以及账户信息
1 | spring.rabbitmq.host=localhost |
3、队列配置
1 |
|
4、发送者
1 |
|
5、接收者
1 | @Component |
6、测试
1 |
|
Spring Boot 以及完美的支持对象的发送和接收,不需要格外的配置。
1 | // 发送者 |
在使用 RabbitMQ 时,我们可以通过消息持久化来解决服务器因异常崩溃而造成的消息丢失。在使用 RabbitMQ 时,我们可以通过消息持久化来解决服务器因异常崩溃而造成的消息丢失。其中,RabblitMQ 的持久化分为三个部分:交换器(Exchange)的持久化、队列(Queue)的持久化、消息(Message)的持久化。
1、设置队列、交换器持久化,防止在 RabbitMQ 出现异常情况(重启,宕机)时,Exchange、Queue 丢失,影响后续的消息写入。
1 |
|
或者通过消费者 @RabbitListener 注解的方式进行持久化。
1 | @Component |
注意:Exchange 交换器的持久化,在声明时指定 durable => true,若 durable=false 非持久化,在 RabbitMQ 出现异常情况(重启,宕机)时,该 Exchange 会丢失,会影响后续的消息写入该 Exchange;Queue 队列的持久化,在声明时指定 durable => true,若 durable=false 非持久化,在 RabbitMQ 出现异常情况(重启,宕机)时,队列丢失,队列丢失导致队列与 Exchange 绑定关系丢失,会影响后续的消息路由给服务器中的队列。
3、发送者
1 |
|
注意:Spring AMQP 是对原生的 RabbitMQ 客户端的封装。一般情况下,我们只需要定义交换器的持久化和队列的持久化。Message 消息的持久化,在投递时指定 delivery_mode => 2(1 是非持久化),RabbitTemplate 它持久化的策略是 MessageDeliveryMode.PERSISTENT,因此它会初始化时默认消息是持久化的。
4、注意事项
(1)理论上可以将所有的消息都设置为持久化,但是这样会严重影响 RabbitMQ 的性能。因为写入磁盘的速度比写入内存的速度慢得不止一点点。对于可靠性不是那么高的消息可以不采用持久化处理以提高整体的吞吐量。在选择是否要将消息持久化时,需要在可靠性和吞吐量之间做一个权衡。
(2)将交换器、队列、消息都设置了持久化之后仍然不能百分之百保证数据不丢失,因为当持久化的消息正确存入 RabbitMQ 之后,还需要一段时间(虽然很短,但是不可忽视)才能存入磁盘之中。如果在这段时间内 RabbitMQ 服务节点发生了宕机、重启等异常情况,消息还没来得及落盘,那么这些消息将会丢失。
(3)单单只设置队列持久化,重启之后消息会丢失;单单只设置消息的持久化,重启之后队列消失,继而消息也丢失。单单设置消息持久化而不设置队列的持久化显得毫无意义。
默认情况下消息消费者是自动 ack (确认)消息的,自动确认会在消息发送给消费者后立即确认,这样存在丢失消息的可能。
1、配置 RabbitMQ 的安装地址、端口、账户信息以及 ACK 确认模式
1 | spring.rabbitmq.host=118.25.39.41 |
注意:AcknowledgeMode.MANUAL 模式需要人为地获取到 channel 之后调用方法向 server 发送 ack(或消费失败时的 nack)信息;AcknowledgeMode.AUTO 模式下,由 spring-rabbit 依据消息处理逻辑是否抛出异常自动发送 ack(无异常)或 nack(异常)到 server 端。
2、接收者,消息消费者手动确认消息以及消息拒绝
1 |
|
topic 是 RabbitMQ 中最灵活的一种方式,可以根据 routing_key 自由的绑定不同的队列。
1、首先对 topic 规则配置,使 queueMessages 同时匹配两个队列,queueMessage 只匹配 “topic.message” 队列。
1 |
|
2、发送 send1 会匹配到 topic.# 和 topic.message 两个 Receiver 都可以收到消息,发送 send2 只有 topic.# 可以匹配所有只有 Receiver2 监听到消息。
1 | public void send1() { |
Fanout 就是我们熟悉的广播模式或者订阅模式,给 Fanout 交换机发送消息,绑定了这个交换机的所有队列都收到这个消息。
1、首先对 fanout 规则配置,这里使用了 A、B、C 三个队列绑定到 Fanout 交换机上面,发送端的 routing_key 写任何字符都会被忽略。
1 |
|
2、绑定到 fanout 交换机上面的队列都收到了消息
1 | public void send() { |
运行结果:
1 | Sender : hi, fanout msg |
延时队列顾名思义,即放置在该队列里面的消息是不需要立即消费的,而是等待一段时间之后取出消费。在很多的业务场景中,延时队列可以实现很多功能,此类业务中,一般上是非实时的,需要延迟处理的,需要进行重试补偿的。
RabbitMQ 实现延时队列一般而言有两种形式:
AMQP 协议和 RabbitMQ 队列本身没有直接支持延迟队列功能,但是我们可以通过 RabbitMQ 的两个特性 TTL(Time-To-Live,存活时间)和 DLX(Dead-Letter-Exchange,死信队列交换机)来曲线实现延迟队列:
RabbitMQ 可以通过设置队列过期时间实现延时消费或者通过设置消息过期时间实现延时消费,如果超时(两者同时设置以最先到期的时间为准),则消息变为 dead letter(死信)。
RabbitMQ 针对队列中的消息过期时间有两种方法可以设置,A:通过队列属性设置,队列中所有消息都有相同的过期时间;B:对消息进行单独设置,每条消息 TTL 可以不同。如果同时使用,则消息的过期时间以两者之间 TTL 较小的那个数值为准。消息在队列的生存时间一旦超过设置的 TTL 值,就成为 dead letter。
设置了 TTL 的消息或队列最终会成为 Dead Letter,当消息在一个队列中变成死信之后,它能被重新发送到另一个交换机中,这个交换机就是 DLX,绑定此 DLX 的队列就是死信队列。
RabbitMQ 的 Queue 可以配置 x-dead-letter-exchange 和 x-dead-letter-routing-key(可选)两个参数,如果队列内出现了 dead letter,则按照这两个参数重新路由转发到指定的队列。
x-dead-letter-exchange:出现 dead letter 之后将 dead letter 重新发送到指定 exchange;
x-dead-letter-routing-key:出现 dead letter 之后将 dead letter 重新按照指定的 routing-key 发送。
队列出现 dead letter 的情况有:
消息或者队列的 TTL 过期;
队列达到最大长度;
消息被消费端拒绝(basic.reject or basic.nack)并且 requeue=false。
1、队列以及交换器配置
1 |
|
2、发送者,这里发送者需要指定前面配置了过期时间的队列 dead.letter.queue
1 |
|
3、接收者,消费者监听指定用于延时消费的队列 repeat.trade.queue
1 | @Component |
4、测试
1 | @SpringBootTest |
运行结果:
1 | DeadLetterSender sendTime:2020-01-19T17:15:42.633 message:hello Sun Jan 19 17:15:42 CST 2020 |
延迟插件 rabbitmq-delayed-message-exchange 是在 RabbitMQ 3.5.7 及以上的版本才支持的,依赖 Erlang/OPT 18.0 及以上运行环境。
实现机制:安装插件后会生成新的 Exchange 类型 x-delayed-message,该类型消息支持延迟投递机制, 接收到消息后并未立即将消息投递至目标队列中,而是存储在 mnesia(一个分布式数据系统) 表中,检测消息延迟时间,如达到可投递时间时并将其通过 x-delayed-type 类型标记的交换机类型投递至目标队列。
1、下载插件
可以通过 RabbitMQ 官网的官方插件 Community Plugins 下载相对应的 rabbitmq_delayed_message_exchange 插件,并将插件包放在 RabbitMQ 安装目录 plugins 目录下。
1 | [root@VM_24_98_centos plugins]# wget https://github.com/rabbitmq/rabbitmq-delayed-message-exchange/releases/download/v33.8 .ez /rabbitmq_delayed_message_exchange- |
2、开启 rabbitmq_delayed_message_exchange 插件
1 | [root@VM_24_98_centos plugins] |
3、查询安装的所有插件,检查 rabbitmq_delayed_message_exchange 插件是否是开启状态
1 | [root@VM_24_98_centos plugins] |
4、重启 RabbitMQ,使插件生效
1 | [root@VM_24_98_centos ~] |
此时,通过浏览器访问控制台在交换器栏目下新增交换器多了 “x-delayed-message” 类型。
1、队列以及交换器配置
1 |
|
2、发送者,这里发送者需要指定前面配置了过期时间的队列 dead.letter.queue
1 |
|
3、接收者,消费者监听指定用于死信队列 dead.letter.queue
1 | @Component |
4、测试
1 | @SpringBootTest |
运行结果:
1 | DeadLetterSender sendTime:2020-01-20T11:08:24.412 message:hello Mon Jan 20 11:08:24 CST 2020 |
[1]. RabbitMQ 官方文档地址
[2]. Spring Boot(八):RabbitMQ 详解
[3]. Spring Boot(十四)RabbitMQ延迟队列
[1]. AMQP:AMQP(advanced message queuing protocol)在 2003 年时被提出,最早用于解决金融领不同平台之间的消息传递交互问题。顾名思义,AMQP 是一种协议,更准确的说是一种 binary wire-level protocol(链接协议)。在 AMQP 中,消息路由(message routing)和 JMS 存在一些差别,在 AMQP 中增加了 Exchange 和 binding 的角色。producer 将消息发送给 Exchange,binding 决定 Exchange 的消息应该发送到那个 queue,而 consumer 直接从 queue 中消费消息。
JDK 5 引入了 Future 模式。Future 接口是 Java 多线程 Future 模式的实现,在 java.util.concurrent 包中,可以来进行异步计算。虽然 Future 以及相关使用方法提供了异步执行任务的能力,但是对于结果的获取却是很不方便,只能通过阻塞或者轮询的方式得到任务的结果。阻塞的方式显然和我们的异步编程的初衷相违背,轮询的方式又会耗费无谓的 CPU 资源,而且也不能及时地得到计算结果,为什么不能用观察者设计模式当计算结果完成及时通知监听者呢?如 Netty、Guava 分别扩展了 Java 的 Future 接口,方便异步编程。
为了解决这个问题,自 JDK8 开始,吸收了 Guava 的设计思想,加入了 Future 的诸多扩展功能形成了 CompletableFuture,让 Java 拥有了完整的非阻塞编程模型。CompletableFuture 它提供了非常强大的 Future 的扩展功能,可以帮助我们简化异步编程的复杂性,并且提供了函数式编程的能力。CompletableFuture 能够将回调放到与任务不同的线程中执行,也能将回调作为继续执行的同步函数,在与任务相同的线程中执行。它避免了传统回调最大的问题,那就是能够将控制流分离到不同的事件处理器中。
CompletableFuture 弥补了 Future 模式的缺点。在异步的任务完成后,需要用其结果继续操作时,无需等待。可以直接通过 thenAccept、thenApply、thenCompose 等方式将前面异步处理的结果交给另外一个异步事件处理线程来处理。
CompletableFuture 类实现了 CompletionStage 和 Future 接口,所以你还是可以像以前一样通过阻塞或者轮询的方式获得结果,尽管这种方式不推荐使用。
1 | public class CompletableFuture<T> implements Future<T>, CompletionStage<T> { |
在该类中提供了四个静态方法创建 CompletableFuture 对象:
1 | // 使用 ForkJoinPool.commonPool() 作为它的线程池执行异步代码,异步操作有返回值 |
1 | // 完成异步执行,并返回 future 的结果 |
我们可以通过 CompletableFuture 来异步获取一组数据,并对数据进行一些转换,类似 RxJava、Scala 的 map、flatMap 操作。
我们可以将操作串联起来,或者将 CompletableFuture 组合起来。它的入参是上一个阶段计算后的结果,返回值是经过转化后结果。
1 | // 接受一个 Function<? super T, ? extends U> 参数用来转换 CompletableFuture |
thenCompose 可以用于组合多个 CompletableFuture,将前一个结果作为下一个计算的参数,它们之间存在着先后顺序。
1 | // 在异步操作完成的时候对异步操作的结果进行一些操作,并且仍然返回 CompletableFuture 类型。 |
Function<? super T,? extends U>
参数用来转换 CompletableFuture,相当于流的 map 操作,返回的是非 CompletableFuture 类型,它的功能相当于将 CompletableFuture<T> 转换成 CompletableFuture<U>。thenCombine 方法主要作用:结合两个 CompletionStage 的结果,进行转化后返回。
1 | // 当两个 CompletableFuture 都正常完成后,执行提供的 fn,用它来组合另外一个 CompletableFuture 的结果。 |
thenAcceptBoth 方法主要作用:结合两个 CompletionStage 的结果,进行消耗,返回CompletableFuture<Void> 类型。
1 | // 当两个 CompletableFuture 都正常完成后,执行提供的 action,用它来组合另外一个 CompletableFuture 的结果。 |
当 CompletableFuture 完成计算结果后,我们可能需要对结果进行一些处理。
whenComplete 方法主要作用:当运行完成时,对结果的记录。
当 CompletableFuture 的计算结果完成,或者抛出异常的时候,有如下四个方法:
1 | // 当 CompletableFuture 完成计算结果时对结果进行处理,或者当 CompletableFuture 产生异常的时候对异常进行处理。 |
BiConsumer<? super T, ? super Throwable>
它可以处理正常的计算结果,或者异常情况。handle 方法主要作用:运行完成时,对结果的处理。
除了上述四个方法之外,一组 handle 方法也可用于处理计算结果。当原先的 CompletableFuture 的值计算完成或者抛出异常的时候,会触发这个 CompletableFuture 对象的计算,结果由 BiFunction 参数计算而得。因此这组方法兼有 whenComplete 和转换的两个功能。
1 | // 当 CompletableFuture 完成计算结果或者抛出异常的时候,执行提供的 fn |
上面的方法是当计算完成的时候,会生成新的计算结果 (thenApply, handle),或者返回同样的计算结果 whenComplete。我们可以在每个 CompletableFuture 上注册一个操作,该操作会在 CompletableFuture 完成执行后调用它。
1 | // 当 CompletableFuture 完成计算结果,只对结果执行 Action,而不返回新的计算值。 |
Either 表示的是两个 CompletableFuture,当其中任意一个 CompletableFuture 计算完成的时候就会执行。
applyToEither 方法主要作用:两个 CompletionStage,谁计算的快,我就用那个 CompletionStage 的结果进行下一步的消耗操作。
1 | // 当任意一个 CompletableFuture 完成的时候,action 这个消费者就会被执行。 |
applyToEither 方法主要作用:两个 CompletionStage,谁计算的快,我就用那个 CompletionStage 的结果进行下一步的转化操作。
1 | // 当任意一个 CompletableFuture 完成的时候,fn 会被执行,它的返回值会当作新的 CompletableFuture<U> 的计算结果。 |
allOf、anyOf 是 CompletableFuture 的静态方法。
1 | // 在所有 Future 对象完成后结束,并返回一个 future |
CompletableFuture 在运行时如果遇到异常,可以使用 get() 并抛出异常进行处理,但这并不是一个最好的方法。CompletableFuture 本身也提供了几种方式来处理异常。
1 | // 只有当 CompletableFuture 抛出异常的时候,才会触发这个 exceptionally 的计算,调用 function 计算值。 |
这个简单的示例中创建了一个已经完成的预先设置好结果的 CompletableFuture。通常作为计算的起点阶段。
1 | static void completedFutureExample() { |
下面的例子解释了如何创建一个异步运行 Runnable 的 stage。
1 | static void runAsyncExample() { |
下面的例子引用了第一个例子中已经完成的 CompletableFuture,它将引用生成的字符串结果并将该字符串大写。
1 | static void thenApplyExample() { |
1 | static void thenApplyAsyncExample() { |
异步方法的一个好处是可以提供一个 Executor 来执行 CompletableStage。这个例子展示了如何使用一个固定大小的线程池来实现大写操作。
1 | static ExecutorService executor = Executors.newFixedThreadPool(3, new ThreadFactory() { |
果下一个 Stage 接收了当前 Stage 的结果但是在计算中无需返回值(比如其返回值为 void),那么它将使用方法 thenAccept 并传入一个 Consumer 接口。
1 | static void thenAcceptExample() { |
Consumer 将会同步执行,所以我们无需在返回的 CompletableFuture 上执行 join 操作。
1 | static void thenAcceptAsyncExample() { |
为了简洁性,我们还是将一个字符串大写,但是我们会模拟延时进行该操作。我们会使用 thenApplyAsyn(Function, Executor),第一个参数是大写转化方法,第二个参数是一个延时 executor,它会延时一秒钟再将操作提交给 ForkJoinPool。
1 | static void completeExceptionallyExample() { |
注意:handle 方法返回一个新的 CompletionStage,无论之前的 Stage 是否正常运行完毕。传入的参数包括上一个阶段的结果和抛出异常。
和计算时异常处理很相似,我们可以通过 Future 接口中的 cancel(boolean mayInterruptIfRunning) 来取消计算。
1 | static void cancelExample() { |
注意:exceptionally 方法返回一个新的 CompletableFuture,如果出现异常,则为该方法中执行的结果,否则就是正常执行的结果。
下面的例子创建了一个 CompletableFuture 对象并将 Function 作用于已完成的两个 Stage 中的任意一个(没有保证哪一个将会传递给 Function)。这两个阶段分别如下:一个将字符串大写,另一个小写。
1 | static void applyToEitherExample() { |
和前一个例子类似,将 Function 替换为 Consumer
1 | static void acceptEitherExample() { |
注意这里的两个 Stage 都是同步运行的,第一个 stage 将字符串转化为大写之后,第二个 stage 将其转化为小写。
1 | static void runAfterBothExample() { |
Biconsumer 支持同时对两个 Stage 的结果进行操作。
1 | static void thenAcceptBothExample() { |
如果 CompletableFuture 想要合并两个阶段的结果并且返回值,我们可以使用方法 thenCombine。这里的计算流都是同步的,所以最后的 getNow() 方法会获得最终结果,即大写操作和小写操作的结果的拼接。
1 | static void thenCombineExample() { |
1 | static void thenCombineAsyncExample() { |
我们可以使用 thenCompose 来完成前两个例子中的操作。
1 | static void thenComposeExample() { |
1 | static void anyOfExample() { |
1 | static void allOfExample() { |
1 | static void allOfAsyncExample() { |
下面展示了一个实践 CompletableFuture 的场景:
1、先通过调用 cars() 方法异步获得 Car 列表。它将会返回一个 CompletionStage<List<Car>>。cars() 方法应当使用一个远程的 REST 端点来实现。
2、我们将该 Stage 和另一个 Stage 组合,另一个 Stage 会通过调用 rating(manufactureId) 来异步获取每辆车的评分。
3、当所有的 Car 对象都填入评分后,我们调用 allOf() 来进入最终 Stage,它将在这两个阶段完成后执行
4、 在最终 Stage 上使用 whenComplete(),打印出车辆的评分。
1 | // cars()返回一个汽车列表 |
Java 8 提供了一种函数风格的异步和事件驱动编程模型 CompletableFuture,它不会造成堵塞。CompletableFuture 背后依靠的是 fork/join 框架来启动新的线程实现异步与并发。当然,我们也能通过指定线程池来做这些事情。
CompletableFuture 特别是对微服务架构而言,会有很大的作为。举一个具体的场景,电商的商品页面可能会涉及到商品详情服务、商品评论服务、相关商品推荐服务等等。获取商品的信息时,需要调用多个服务来处理这一个请求并返回结果。这里可能会涉及到并发编程,我们完全可以使用 Java 8 的 CompletableFuture 或者 RxJava 来实现。事实证明,只有当每个操作很复杂需要花费相对很长的时间(比如,调用多个其它的系统的接口;比如,商品详情页面这种需要从多个系统中查数据显示)的时候用 CompletableFuture 才合适,不然区别真的不大,还不如顺序同步执行。
[1]. 猫头鹰的深夜翻译:使用JAVA CompletableFuture的20例子
[2]. Java8学习笔记之CompletableFuture组合式异步编程
在本系列的前一篇博文中,笔者对 RabbitMQ 基本框架、概念、通信过程等基础原理,RabbitMQ 安装教程,RabbitMQ 在项目中实际应用场景等进行了详细的讲解。经过上一篇博客介绍,相信大家对 RabbitMQ 已经有了一个大致了解。Kafka 是由 LinkedIn 公司采用 Scala 语言开发的一个分布式、多分区、多副本且基于 Zookeeper 协调的分布式消息系统,现已捐献给 Apache 基金会。它是一种高吞吐量的分布式发布订阅消息系统,以可水平扩展和高吞吐率而被广泛使用。目前越来越多的开源分布式处理系统如 Cloudera、Apache Storm、Spark、Flink 等都支持与 Kafka 集成。
本文意在介绍 Kafka 的基本原理,包括 Kafka 基本概念、通信过程等,介绍一下 Kafka 安装教程,最后介绍一下 Kafka 在项目中实际应用场景。
Kafka 是一个消息系统,原本开发自 LinkedIn,用作 LinkedIn 的活动流(Activity Stream)和运营数据处理管道(Pipeline)的基础。现在它已被多家不同类型的公司 作为多种类型的数据管道和消息系统使用。
如下图所示,一个典型的 kafka 集群中包含若干 producer(可以是 web 前端产生的 page view,或者是服务器日志,系统 CPU、memory 等),若干 broker(Kafka 支持水平扩展,一般 broker 数量越多,集群吞吐率越高),若干 consumer group,以及一个 Zookeeper 集群。Kafka 通过 Zookeeper 管理集群配置,选举 leader,以及在 consumer group 发生变化时进行 rebalance。producer 使用 push 模式将消息发布到 broker,consumer 使用 pull 模式从 broker 订阅并消费消息。
生产者发送消息流程:
1 | Properties properties = new Properties() {{ |
注意:Producer 有两种错误类型。一种是可以通过再次发送消息解决的错误,比如连接出现问题,需要重新连接;或者是 “no leader” 错误,通过等待一会 Leader 重新选举完就可以继续。Producer 可以配置自动重试。另一种是通过重试无法处理的错误,比如消息过大,这种情况下,Producer 就不会重试,而是直接抛出异常。
1 | Properties properties = new Properties() {{ |
(1)acks:acks 参数指定了必须要有多少个分区副本收到消息,生产者才会认为消息写入是成功的。这个参数消息丢失的可能性有重要影响。只有当 leader 确认已成功写入消息的副本数后,才会给 Producer 发送响应,此时消息才可以认为 “已提交”。该参数影响着消息的可靠性以及生产端 的 吞吐量,并且两者往往相向而驰,通常消息可靠性越高则生产端的吞吐量越低。
调优建议:建议根据实际情况设置,如果要严格保证消息不丢失,请设置为 all 或 - 1;如果允许存在丢失,建议设置为 1;一般不建议设为 0,除非无所谓消息丢不丢失。
(2)batch.size:发送到缓冲区中的消息会被分为一个一个的 batch,分批次的发送到 broker 端,这个参数就表示 batch 批次大小,默认值为 16384,即 16KB。因此减小 batch 大小有利于降低消息延时,增加 batch 大小有利于提升吞吐量。
调优建议:通常合理调大该参数值,能够显著提升生产端吞吐量,比如可以调整到 32KB,调大也意味着消息会有相对较大的延时。
(3)buffer.memory:表示生产端消息缓冲池或缓冲区的大小,默认值为 33554432,即 32M。这个参数基本可以认为是 Producer 程序所使用的内存大小。当前版本中,如果生产消息的速度过快导致 buffer 满了的时候,将阻塞 max.block.ms(默认 60000 即 60s)配置的时间,超时将会抛 TimeoutException 异常。在 Kafka 0.9.0 及之前版本,建 议设置另一个参数 block.on.buffer.full 为 true,该参数表示当 buffer 填满时 Producer 处于阻塞状态并停止接收新消息而不是抛异常。
调优建议:通常我们应尽量保证生产端整体吞吐量,建议适当调大该参数,也意味着生产客户端会占用更多的内存。也可以选择不调整 。
(4)compression.type:表示生产端是否对消息进行压缩,默认值为 none,即不压缩消息。压缩可以显著减少网络 IO 传输、磁盘 IO 以及磁盘空间,从而提升整体吞吐量,但也是以 牺牲 CPU 开销为代价的 。当前 Kafka 支持 4 种压缩方式,分别是 gzip、snappy、 lz4 及 zstd(Kafka 2.1.0 开始支持) 。
调优建议:出于提升吞吐量的考虑,建议在生产端对消息进行压缩。对于 Kafka 而已,综合考虑吞吐量与压缩比,建议选择 lz4 压缩。如果追求最高的压缩比则推荐 zstd 压缩 。
(5)max.request.size:这个参数比较重要,表示生产端能够发送的 最大 消息大小,默认值为 1048576,即 1M。
调优建议:一般而言,这个配置有点小,为了避免因消息过大导致发送失败,建议适当调大,比如调到 10485760 即 10M。
(6)retries:表示生产端消息发送失败时的重试次数,默认值为 0,表示不进行重试。这个参数一般是为了解决因瞬时故障导致的消息发送失败, 比如网络抖动、leader 换主,其中瞬时的 leader 重选举是比较常见的 。因此这个参数的设置显得非常重要。另外为了避免频繁重试的影响,两次重试之间都会停顿一段时间,受参数 retry.backoff.ms,默认为 100ms,通常可以不调整。
调优建议:这里要尽量避免消息丢失,建议设置为一个大于 0 的值,比如 3 或者更大值 。
(7)linger.ms:用来控制 batch 最大的空闲时间,超过该时间的 batch 也会被发送到 broker 端。这实际上是一种权衡,即吞吐量与延时之间的权衡。默认值为 0,表示消息需要被立即发送,无需关系 batch 是否被填满。
调优建议:通常为了减少请求次数、提升整体吞吐量,建议设置一个大于 0 的值,比如设置为 100,此时会在负载低的情况下带来 100ms 的延时 。
(8)request.timeout.ms:这个参数表示生产端发送请求后等待 broker 端响应的最长时间,默认值为 30000,即 30s,超时生产端可能会选择重试(如果配置了 retries)。
调优建议:该参数默认值一般够用了。如果生产端负载很大,可以适当调大以避免超时,比如可以调到 60000。
(9)max.in.flight.requests.per.connection:表示生产端与 broker 之间的每个连接最多缓存的请求数,默认值为 5,即每个连接最多可以缓存 5 个未响应的请求,该参数指定了生产者在收到服务器响应之前可以发送多少个消息。这个参数通常用来解决分区乱序的问题。
调优建议:为了避免消息乱序问题,建议将该参数设置为 1,表示生产端在某个 broker 响应之前将无法再向该 broker 发送消息请求,这能够有效避免同一分区下的消息乱序问题。
(10)interceptor.classes:用作拦截器的类的列表。通过实现 ProducerInterceptor 接口,您可以在生产者发布到 Kafka 集群之前拦截(并可能会改变)生产者收到的记录。默认情况下,没有拦截器,可自定义拦截器。
(11)partitioner.class:实现 Partitioner 接口的分区器类。默认使用 DefaultPartitioner 来进行分区。
消费者消费消息流程:
1 | Properties properties = new Properties() {{ |
1 | Properties properties = new Properties() {{ |
注意:消费者为什么要提交偏移量?当消费者崩溃或者有新的消费者加入,那么就会触发再均衡(rebalance),完成再均衡后,每个消费者可能会分配到新的分区,而不是之前处理那个,为了能够继续之前的工作,消费者需要读取每个 partition 最后一次提交的偏移量,然后从偏移量指定的地方继续处理。
Kafka 与消费者相关的配置大部分参数都有合理的默认值,一般不需要修改,不过有一些参数与消费者的性能和可用性有很大关系。
(1)fetch.min.bytes:指定消费者从服务器获取记录的最小字节数。服务器在收到消费者的数据请求时,如果可用的数据量小于 fetch.min.bytes,那么会等到有足够的可用数据时才返回给消费者。
调优建议:合理的设置可以降低消费者和 broker 的工作负载,在 Topic 消息生产不活跃时,减少处理消息次数。如果没有很多可用数据,但消费者的 CPU 使用率却很高,需要调高该属性的值。如果消费者的数量比较多,调高该属性的值也可以降低 broker 的工作负载。
(2)fetch.max.wait.ms:指定在 broker 中的等待时间,默认是 500ms。如果没有足够的数据流入 Kafka,消费者获取的数据量的也没有达到 fetch.min.bytes,最终导致 500ms 的延迟。
调优建议:如果要降低潜在的延迟(提高 SLA),可以调低该属性的值。fetch.max.wait.ms 和 fetch.min.bytes 有一个满足条件就会返回数据。
(3)max.parition.fetch.bytes:指定了服务器从每个分区里返回给消费者的最大字节数,默认值是 1MB。也就是说 KafkaConsumer#poll() 方法从每个分区里返回的记录最多不超过 max.parition.fetch.bytes 指定的字节。
调优建议:如果一个主题有 20 个分区和 5 个消费者(同一个组内),那么每个消费者需要至少 4MB 的可用内存(每个消费者读取 4 个分区)来接收记录。如果组内有消费者发生崩溃,剩下的消费者需要处理更多的分区。max.parition.fetch.bytes 必须比 broker 能够接收的最大消息的字节数(max.message.size)大,否则消费者可能无法读取这些消息,导致消费者一直重试。
(4)session.timeout.ms:指定了消费者与服务器断开连接的最大时间,默认是 3s。如果消费者没有在指定的时间内发送心跳给 GroupCoordinator,就被认为已经死亡,会触发再均衡,把它的分区分配给其他消费者。
调优建议:该属性与 heartbeat.interval.ms 紧密相关,heartbeat.interval.ms 指定了 poll() 方法向协调器发送心跳的频率,session.timeout.ms 指定了消费者最长多久不发送心跳。所以,一般需要同时修改这两个属性,heartbeat.interval.ms 必须比 session.timeout.ms 小,一般是 session.timeout.ms 的三分之一,如果 session.timeout.ms 是 3s,那么 heartbeat.interval.ms 应该是 1s。
(5)auto.offset.reset:指定了消费者在读取一个没有偏移量(offset)的分区或者偏移量无效的情况下(因消费者长时间失效,包含偏移量的记录已经过时井被删除)该作何处理,默认值是 latest,表示在 offset 无效的情况下,消费者将从最新的记录开始读取数据(在消费者启动之后生成的记录)。
调优建议:另一个值是 earliest,消费者将从起始位置读取分区的记录。
(6)enable.auto.commit:指定了消费者是否自动提交偏移量,默认值是 true,自动提交。
调优建议:设为 false 可以程序自己控制何时提交偏移量。如果设为 true,需要通过配置 auto.commit.interval.ms 属性来控制提交的频率。
(7)partition.assignment.strategy:分区分配给组内消费者的策略,根据给定的消费者和 Topic,决定哪些分区应该被分配给哪个消费者。
调优建议:Kafka 有两个默认的分配策略。Range,把 Topic 的若干个连续的分区分配给消费者;RoundRobin,把所有分区逐个分配给消费者。默认值是 org.apache.kafka.clients.consumer.RangeAssignor,这个类实现了 Range 策略,org.apache.kafka.clients.consumer.RoundRobinAssignor 是 RoundRobin 策略的实现类。还可以使用自定义策略,属性值设为自定义类的名字。
(8)client.id:broker 用来标识从客户端发送过来的消息,可以是任意字符串,通常被用在日志、度量指标和配额中。
(9)max.poll.records:用于控制单次调用 call() 方法能够返回的记录数量,帮助控制在轮询里需要处理的数据量。
(10)receive.buffer.bytes:指定了 TCP socket 接收数据包的缓冲区大小。如果设为 - 1 就使用操作系统的默认值。如果生产者或消费者与 broker 处于不同的数据中心,那么可以适当增大这些值,因为跨数据中心的网络一般都有比较高的延迟和比较低的带宽。
(11)send.buffer.bytes:指定了 TCP socket 发送数据包的缓冲区大小。如果设为 - 1 就使用操作系统的默认值。如果生产者或消费者与 broker 处于不同的数据中心,那么可以适当增大这些值,因为跨数据中心的网络一般都有比较高的延迟和比较低的带宽。
本文统一使用软件包管理器的方式安装 Kafka,减少环境变量的配置,更加方便快捷。
从 Kafka 官网下载 Kafka 安装包,解压安装,或直接使用命令下载。Kafka 依赖 ZooKeeper,从 ZooKeeper 官网下载 ZooKeeper 安装包,解压安装,或直接使用命令下载。
1 | # 1. 下载 kafka |
Kafka 是使用 Zookeeper 来保存集群元数据信息和消费者信息。虽然 Kafka 发行版已经自带了 Zookeeper,可以通过脚本直接启动,但仍然建议安装一个完整版的 Zookeeper。
Mac 中使用 brew 安装 Kafka 的方法
1 | # 1. 使用 Kafka 安装,由于 Kafka 依赖了 Zookeeper,所以在下载的时候会自动下载。 |
1 | # 当前机器在集群中的唯一标识,和 zookeeper 的 myid 性质一样 |
1 | # 创建 topic[创建一个名为 test 的 topic,只有一个副本,一个分区] |
友情提示:对于 Kafka 数据我们可以直接通过 IDEA 提供的 Kafka 可视化管理插件 - Kafkalytic 来查看。
Spring 创建了一个项目 Spring-kafka,封装了 Apache 的 Kafka-client,用于在 Spring 项目里快速集成 kafka。除了简单的收发消息外,Spring-kafka 还提供了很多高级功能,下面我们就来一一探秘这些用法。
1、配置 Pom 包,主要是添加 spring-kafka 的支持
1 | <dependency> |
2、配置 kafka 的安装地址、端口以及账户信息
1 | spring.kafka.bootstrap-servers=localhost:9092 |
3、主题配置
1 |
|
4、发送者
1 |
|
5、接收者
1 | @Component |
6、 测试
1 |
|
默认情况下,Spring-kafka 自动生成的 KafkaTemplate 实例,是不具有事务消息发送能力的。如果需要开启事务机制,使用默认配置需要在 application.properties 添加 spring.kafka.producer.transaction-id-prefix 配置或者通过 Java Config 方式自己初始化 Bean。事务激活后,所有的消息发送只能在发生事务的方法内执行了,不然就会抛一个没有事务交易的异常。
1 | // 通过 application.properties 添加 spring.kafka.producer.transaction-id-prefix 配置激活事务 |
Spring-Kafka 的事务消息是基于 Kafka 提供的事务消息功能的,Kafka 使用事务的两种方式:1、配置 Kafka 事务管理器并使用 @Transactional 注解;2、用 KafkaTemplate 的 executeInTransaction 方法
(一)配置 Kafka 事务管理器并使用 @Transactional 注解
使用注解方式开启事务还是比较方便的,不过首先需要我们配置 KafkaTransactionManager,这个类就是 Kafka 提供给我们的事务管理类,我们需要使用生产者工厂来创建这个事务管理类。通过 application.properties 添加 spring.kafka.producer.transaction-id-prefix 配置,KafkaAutoConfiguration 类会自动帮我们配置好相应的 Bean,感兴趣的同学可以阅读 KafkaAutoConfiguration 类的方法。
1 | @Test |
运行测试方法后我们可以看到控制台中输出了如下日志:
1 | org.apache.kafka.common.KafkaException: Failing batch since transaction was aborted |
(二)使用 KafkaTemplate 的 executeInTransaction 方法
1 |
|
通过 KafkaTemplate 发送消息时,我们可以通过异步或者同步的方式获取发送结果:
(1)异步获取
1 |
|
(2)同步获取
1 |
|
前面在简单集成中已经演示过了 @KafkaListener 接收消息的能力,但是 @KafkaListener 的功能不止如此,其他的比较常见的,使用场景比较多的功能点如下:
(1)显示的指定消费哪些Topic和分区的消息
(2)设置每个Topic以及分区初始化的偏移量
(3)设置消费线程并发度
(4)设置消息异常处理器
1 | @KafkaListener(id = "webGroup", topicPartitions = { |
errorHandler 需要设置这个参数需要实现一个接口 KafkaListenerErrorHandler。而且注解里的配置,是你自定义实现实例在 spring 上下文中的 Name。比如,上面配置为 errorHandler = “myErrorHandler”。
1 | @Service("myErrorHandler") |
@KafkaListener 注解的监听器的生命周期是可以控制的,默认情况下,@KafkaListener 的参数 autoStartup = “true”。也就是自动启动消费,但是也可以同过 KafkaListenerEndpointRegistry 来干预他的生命周期。KafkaListenerEndpointRegistry 有三个动作方法分别如:启动 start()、停止 pause()、继续 resume();接下来我们通过一个场景来描述一下这个功能的用途:比如现在单机环境下,我们需要利用 Kafka 做数据持久化的功能,由于用户活跃的时间为早上 10 点至晚上 12 点,那在这个时间段做一个大数据量的持久化可能会影响数据库性能导致用户体验降低,我们可以选择在用户活跃度低的时间段去做持久化的操作,也就是晚上 12 点后到第二条的早上 10 点前。
1 | @Slf4j |
手动 ACK 模式,由业务逻辑控制提交偏移量。开启手动首先需要关闭自动提交,然后设置下 consumer 的消费模式:
1 | // enable.auto.commit 参数设置成 false。那么就是 Spring 来替为我们做人工提交,从而简化了人工提交的方式 |
上面的设置好后,在消费时,只需要在 @KafkaListener 监听方法的入参加入 Acknowledgment 即可,执行到 ack.acknowledge() 代表提交了偏移量:
1 | @KafkaListener(id = "hello", topics = "topic_input") |
@SendTo 注解可以带一个参数,指定转发的 Topic 队列。常见的场景如,一个消息需要做多重加工,不同的加工耗费的 cup 等资源不一致,那么就可以通过跨不同 Topic 和部署在不同主机上的 consumer 来解决了。
1 |
|
kafka 使用分区将 topic 的消息打散到多个分区分布保存在不同的 broker 上,实现了 producer 和 consumer 消息处理的高吞吐量。Kafka 的 producer 和 consumer 都可以多线程地并行操作,而每个线程处理的是一个分区的数据。因此分区实际上是调优 Kafka 并行度的最小单元。对于 producer 而言,它实际上是用多个线程并发地向不同分区所在的 broker 发起 Socket 连接同时给这些分区发送消息;而 consumer,同一个消费组内的所有 consumer 线程都被指定 topic 的某一个分区进行消费。所以说,如果一个 topic 分区越多,理论上整个集群所能达到的吞吐量就越大。
分区多的缺点:
一、客户端 / 服务器端内存开销
Kafka0.8.2 之后,在客户端 producer 有个参数 batch.size,默认是 16KB。它会为每个分区缓存消息,一旦满了就打包将消息批量发出。不过很显然,因为这个参数是分区级别的,如果分区数越多,这部分缓存所需的内存占用也会更多。对于服务器端 consumer 的开销也不小,如果阅读 Kafka 源码的话可以发现,服务器端的很多组件都在内存中维护了分区级别的缓存,比如 controller,FetcherManager 等,因此分区数越多,这种缓存的成本就越大。
二、文件句柄的开销
每个分区在底层文件系统都有属于自己的一个目录。该目录下通常会有两个文件: base_offset.log 和 base_offset.index。Kafak 的 Controller 和 ReplicaManager 会为每个 broker 都保存这两个文件句柄 (file handler)。很明显,如果分区数越多,所需要保持打开状态的文件句柄数也就越多,最终可能会突破你的 ulimit -n 的限制。
三、降低高可用性
Kafka 通过副本 (replica) 机制来保证高可用。具体做法就是为每个分区保存若干个副本 (replica_factor 指定副本数)。每个副本保存在不同的 broker 上。其中的一个副本充当 leader 副本,负责处理 producer 和 consumer 请求。其他副本充当 follower 角色,由 Kafka controller 负责保证与 leader 的同步。如果 leader 所在的 broker 挂掉了,contorller 会检测到然后在 zookeeper 的帮助下重选出新的 leader —— 这中间会有短暂的不可用时间窗口,虽然大部分情况下可能只是几毫秒级别。但如果你有 10000 个分区,10 个 broker,也就是说平均每个 broker 上有 1000 个分区。此时这个 broker 挂掉了,那么 zookeeper 和 controller 需要立即对这 1000 个分区进行 leader 选举。比起很少的分区 leader 选举而言,这必然要花更长的时间,并且通常不是线性累加的。
一、按照 key 值分配:默认情况下,Kafka 根据传递消息的 key 来进行分区的分配,即 hash(key) % numPartitions,这保证了相同 key 的消息一定会被路由到相同的分区。
二、key 为 null 时,从缓存中取分区 id 或者随机取一个:不指定 key 时,Kafka 几乎就是随机找一个分区发送无 key 的消息,然后把这个分区号加入到缓存中以备后面直接使用——当然了,Kafka 本身也会清空该缓存(默认每 10 分钟或每次请求 topic 元数据时)。
一、Topic 分区副本
在 Kafka 0.8.0 之前,Kafka 是没有副本的概念的,那时候人们只会用 Kafka 存储一些不重要的数据,因为没有副本,数据很可能会丢失。但是随着业务的发展,支持副本的功能越来越强烈,所以为了保证数据的可靠性,Kafka 从 0.8.0 版本开始引入了分区副本。也就是说每个分区可以人为的配置几个副本(比如创建主题的时候指定 replication-factor,也可以在 Broker 级别进行配置 default.replication.factor),一般会设置为 3。通过分区副本,引入了数据冗余,同时也提供了 Kafka 的数据可靠性。Kafka 的分区多副本架构是 Kafka 可靠性保证的核心,把消息写入多个副本可以使 Kafka 在发生崩溃时仍能保证消息的持久性。
二、Producer 往 Broker 发送消息
Kafka 在 Producer 里面提供了消息确认机制,也就是说我们可以通过配置来决定消息发送到对应分区的几个副本才算消息发送成功。根据实际的应用场景,我们设置不同的 acks,以此保证数据的可靠性。另外,Producer 发送消息还可以选择同步(默认,通过 producer.type=sync 配置) 或者异步(producer.type=async)模式。如果设置成异步,虽然会极大的提高消息发送的性能,但是这样会增加丢失数据的风险。如果需要确保消息的可靠性,必须将 producer.type 设置为 sync。
三、Leader 选举
每个分区的 leader 会维护一个 ISR 列表,ISR 列表里面就是 follower 副本的 Borker 编号,只有跟得上 Leader 的 follower 副本才能加入到 ISR 里面,只有 ISR 里的成员才有被选为 leader 的可能。所以当 Leader 挂掉了,而且 unclean.leader.election.enable=false 的情况下,Kafka 会从 ISR 列表中选择第一个 follower 作为新的 Leader,因为这个分区拥有最新的已经 committed 的消息。通过这个可以保证已经 committed 的消息的数据可靠性。
综上所述,为了保证数据的可靠性,我们最少需要配置一下几个参数:
这里介绍的数据一致性主要是说不论是老的 Leader 还是新选举的 Leader,Consumer 都能读到一样的数据。那么 Kafka 是如何实现的呢?
假设分区的副本为 3,其中副本 0 是 Leader,副本 1 和副本 2 是 follower,并且在 ISR 列表里面。虽然副本 0 已经写入了 Message4,但是 Consumer 只能读取到 Message2。因为所有的 ISR 都同步了 Message2,只有 High Water Mark 以上的消息才支持 Consumer 读取,而 High Water Mark 取决于 ISR 列表里面偏移量最小的分区,对应于上图的副本 2,这个很类似于木桶原理。这样做的原因是还没有被足够多副本复制的消息被认为是 “不安全” 的,如果 Leader 发生崩溃,另一个副本成为新 Leader,那么这些消息很可能丢失了。如果我们允许消费者读取这些消息,可能就会破坏一致性。试想,一个消费者从当前 Leader(副本 0) 读取并处理了 Message4,这个时候 Leader 挂掉了,选举了副本 1 为新的 Leader,这时候另一个消费者再去从新的 Leader 读取消息,发现这个消息其实并不存在,这就导致了数据不一致性问题。当然,引入了 High Water Mark 机制,会导致 Broker 间的消息复制因为某些原因变慢,那么消息到达消费者的时间也会随之变长(因为我们会先等待消息复制完毕)。延迟时间可以通过参数 replica.lag.time.max.ms 参数配置,它指定了副本在复制消息时可被允许的最大延迟时间。
Kafka 是一个高吞吐的消息队列,是大数据场景首选的消息队列,这种场景就意味着发送单位时间消息的量会特别的大,那么 Kafka 如何做到能支持能同时发送大量消息的呢?
Kafka 通过批量压缩和发送做到能支持能同时发送大量消息。Kafka 的 kafkaProducer 对象是线程安全的,每个发送线程在发送消息时候共用一个 kafkaProducer 对象来调用发送方法,最后发送的数据根据 Topic 和分区的不同被组装进某一个 RecordBatch 中。发送的数据放入 RecordBatch 后会被发送线程批量取出组装成 ProduceRequest 对象发送给 Kafka 服务端。
Kafka 通过使用内存缓冲池的设计,让整个发送过程中的存储空间循环利用,有效减少 JVM GC 造成的影响,从而提高发送性能,提升吞吐量。也就是说,Kafka 首先判断需要存储的数据的大小是否 free(已申请未使用空间)里有合适的 recordBatch 装得下,如果装得下则用 recordBatch 来存储数据;如果 free(已申请未使用空间)里没有空间但是 availableMemory(未申请未使用)+free(已申请未使用空间)的大小比需要存储的数据大(也就是说可使用空间比实际需要申请的空间大),说明可使用空间大小足够,则会用让 free 一直释放 byteBuffer 空间直到有空间装得下要存储的数据位置;如果需要申请的空间比实际可使用空间大,则内存申请会阻塞直到申请到足够的内存为止。
[1]. kafka生产者Producer参数设置及参数调优建议-kafka商业环境实战系列
[2]. spring-kafka生产者消费者配置详解
[3]. Kafka 安装及快速入门
[4]. spring boot集成kafka之spring-kafka深入探秘
[5]. Spring Boot Kafka概览、配置及优雅地实现发布订阅
[6]. Kafka 是如何保证数据可靠性和一致性
[1]. ISR:ISR((In Sync Replicas)所有与 leader 副本保持一定程度同步的副本(包括 leader 副本在内)组成 ISR (In Sync Replicas)。分区中的所有副本统称为 AR (Assigned Replicas)。ISR 集合是 AR 集合的一个子集。消息会先发送到 leader 副本,然后 follower 副本才能从 leader 中拉取消息进行同步。同步期间,follow 副本相对于 leader 副本而言会有一定程度的滞后。前面所说的 ”一定程度同步 “ 是指可忍受的滞后范围,这个范围可以通过参数进行配置。于 leader 副本同步滞后过多的副本(不包括 leader 副本)将组成 OSR (Out-of-Sync Replied)由此可见,AR = ISR + OSR。正常情况下,所有的 follower 副本都应该与 leader 副本保持 一定程度的同步,即 AR=ISR,OSR 集合为空。leader 副本负责维护和跟踪 ISR 集合中所有 follower 副本的滞后状态,当 follower 副本落后太多或失效时,leader 副本会把它从 ISR 集合中剔除。如果 OSR 集合中所有 follower 副本“追上” 了 leader 副本,那么 leader 副本会把它从 OSR 集合转移至 ISR 集合。默认情况下,当 leader 副本发生故障时,只有在 ISR 集合中的 follower 副本才有资格被选举为新的 leader,而在 OSR 集合中的副本则没有任何机会。
Java 11 已于 2018 年 9 月 25 日正式发布,Java 开发团队为了加快的版本迭代、跟进社区反馈,Java 的版本发布周期调整为每六个月一次——即每半年发布一个大版本,每个季度发布一个中间特性版本,并且做出不会跳票的承诺。通过这样的方式,Java 开发团队能够将一些重要特性尽早的合并到 Java Release 版本中,以便快速得到开发者的反馈,避免出现类似 Java 9 发布时的两次延期的情况。
本文主要针对 Java 9 - Java 11 中的新特性展开介绍,让您快速了解 Java 9 - Java 11 带来的变化。
按照官方介绍,新的版本发布周期将会严格按照时间节点,于每年的 3 月和 9 月发布,Java 11 发布的时间节点也正好处于 Java 8 免费更新到期的前夕。与 Java 9 和 Java 10 这两个被称为 “功能性的版本” 不同,Java 11 仅将提供长期支持服务(LTS, Long-Term-Support),还将作为 Java 平台的默认支持版本,并且会提供技术支持直至 2023 年 9 月,对应的补丁和安全警告等支持将持续至 2026 年。
REPL(Read Eval Print Loop)意为交互式的编程环境。JShell 是 Java 9 新增的一个交互式的编程环境工具。它允许你无需使用类或者方法包装来执行 Java 语句。它与 Python 的解释器类似,可以直接 输入表达式并查看其执行结果。JShell 它提供了一个交互式 shell,用于快速原型、调试、学习 Java 及 Java API,所有这些都不需要 public static void main 方法,也不需要在执行之前编译代码。
1、执行 JSHELL
1 | $ jshell |
2、查看 JShell 命令:输入 /help 可以查看 JShell 相关的命令
1 | jshell> /help |
3、JShell 执行使用
1 | // JShell 执行计算 |
4、退出 JShell:输入 /exit 命令退出 jshell
1 | /exit |
从 Java 10 开始,便引入了局部变量类型推断这一关键特性。类型推断允许使用关键字 var 作为局部变量的类型而不是实际类型,编译器根据分配给变量的值推断出类型。这一改进简化了代码编写、节省了开发者的工作时间,因为不再需要显式声明局部变量的类型,而是可以使用关键字 var,且不会使源代码过于复杂。var 是 Java10 中新增的局部类型变量推断。它会根据后面的值来推断变量的类型,所以 var 必须要初始化。var 其实就是 Java 10 增加的一种语法糖而已,在编译期间会自动推断实际类型,其编译后的字节码和实际类型一致。
但是在 Java 10 中,还有下面几个限制:
1 | // var 定义局部变量[等同于 int a = 1;] |
Java 11 与 Java 10 的不同之处在于允许开发者在 Lambda 表达式中使用 var 进行参数声明。乍一看,这一举措似乎有点多余,因为在写代码过程中可以省略 Lambda 参数的类型,并通过类型推断确定它们。但是,添加上类型定义同时使用 @Nonnull 和 @Nullable 等类型注释还是很有用的,既能保持与局部变量的一致写法,也不丢失代码简洁。
1 | @Nonnull var x = new Foo(); |
局部变量类型推断优缺点
(1)优点:简化代码
1 | CopyOnWriteArrayList list1 = new CopyOnWriteArrayList(); |
从以上代码可以看出,很长的定义类型会显得代码很冗长,使用 var 大大简化了代码编写,同时类型统一显得代码很对齐。
(2)缺点:掩盖类型
1 | var token = new JsonParserDelegate(parser).currentToken(); |
看以上代码,不进去看返回结果类型,谁知道返回的类型是什么?所以这种情况最好别使用 var,而使用具体的抽象类、接口或者实例类型。
在 Java 8 之前,接口可以有常量变量和抽象方法。我们不能在接口中提供方法实现。如果我们要提供抽象方法和非抽象方法(方法与实现)的组合,那么我们就得使用抽象类。
在 Java 8 接口引入了一些新功能——默认方法和静态方法。我们可以在Java SE 8的接口中编写方法实现,仅仅需要使用 default 关键字来定义它们。
Java 9 不仅像 Java 8 一样支持接口默认方法,同时还支持私有方法。在 Java 9 中,一个接口中能定义如下几种变量/方法:
1 | public interface Employee { |
Java 11 增加了一系列的字符串处理方法,如以下所示:
1 | // 判断字符串是否为空白 |
自 Java 9 开始,Jdk 里面为集合(List/ Set/ Map)都添加了 of 和 copyOf 方法,它们两个都用来创建不可变的集合,来看下它们的使用和区别。
1 | // 不可变集合 List |
注意:使用 of 创建的集合为不可变集合,不能进行添加、删除、替换、排序等操作,不然会报 java.lang.UnsupportedOperationException 异常,使用 Set.of() 不能出现重复元素、Map.of() 不能出现重复 key,否则回报 java.lang.IllegalArgumentException。
1、Arrays.asList 返回可变的 list,而 List.of 返回的是不可变的 list
1 | List<Integer> list = Arrays.asList(1, 2, null); |
2、Arrays.asList 支持 null,而 List.of 不行
1 | List<Integer> list = Arrays.asList(1, 2, null); // OK |
3、List.of 和 Arrays.asList 的 contains 方法对 null 处理不一样
1 | List<Integer> list = Arrays.asList(1, 2, 3); |
4、Arrays.asList 的数组的修改会影响原数组
1 | Integer[] array = {1, 2, 3}; |
1 | // 不可变集合 List |
来看下它们的源码:
1 | static <E> List<E> of(E... elements) { |
可以看出 copyOf 方法会先判断来源集合是不是 AbstractImmutableList 类型的,如果是,就直接返回,如果不是,则调用 of 创建一个新的集合。
Stream 是 Java 8 中的新特性,Java 9 开始对 Stream 增加了以下 4 个新方法。
(1) 增加单个参数构造方法,可为 null,此方法可以接收 null 来创建一个空流
1 | long count = Stream.ofNullable(null).count(); |
(2)增加 takeWhile 和 dropWhile 方法
1 | // 从开始计算,当 n < 3 时就截止:此方法根据 Predicate 接口来判断如果为 true 就取出来生成一个新的流,只要碰到 false 就终止,不管后边的元素是否符合条件。 |
(3)iterate 重载
以前使用 iterate 方法生成无限流需要配合 limit 进行截断
1 | List<Integer> collect2 = Stream.iterate(1, i -> i + 1) |
现在重载后这个方法增加了个判断参数
1 | List<Integer> collect3 = Stream.iterate(1, i -> i <= 5, i -> i + 1) |
Opthonal 也增加了几个非常酷的方法,现在可以很方便的将一个 Optional 转换成一个 Stream,或者当一个空 Optional 时给它一个替代的。
(1)stream():stream 方法的作用就是将 Optional 转为一个 Stream,如果该 Optional 中包含值,那么就返回包含这个值的 Stream,否则返回一个空的 Stream(Stream.empty())。
1 | // 返回Optional值的流 |
(2)ifPresentOrElse(Consumer< ? super T> action, Runnable emptyAction):ifPresentOrElse 方法的改进就是有了 else,接受两个参数 Consumer 和 Runnable。ifPresentOrElse 方法的用途是,如果一个 Optional 包含值,则对其包含的值调用函数 action,即 action.accept(value),这与 ifPresent 一致;与 ifPresent 方法的区别在于,ifPresentOrElse 还有第二个参数 emptyAction —— 如果 Optional 不包含值,那么 ifPresentOrElse 便会调用 emptyAction,即 emptyAction.run()。
1 | Optional<Integer> optional = Optional.of(1); |
(3)or(Supplier< ? extends Optional< ? extends T>> supplier):如果值存在,返回 Optional 指定的值,否则返回一个预设的值。
1 | Optional<String> optional1 = Optional.of("Mahesh"); |
Java 11 对 Java 9 中引入并在 Java 10 中进行了更新的 Http Client API 进行了标准化,在前两个版本中进行孵化的同时,Http Client 几乎被完全重写,并且现在完全支持异步非阻塞。
Java 11 中的新 Http Client API,提供了对 HTTP/2 等业界前沿标准的支持,同时也向下兼容 HTTP/1.1,精简而又友好的 API 接口,与主流开源 API(如:Apache HttpClient、Jetty、OkHttp 等)类似甚至拥有更高的性能。与此同时它是 Java 在 Reactive-Stream 方面的第一个生产实践,其中广泛使用了 Java Flow API,终于让 Java 标准 HTTP 类库在扩展能力等方面,满足了现代互联网的需求,是一个难得的现代 Http/2 Client API 标准的实现,Java 工程师终于可以摆脱老旧的 HttpURLConnection 了。
同步请求会阻止当前线程
1 | var request = HttpRequest.newBuilder() |
异步请求不会阻止当前线程,而是返回 CompletableFuture 来进行异步操作
1 | var client = HttpClient.newHttpClient(); |
Java 11 版本中最令人兴奋的功能之一是增强 Java 启动器,使之能够运行单一文件的 Java 源代码。此功能允许使用 Java 解释器直接执行 Java 源代码。源代码在内存中编译,然后由解释器执行。唯一的约束在于所有相关的类必须定义在同一个 Java 文件中。
如今单文件程序在编写小实用程序时很常见,特别是脚本语言领域。从中开发者可以省去用 Java 编译程序等不必要工作,以及减少新手的入门障碍。
举个例子,写一个类文件 HelloWorld.java
1 | public class HelloWorld { |
以前简化启动单个源代码文件需要这样运行
1 | $ javac HelloWorld.java |
现在只需要这样1
2$ java HelloWorld.java
Hello World
现在是 2020 年 1 月 22 日,戊戌狗年,腊月十七,星期三,也是今年我在公司上班的最后一天。临近年末,各个项目已经陆续的开始封版,我也开始光明正大的摸鱼了,终于体会到 “摸鱼一时爽,一直摸鱼一只爽”。哈哈哈哈哈,开玩笑。按道理来说,本来 2019 年度总结,我应该在 2019 年 12 月 31 日写完,然后在 2020 年 1 月 1 日发表出来,这样的节奏,生活才有仪式感。那么为什么不是这样的节奏进行呢?因为我的拖延症又又又犯了,我可太难了 (┬_┬)。在 2019 年年末没有完成年终总结,本来打算就不写了。后来想想还是打算写一下,一方面是因为之前从来没有写过年终总结,另一方面是今年我的生活还是发生了很大的变化,是值得纪念的一年,因此还是想把我的 2019 年总结一下。那么,我就开始回顾一下我的 2019 年。
上图是我 2018 年为我 2019 年定的小目标以及 2019 年我对这些目标的完成情况,我就从这么小目标进行展开,来详细的回顾一下我的 2019 年发生的点点滴滴。
我好想是一个从来不喜欢看书的孩子。上学的时候,看书、背书是为了考试,好像只是为了应付考试,去死记硬背。从而我的短期记忆力好像很强,但是考试考完之后,所有背的东西就选择性一下子忘光了,我想,这可能就是我学不好英语的主要原因吧!上大学的时候,除了课本以外,陆陆续续的看了几本书,那时,好像真的是为了 “文艺” 去看书(┬_┬)。工作后的第一年安顿生活,第二年选择安逸活着,也没有动力去看书。而 2019 年的我,好像除了应付面试,死记硬背面试题;以及阅读《Effective Java 中文版(第 3 版)》、《Redis 设计与实现》 这两本书籍以外并没有刻意的去学习,与我年初定下的目标还有很大的差距,因此这项我给我自己打未完成。
说是每天运动,可能是我当初设置目标的表达不够准确,应该是每个星期坚持跑步 2 次。2019 年,我一般都是在星期一、三晚上到家后都会去马路上进行长跑,虽然有时候也会因为某些原因中断掉,但总算是踉踉跄跄的坚持下来了。长跑可能是我比较喜欢的运动,也是我坚持最长久的一项运动。高中的时候上完晚自习,我会去操场上跑上几圈,那时真的是单纯的释放压力;后来,我发现我特别享受跑步的过程,我享受定下目标后,一步一步接近目标那种感觉,所以跑步渐渐成为了我的爱好之一,并且一直坚持着。2019 年我共跑步 372.59 公里,跑步时长 35:06:12,共跑步 58 次,算勉强完成当时定下的目标。其实,当初我定这个目标的主要目的,就是督促自己坚持锻炼,我个人认为,出门在外照顾好自己,就是对父母最大的回报。
就我个人而言,我还是一个比较固执的人,很多时候,自己想好的事情,很容易一条道走到黑,听不进别人的意见。当初定下这个目标的想法就是想改改自己这个坏毛病,但是,一年下来,我发现,这东西是性格方面的原因,一时半会儿很难去改变,这一小目标也没有达成。但是,我意识到自己的缺点,我会在以后的生活着,尝试着慢慢去改正。
我希望所有的事情能够一件一件来,等我处理完这件事后,下一件事情在过来。当在生活中,经常会出现,所有糟糕的、不糟糕的事情突然一下子找上你。面对这种情况,我就有一种“心有余而力不足”的感觉。用程序员的方式表示,我就好像是一个单线程处理的程序,没有同时处理多个请求的能力,面对并发的情况,一下子会导致程序崩溃。成年人的崩溃可能只来自瞬间,当很多事情堆在一起,我往往会有一种手足无措的感觉,自己的情绪很容易奔溃。接下来的一年,我希望自己在任何情况下处理事情都游刃有余,掌握好自己情绪,以正确积极的态度面对生活中发生的事情。
2019 年我觉得我做的最好的事情就是做自己觉得应该要做的事情。比如说,我觉得我应该要换一份工作,那么背面试题、面试,这些事情都在为我换工作而努力;比如说,我觉得我应该要换一处住所,那么找房子、搬家,这些事情都在为我换处所而努力;比如说,我觉得我应该要坚持锻炼,那么每个星期一三跑步,这些事情都在为我坚持锻炼而努力;比如说,我觉得我应该会游泳,那么每个星期六早上去练习,这些事情都在为我学习游泳而努力;比如说,我觉得我应该会做饭…….这也是我觉得今年和往年以来最大的区别,相比往年的庸庸碌碌,今年的我好像更加充实了。
回顾我们的工作生活,会发现,只要不是太傻的人,一般经过 3 个月左右的训练,通常能较好的适应自己的工作角色。因此,我们在公司的框架下工作,就会过得比较舒服,但这种状态是比较危险的。因为,一旦哪天突然掉到了地上,比如公司倒闭了,又或者被公司开除了,我们往往会变得无所适从。因此,为了跨出自己呆了近两年你的舒适区,我想在 2019 年我应该做出一些改变。为什么说是近两年的舒适区呢?从我大学毕业到 2019 年年初,刚好近两年的时间。大学刚毕业的时候,由于跨专业找开发的工作,找工作一直不顺利,因此也没有定下到底住哪里。大学室友看到我的情况说,要不你先住我这里,等找到工作后再说?我一口就答应了,根本没有给室友反悔的机会。我是真的太懒,如果有现成的住所,我是不会拒绝的。直到后来,我找到工作也没有再次换住所,而是选择安心住下了。兜兜转转过了近 2 年,舒适的居住环境,熟悉的工作环境,都快让自己忘记了自己已经是一个工作近 2 年的社会人了。莫名的危机感顿时笼罩了自己,从那一刻起,自己就决定要换一个生活环境、换一个工作环境。
拖延症!拖延症!!拖延症!!!我该拿你怎么办呀。2019 年自己还是如愿以偿的没有改掉拖延症,很多事情都一定要到了非做不可的地方才回去执行。我个人感觉我可能不是单纯的拖延症,也有可能只是单纯的懒,不想做事情,然后拿拖延症当借口。我可太难了,明明知道这个习惯不好,可自己就是一直改不掉。2019 年我没有能如愿的改掉拖延症,希望 2020 年的我能够再接再厉,客服困难,抢抓机遇,迎接挑战,坚定信心把工作做好,坚决完成好脱贫攻坚的各项任务……. 坚决完成改掉拖延症的坏习惯。
其实从上面很多没有完成的目标中,可以发现我其实有很多坏习惯:懒、拖延症、固执、依赖他人、脾气不好等等,好像也挺多的。但是这些坏习惯我能改掉的却是少之又少。我觉得养成一个习惯或者戒掉一个习惯都是需要长期坚持的过程,很可惜,2019 年的我并没有坚持好,因此很多坏习惯还在深深影响着我自己。但是我觉得,如果我能意识到这些坏习惯,那么在往后的日子中,我也会慢慢的改正这些坏习惯的,是吧!
这一个目标好像和拖延症那个目标有些冲突了。按道理来说,拖延症的目标我没有完成,这个目标从性质上也应该是不完成状态啊。现在的我也不知道当时的我为什么给这个目标打上了对勾,可能是我坚持阅读了几本技术书籍的原因,可能是我坚持写博客的原因,可能是我每个星期坚持打扫房间的原因,可能是我每个星期坚持跑步的原因,可能是我每个星期六坚持做饭的原因,可能是我每个星期六坚持游泳的原因……. 哦吼,没想到,我的 2019 还听丰富的吗。当然,这里的丰富只是与我的 2018 相比。这么一看,可能当时我就是因为这些原因,才会把这项目标打上对勾的,起码,我对 2019 还是满意的。
2019 年 11 月 17 日,我收到了这样的一条短信 “【微马城市】尊敬的陈星星:祝贺您完成了祥生 ·2019 诸暨西施马拉松。项目: 半程马拉松,参赛号: C3254,成绩 02:00:03,净时成绩: 01:57:52。此成绩仅供参考最终解释权归组委会所有。” 是的,我完成了人生第二次半程马拉松,也是我人生第一次半程马拉松跑进 2 个小时。跑步一直是我最喜欢的一项运动,而在马拉松中取得更好的 PB 更是我追求的目标。从报名到比赛,为了在接下来的半程马拉松中跑进 2 小时,我前前后后准备了 2 个月的时间。从每个星期坚持跑 2 次 5 公里,到每个星期坚持跑 2 次 7 公里,到每个星期坚持跑 2 次 10 公里,到每个星期坚持跑 2 次 14 公里,慢慢提升自己跑量。终于在诸暨西施马拉松我实现了对自己的超越,给自己交了一份满意的答卷。接下来,我的目标是 2020 年完成全程马拉松,为了达成这个目标,我给自己定下了,每个月跑量不少于 100 公里的目标。希望 2020 年的我能够为了全程马拉松,每个月都坚持达成目标。
如果说 2019 年,我做的最自豪的一件事,我想那应该是学会游泳。当初为什么要学习游泳的目的我忘记了,可能是单纯为了打发时间,可能是为了寻找一个娱乐项目…… 但是,当我迈入泳池开始扑腾的时候,我就知道我肯定会喜欢这项运动。为了更快的学会游泳,我从网上找游泳教程,然后每个星期坚持去泳池扑腾,就这样扑腾了进 2 个月时间,我终于学会了蛙泳,可能是我真的没有运动天赋,但是架不住我喜欢游泳啊。而且,这期间发生了非常有意思的事。和我同期的一个同事,听说我在学习游泳,然后就每个星期和我一起学习游泳,他还带上了我们公司另外的一个小伙伴,也就是我们的小师傅。自从有了小师傅,我们学习游泳的进度飞速的提升,可太幸福了。每次,游完泳我们还一起相约吃自助餐。我终于体会到在老公司那种,同事不仅仅是同事,而是一个可以交心的小伙伴的感觉,这种感觉还是非常神奇的,给我普通平凡的工作生活添加了些许色彩。
如果说 2019 年我做的最自豪的一件事是学习游泳,那么,2019 年我最遗憾的一件事就是没有爬华山了。游遍中华五岳是我大学时的一个梦想,但是大学时间游完泰山,这个计划就搁置了,更多的原因还是因为经济问题,大学时的我们经济并不富裕。后来,我们工作后,更多的是时间上的不富裕。当时这个目标定下的时候,是我和另外两个基友,因此,我们完成这个目标也因该是我们三个。但是,因为时间难以协调,这个目标在 2019 年就被耽搁了。很遗憾,因为换工作的原因,在 2019 年不能去看看祖国的大好河山。
上面提到了我换一个工作的强烈的意愿主要是跨出舒适的区域,为了这个目标,背面试题、投简历、面试、被拒,这四件事好像成了 2019 年上半年主旋律。面试的不顺一度让我非常怀疑自己能力,好在公司小伙伴的安慰以及并非裸辞的保障让我并没有那么焦虑。整个换工作的经历磕磕绊绊,因为工作经验的不足,导致面试机会的次数并不多。经历了长达 2 个多月的准备以及面试,在 2019 年 5 月 27 日终于如期的进入了新公司。我也彻底的告别了毕业后的第一家公司,从 2017 年 7 月 19 日至 2019 年 5 月 24 日,674 天,感谢魔蝎让我学会了在社会中跌跌撞撞的成长。
其实学习做菜是我一直以来的目标,在 2018 年坚持过一段时间后就逐渐放弃了,可能是因为自己做的实在太难吃了吧。(┬_┬) 2019 年,在住所和工作都安顿好后,学习做菜的念头有浮现在我的脑海中了。我是这么认为的,出门在外,学会做饭起码饿不死自己。面对自己越来越熟练的厨艺,我也越发的高兴起来,我又学会了一项生存技能。
三十而立,四十不惑,五十知天命,人过半百,在这个年龄,我们的父母,已经很苍老了。记忆中父母的身体总是很硬朗,能够为我们这个小家撑起半边天。从高中开始,我回到浙江读书,算算日子,我离开父母身边已经有近十年的时间了。虽然期间,也会有寒假、暑假的时间回到父母身边,但是生活的时间总是不回太长。当我工作后,父母因为家里的一些事情陆陆续续回来过一段时间,我才发现,父母是真的开始变老了。最明显的是,白头发开始变多了,面容也苍老了许多,这时,作为儿女就有一种无力感。我想,我能做的就是定期带父母去体检,保证他们身体的无恙,为了不在他们身边的自己心安。
与其说是 2019 年度总结,更像是 2019 年度流水账。但是 2019 年对于我来说,还是变化比较大的一年,也是我比较满意的一年,起码日子不会那么碌碌无为了。因为有了这么多需要实现的目标,生活而变得更加充实。
既然有了 2019 年人生小目标,按照历史传统照例当然也存在 2020 年人生小目标,接下来就是明年的 TODO-LIST:
希望 2020 年的我,再接再厉,不要因为经历的太少,鸡毛蒜皮都是烦恼。愿新年,胜旧年。2020,z.z.z — fating!
]]>在许多情况下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,还有一个重要的原因是计算机的运算速度与它的存储和通信子系统速度的差距太大,大量的时间都花费在磁盘 I/O、网络通信和数据库访问上。因此,让计算机同时处理几项任务是充分利用计算机处理器的能力最有效的手段。
服务端是 Java 语言最擅长的领域之一,而线程是 Java 语言中不可或缺的重要功能,它们能使复杂的异步代码变得更加简单,从而极大地简化了复杂系统的开发。此外,要想充分发挥多处理器的强大计算能力,最简单的方式就是使用线程。随着处理器数量的持续增长,如何高效地使用并发正变得越来越重要。
本章作为 Java 并发编程的开篇,首先大致介绍了一下线程以及线程安全的概念,然后详细介绍了一下 Java 内存模型,最后针对常见的 Java 并发编程进行问与答。
当代操作系统,大多数都支持多任务处理。对于多任务的处理,有两个常见的概念:进程和线程。
进程 是程序执行时的一个实例,是操作系统进行资源分配和调度的一个独立单位,这里的资源包括 CPU、内存、IO、磁盘、文件句柄以及安全证书等,进程之间切换时,操作系统需要分配和回收这些资源,所以其开销相对较大(远大于线程切换)。
线程 也被称为轻量级进程。在大多数现代操作系统中,都是以线程为基本的调度单位。线程是进程的一个实体,是 CPU 调度和分派的基本单位,是比进程更小的能独立运行的基本单位。多个线程在切换时,CPU 会根据其优先级和相互关系分配时间片。除时间切换之外,线程切换时一般没有其它资源(或只有很少的内存资源)需要切换,所以切换速度远远高于进程切换。
进程是操作系统分配资源的最小单元,线程是操作系统调度的最小单元。一个程序至少有一个进程,一个进程至少有一个线程。进程在执行时通常拥有独立的内存单元,而线程之间可以共享内存。使用多线程的编程通常能够带来更好的性能和用户体验,但是多线程的程序对于其他程序是不友好的,因为它可能占用了更多的 CPU 资源。当然,也不是线程越多,程序的性能就越好,因为线程之间的调度和切换也会浪费 CPU 时间。
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件 I/O 等),又可以独立调度(线程是 CPU 调度的基本单位)。
实现线程主要有 3 种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度。
协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了后,要主动通知系统切换到另外一个线程上。
抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。
Java 语言定义了 5 种线程状态,在任何一个时间点,一个线程只能有且只有其中的一种状态,这 5 种状态分别如下:
新建(New):创建后尚未启动的线程处于这种状态。
运行(Runnable):Runnable 包括了操作系统线程状态中的 Running 和 Ready,也就是处于此状态的线程有可能正在执行,也有可能在等待着 CPU 为它分配执行时间。
无限期等待(Waiting):处于这种状态的线程不会被分配 CPU 执行时间,它们要等待被去其他线程显式地唤醒。以下方法会让线程陷入无限期的等待状态:
1、没有设置 Timeout 参数的 Object.wait() 方法。
2、没有设置 Timeout 参数的 Thread.join() 方法。
3、LockSupport.park() 方法。
限期等待(Timed Waiting):处于这种状态的线程也不会被分配 CPU 执行时间,不过无须等待被其它线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
1、Thread.sleep() 方法。
2、设置了 Timeout 参数的 Object.wait() 方法。
3、设置了 Timeout 参数的 Thread.join() 方法。
4、LockSupport.parkNanos() 方法。
5、LockSupport.parkUntil() 方法。
阻塞(Blocked):线程被阻塞了,“阻塞状态”与 “等待状态” 的区别是:“阻塞状态”在等待着获取一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而 “等待状态” 则是等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,程序将进入这种状态。
结束(Terminated):已终止线程的状态,线程已经结束执行。
由于同一个进程中的所有线程都将共享进程的内存地址空间,因此这些线程都能访问相同的变量并在同一堆上分配对象,这就需要实现一种比在进程间共享数据粒度更细的数据共享机制。如果没有明确的同步机制来协同对共享数据的访问,那么当一个线程正在使用某个变量时,另一个线程可能同时访问这个变量,这将造成不可预测的结果。
《Java Concurrency In Practice》的作者 Brian Goetz 对 “线程安全” 有一个比较恰当的定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的”。
多个线程访问了相同的资源,向这些资源做了写操作,对执行顺序有要求:
1 | public class Demo { |
临界区:incr 方法内部就是临界区域,关键部分代码的多线程并发执行,会对执行结果产生影响。即多线程情况下,会发生线程安全问题的区域。
竞态条件:可能发生在临界区域内的特殊条件。多线程执行 incr 方法中的 i++ 关键代码时,产生了竞态条件。即临界区内,引发线程安全问题的代码。
按照线程安全的 “安全程度” 由强至弱来排序,我们可以将 Java 语言中各种操作共享的数据分为以下 5 类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
在 Java 语言中,不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施,只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程之中处于不一致的状态。
Java 语言中,如果共享数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任务影响才行。
在 Java API 中符合不可变要求的类型,包括:String、枚举类、java.lang.Number 的部分子类(如 Long、Double 等数值包装类型)。
绝对线程安全是指:不管运行环境如何,调用者都不需要任何额外的同步措施。在 Java API 中标注自己是线程安全的类,大多数都不是绝对线程安全。
例如,Vector 的 get()、remove()、和 size() 方法都是同步的,但是在多线程的环境中,如果不在方法调用端做额外的同步措施的话,一个线程循环读取元素,一个线程循环删除元素,这种场景仍然是不安全的,因为如果另一个线程恰好在错误的时间里删除了一个元素,导致序号 i 已经不再可用的话,再用 i 访问数据就会抛出一个 ArrayIndexOutOfBoundsException 。
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、Hashtable、Collections 的 synchronizedCollection() 方法包装的集合等。
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。
Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 Hashtable 相对应的集合类 ArrayList 和 HashMap 等。
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特征,线程对立这种排斥多线程的代码很少出现,而且通常都是有害的,应当尽量避免。
一个线程对立的例子是 Thread 类的 suspend() 和 resume(),如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的。常见的线程对立的操作还有 System.setIn()、System.setOut() 和 System.runFinalizersOnExit() 等。
互斥同步(Mutual Exclusion & Synchronization)是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号新的时候)线程使用。而互斥实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。因此,互斥同步这 4 个字里面,互斥是方法,同步是目的。
在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,synchronized 关键字经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两个字节码都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 程序中的 synchronized 明确指定了对象的参数,那就是这个对象的 reference;如果没有明确指定,那就根据 synchronized 修饰的是实例方法还是类方法,去取对应的对象实例或 Class 对象来作为锁对象。
根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit 指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,知道对象锁被另外一个线程释放为止。
java.util.concurrent(J.U.C)包中的重入锁(ReentrantLock)在基本用法上与 synchronized 很相似,他们都具备一样的线程重入特征,只是代码写法上有点区别,一个表现为 API 层面的互斥 lock() 和 unlock() 方法配合 try/finally 语句块来完成),另一个表现为原声语句层面的互斥锁。与 synchronized 相比 ReentrantLock 增加了一些高级功能:等待可中断、可实现公平锁、以及锁可以绑定多个条件:
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都需要进行加锁、用户态核心态转换 [1]、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
随着硬件指令集的发展,基于冲突检测的乐观并发策略为我们提供了另外一个选择。通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断重试,直到成功为止),这种乐观的并发策略的许多时间都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。
“硬件指令的发展” 使操作和冲突检测这两个步骤具备原子性,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
CAS 指令需要有 3 个操作数,分别是内存位置(在 Java 中可以简单理解为变量的内存地址,用 V 表示)、旧的预期值(用 A 表示)和新值(用 B 表示)。CAS 指令执行时,当且仅当 V 符合旧预期值 A 时,处理器用新值 B 更新 V 的值,否则它就不执行更新,但是无论是否更新了 V 的值,都会返回 V 的旧值,上述的处理过程是一个原子操作。
在 JDK 1.5 之后,Java 程序中才可以使用 CAS 操作,该操作由 sun.misc.Unsafe 类里面的 conpareAndSwapInt() 和 conpareAndSwapLong() 等几个方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器 CAS 指令,没有方法调用的过程,或者可以认为是无条件内联进去了。
由于 Unsafe 类不是提供给用户程序调用的类(Unsafe.getUnsafe() 的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的 Class 才能访问它),因此,如果不采用反射手段,我们只能通过其他的 Java API 来间接使用它,如 J.U.C 包里面的整数原子类,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。
尽管 CAS 看起来很美,但显然这种操作无法涵盖互斥同步的所有使用场景,并且 CAS 从语义上来说并不是完美的,存在这样的一个逻辑漏洞:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然为,但是这段期间它的值曾经被改成了 B,后来又被改回为 A,那么 CAS 操作就会误认为它从来没有被改变过。这个漏洞称为 CAS 操作的 “ABA” 问题。J.U.C 包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证 CAS 的正确性。不过目前来说这个类比较鸡肋,大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步的可能会比原子类更高效。
要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
可重入代码(Reentrant Code):可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。相对线程安全来说,可重入性是更基本的特征,它可以保证线程安全,即所有的可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具备可重入行:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
Java 语言中,如果一个变量要被多线程访问,可以使用 volatile 关键字声明它为 “易变的”;如果一个变量要被某个线程独享,可以通过 java.lang.ThreadLocal 类来实现线程本地存储的功能。每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K-V 值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程 K-V 值对中找回对应的本地线程变量。
“让计算机并发执行若干个运算任务” 与 “更充分地利用计算机处理的效能” 之间关系的复杂性来源是绝大数的运算任务都不可能只靠处理器 “计算” 就能完成,处理器至少要于内存交互,如读取运算数据、存储运算结果等。由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现在计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲;将运算需要使用到的数据复制到缓冲中,让运算能够快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。
除了增加高速缓存之外,为了使得处理器内部的单元能够尽量充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的(As-If-Serial 语义 [2]),但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java 虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化 [3]。
由于主流程序语言(如 C/C++ 等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此某些场景就必须针对不同的平台来编写程序。
Java 虚拟机规范中定义了一种 Java 内存模型(Java Memory Model,JMM),JMM 是一种基于计算机内存模型(定义了共享内存系统中多线程程序读写操作行为的规范),屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。保证共享内存的原子性、可见性、有序性。
Java 内存模型的主要目标是定义程序中中各种变量(实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数)的访问规则,即在虚拟机中将变量存储到内存和从内存中读取变量这样的底层细节。
Java 内存模型规定了所有的变量都存储于主内存(Main Memory),每条线程还有自己的工作内存(Working Memory),线程的工作内存保留了被线程使用的变量的主内存副本拷贝。线程对变量的所有的操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
注意:这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两则基本是没有关系的。
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了一下 8 中操作来完成:
lock(锁定):作用于主内存的变量,它把一个变量表示为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个变量的值的字节码指令将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码执行时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中的变量的值传送到主内存中,以便随后的 write 操作使用。
write(写入):作用于主内存的变量,它把 store 操作从工作内存得到的变量的值放入主内存的变量中。
Java 内存模型还规定了执行上述 8 种基本操作时必须满足如下规则:
不允许 read 和 load、store 和 write 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
不允许一个线程丢弃它的最近的 assgin 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或者 assign)的变量,换句话说,就是对一个变量实施 use、store 操作之前,必须先执行过了 assign 和 load 操作。
一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其它线程锁定住的变量。
对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)
以上 8 种内存访问操作以及上述规则限定,再加上 volatile 的一些特殊规定,就已经完全确定了 Java 程序中哪些内存访问操作在并发下是安全的。
关键字 volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,当一个变量被定义成 volatile 之后,他将具备两种特性:
保证此变量对所有线程的可见性:即当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量是做不到这点,普通变量的值在线程在线程间传递均需要通过主内存来完成,例如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一个线程 B 在线程 A 回写完成了之后再从主内存进行读取操作,新变量值才会对线程 B 可见。另外,Java 里面的运算并非原子操作,会导致 volatile 变量的运算在并发下一样是不安全的。
禁止指令重排序优化:普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量赋值操作的顺序与程序中的执行顺序一致。
由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用 synchronize 或 java.util.concurrent 中的原子类)来保证原子性:
Volatile 型变量缺点:由于 Volatile 的 MESI 缓存一致性协议,需要不断的从主内存嗅探 [4]和 CAS 不断循环,无效交互会导致总线带宽达到峰值。
Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这 8 个操作都具有原子性,但是对于 64 位的数据类型(long 和 double),在模型中特别定义了一条相对宽松的规则:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性,这点就是所谓的 long 和 double 的非原子性协定。
Java 内存模型虽然允许虚拟机不把 long 和 double 变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还 “强烈建议” 虚拟机这样实现。
Java 内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个看下哪些操作实现了这三个特性:
原子性(Atomicity):由 Java 内存模型来直接保证的原子性变量包括 read、load、assign、use、store 和 write,我们大致可以认为基本数据类型的访问读写是具备原子性的。如果应用场景需要一个更大方位的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式的使用这两个操作,这两个字节码指令反应到 Java 代码中就是同步块——synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。
可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,普通变量与 volatile 变量的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。除了 volatile 之外,Java 还有两个关键字能实现可见性,即 synchronized 和 final。同步快的可见性是由 “对一个变量执行 unlock 操作前,必须先把此变量同步回主内存” 这条规则获得的;而 final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把 “this” 的引用传递出去,那么在其他线程中就能看见 final 字段的值。
有序性(Ordering):如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的线程操作都是无序的。前半句是指 “线程内表现为串行的语义”,后半句是指“指令重排序” 现象和 “工作内存与主内存同步延迟” 现象。Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由 “一个变量在同一个时刻只允许一条线程对其进行 lock 操作” 这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行的进入。
如果 Java 内存模型中所有的有序性都仅仅靠 volatile 和 synchronize 来完成,那么有一些操作将会变得很烦琐,但是我们在编写 Java 并发代码的时候并没有感觉到这一点,这是因为 Java 语言中有一个 “先行发生”(happens-before)的原则。
为了方便程序员开发,将底层的烦琐细节屏蔽掉,JMM 定义了 Happens-Before 原则。只要我们理解了 Happens-Before 原则,无需了解 JVM 底层的内存操作,就可以解决在并发编程中遇到的变量可见性问题。JVM 定义的 Happens-Before 原则是一组偏序关系:如果说操作 A 先行发生于操作 B,其实就是说发生在操作 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响” 包括修改了内存中共享变量的值、发送了消息、调用了方法等。
1 | // 线程 A 中执行 |
例如:如果说线程 A 是先行发生于线程 B 的,那么可以确定在线程 B 执行之后 j=1,因为根据先行发生原则,A 操作 i = 1 的结果可以被 B 观察到,并且线程 C 还没有执行;那么如果线程 C 是在 A 与 B 之间,j 的值是多少呢?答案是不确定,1 和 2 都有可能,因为线程 C 对变量 i 的影响可能会被线程 B 观察到,也可能不会,这时候线程 B 就存在读取到过期数据的风险,不具备多线程安全性。
先行发生原则是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作之间是否可能存在冲突的所有问题。
程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。这是因为 Java 语言规范要求 JVM 在单个线程内部要维护类似严格串行的语义,如果多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。
管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而 “后面” 是指时间上的先后顺序。
volatile 变量规则(Volatile Variable Rule):对于一个 volatile 变量的写操作先行发生于后面对于这个变量的读操作,这里的 “后面” 同样是指时间上的先后顺序。
线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。
线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行。
线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事情的发生,可以用过 Thread.interrupred() 方法检测到是否有中断发生。
对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。
传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
总结:时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。如果两个操作之间的关系不再以上规则中,并且无法通过以上规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
[1]. 面试官想到,一个Volatile,敖丙都能吹半小时
[2]. Java系列笔记(5) - 线程
[3]. 《深入理解Java虚拟机:JVM高级特性与最佳实践》,第五部分 高效并发
[1]. 用户态核心态转换:Java 的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块(如被 synchronized 修饰的 getter() 或 setter() 方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。
[2]. As-If-Serial 语义:不管怎么进行指令重排序,单线程内程序的执行结果不能被改变。编译器,处理器进行指令重排序都必须要遵守 as-if-serial 语义规则。为了遵守 as-if-serial 语义,编译器和处理器对存在依赖关系的操作,都不会对其进行重排序,因为这样的重排序很可能会改变执行的结果,但是对不存在依赖关系的操作,就有可能进行重排序。
[3]. 指令重排序优化:为了提高程序的执行效率,编译器在生成指令序列时,有可能对指令进行重排序。一般指令重排序可以分为如下三种:编译器重排序、指令级并行重排序、处理器重排序。Java 语言规范要求 JVM 只在单个线程内部维护一种类似串行的语义,即只要程序的最终结果与严格串行环境中执行的结果相同即可。
[4]. 嗅探:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
处理过线上问题的同学基本上都会遇到系统突然运行缓慢,CPU 100%,以及 Full GC 次数过多的问题。当然,这些问题的最终导致的直观现象就是系统运行缓慢,并且有大量的报警。给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用处理数据的手段。这里说的数据包括:运行日志、异常堆栈、GC 日志、线程快照(threaddump/javacore 文件)、堆转侟快照(heapdump/hprof 文件)等。经常使用适当的虚拟机监控和分析的工具可以加快我们分析数据、定位解决问题的速度。
对于线上系统突然产生的运行缓慢问题,如果该问题导致线上系统不可用,那么首先需要做的就是,导出 jstack 和内存信息,然后重启系统,尽快保证系统的可用性。本文主要针对 JDK 的命令行工具,主要包括用于监视虚拟机和故障处理的工具。根据 JDK 的命令行工具提供解决问题的排查思路,从而定位出问题的代码点,进而提供解决该问题的思路。
名称 | 主要作用 |
---|---|
jps | JVM Process Status Tool,显示指定系统内所有 HotSpot 虚拟机进程 |
jstat | JVM Statistics Monitoring Tool,用于收集 HotSpot 虚拟机各方面的运行数据 |
jinfo | Configuration Info For Java,显示虚拟机配置信息 |
jmap | Memory Map for Java,生成虚拟机的内存转储快照(heapdump 文件) |
jhat | JVM Heap Dump Browser,用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用于可以在游览器上查看分析结果 |
jstack | Stack Trace for Java,显示虚拟机的线程快照 |
这些命令行工具大多数是 jdk/lib/tools.jar 类库的一层薄包装而已,它们主要的功能代码是在 tools 类库中实现的。接来下介绍的 JDK 命令行工具大多都是基于 JDK 1.6,因此会存在个别参数在新版本 JDK 被淘汰的情况出现。所有的 JDK 工具都可以在 Oracle 官网的 Java Tools Reference 文档中找到使用说明,这是主要参考,包括命令格式、参数内容、输出信息等等。
jps 可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main() 函数所在的类)名称以及这些进程的本地虚拟机唯一 ID(Local Virtual Machine Identifier,LVMID)。
查看 jps 的帮助信息:
1 | [root@VM_24_98_centos jvm]# jps -help |
jps 命令格式:
1 | λ jps -lv |
选项 | 作用 |
---|---|
-q | 只输出 LVMID,省略主类的名称 |
-m | 输出虚拟机进程启动时传递给主类 main() 函数的参数 |
-l | 输出主类的全名,如果进程执行的是 Jar 包,输出 Jar 路径 |
-v | 输出虚拟机进程启动时 JVM 参数 |
jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具,它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据。
查看 jstat 的帮助信息:
1 | [root@VM_24_98_centos jvm]# jstat -help |
jstat 命令格式:
1 | // 每 250 毫秒查询一次进程 2764 垃圾收集状态,一共查询 20 次 |
选项 | 作用 |
---|---|
-class | 监视类装载、卸载数量、总空间以及类装载所耗费的时间 |
-gc | 监视 Java 堆状况,包括 Eden 区、两个 survivor 区、老年代、永久代等的容量、已用空间、GC 空间合计等信息 |
-gccapacity | 监视内容与 -gc 基本相同,但输出主要关注 Java 堆各个区域使用到的最大、最小空间 |
-gcutil | 监视内容与 -gc 基本相同,但输出主要关注已使用空间占总空间的百分比 |
-gccause | 与 -gcutil 功能一样,但是会额外输出导致上一次 GC 产生的原因 |
-gcnew | 监视新生代 GC 状况 |
-gcnewcapacity | 监视内容与 -gcnew 基本相同,但输出主要关注使用到的最大、最小空间 |
-gcold | 监视老年代 GC 状况 |
-gcoldcapacity | 监视内容与 -gcold 基本相同,但输出主要关注使用到的最大、最小空间 |
-gcpermcapacity | 输出永久代使用到的最大、最小空间(JDK1.8 已废弃) |
-gcmetacapacity | 输出元数据空间使用到的最大、最小空间(JDK1.8 已废弃) |
-compiler | 输出 JIT 编译器编译过的方法、耗时等信息 |
-printcompilation | 输出已经被 JIT 编译的方法 |
特别说明:jstat 监视选项众多,由于版本原因无法逐一演示,感兴趣的朋友可以参考博客 《jvm 性能调优工具之 jstat》
jinfo(Configuration Info for Java)的作用是实时地查看和调整虚拟机各项参数。使用 jps 命令的 -v 参数可以查看虚拟机启动时显示指定的参数列表,但如果想知道未被显示指定的参数的系统默认值,就只能使用 jinfo 的 -flag 选项进行查询了。
查看 jinfo 的帮助信息:
1 | [root@VM_24_98_centos jvm]# jinfo -help |
jinfo 命令格式:
1 | // jinfo 查询进程 2764 虚拟机各项参数 |
jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为 heapdump 或者 dump 文件)。如果不使用 jmap 命令,可以使用 -XX:+HeapDumpOnOutOfMemoryError 参数,让虚拟机在 OOM 异常出现之后自动生成 dump 文件。
jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalize 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等。
查看 jmap 的帮助信息:
1 | [root@VM_24_98_centos ~]# jmap -help |
jmap 命令格式:
1 | // jinfo 查询进程 2764 Java 堆详细信息 |
选项 | 作用 |
---|---|
-dump | 生成 Java 堆转储快照。格式为:-dump:[live,]format=b,file=<filename>,其中 live 子参数说明是否只 dump 出存活的对象 |
-finalizerinfo | 显示在 F-Queue 中等待 Finalizer 线程执行 finalize 方法的对象。只有在 Linux/Solaris 平台下有效 |
-heap | 显示 Java 堆详细信息,如使用哪种回收器、参数配置、分代状况等。只有在 Linux/Solaris 平台下有效 |
-histo | 显示堆中对象统计信息,包括类、实例数量、合计容量 |
-histo:live | 与 -histo:live 功能一样,在统计之前 JVM 会先触发一次 FULL GC,线上慎用 |
-permstat | 以 ClassLoader 为统计口径显示永久代内存状态。只有在 Linux/Solaris 平台下有效 |
-F | 当虚拟机进程对 -dump 选项没有响应时,可使用这个选项强制生成 dump 快照。只有在 Linux/Solaris 平台下有效 |
Sun JDK 提供 jhat(JVM Heap Analysis Tool)命令与 jmap 搭配使用,来分析 jmap 生成的堆转快照。jhat 内置了一个微型的 HTTP/HTML 服务器,生成 dump 文件的分析结果后,可以在游览器中查看。
jhat 的分析功能相对来说比较简陋,在现实工作中,我们一般会使用 VisualVM、Eclipse Memory Analyzer、IBM HeapAnalyzer 等专业用于分析 dump 文件的工具。
查看 jhat 的帮助信息:
1 | [root@VM_24_98_centos ~]# jhat -help |
jmap 命令格式:
1 | // jhat 分析 file.dump,屏幕显示“Server is ready”的提示后,用户在游览器输入 http://localhost:7000/ 就可以看到分析结果 |
jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为 threaddump 或者 javacore 文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待都是导致线程长时间停顿的常见原因。
查看 jstack 的帮助信息:
1 | [root@VM_24_98_centos ~]# jstack -help |
jstack 命令格式:
1 | // jstack 查看进程 2764 线程快照 |
选项 | 作用 |
---|---|
-F | 当正常输出的请求不被响应时,强制输出线程堆栈 |
-l | 除堆栈外,显示关于锁的附加信息 |
-m | 如果调用本地方法的话,可以显示 C/C++ 的堆栈 |
JDK 中除了提供大量的命令行工具外,还有两个功能强大的可视化工具:JConsole 和 VisualVM。
JConsole(Java Monitoring and Management Console)是一种基于 JMX 的可视化监视、管理工具,它管理部分的功能是针对 JMX MBean 进行管理。
特殊说明:要对 Java 进程进行远程监控,在启动它的时候需要启用 JMX,对于 Java 进程开启远程调试可以参考博客《Java - jmx远程调优》
VisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随 JDK 发布的功能最强大的运行监视和故障处理程序。VisualVM 除了运行监视、故障处理外,还提供了很多其他方面你的功能,如性能分析(Profiling)。VisualVM 的性能分析功能甚至比起 Jprofiler、YourKit 等专业且收费的 Profiling 工具都不会逊色多少,而且 VisualVM 不需要被监视的程序基于特殊 Agent 运行,因此它对对应用程序的实际性能的影响很小,使得它可以直接应用在生产环境中。
模拟环境:Centos7 1 核 2GB,Java 8
笔者之前在一家互联网公司从事爬虫业务,在解析 HTML 时,经常由于网站返回的 HTML 网页结构不完整而导致解析框架死循环,从而导致 CPU 飚高,系统运行缓慢。
场景模拟:在线上的环境中,一般 CPU 飙高极大的可能性是出现了死循环了。因此我们通过模拟死循环的方式模拟 CPU 飚高的情况,然后通过 jstack 找出最耗 CPU 的线程并定位代码。启动程序后,发现该程序 CPU 直线飙高,直接到达 100% 根本没有要下降的趋势,并且系统平均负载也直线飙高至 3.79,导致系统缓慢。
1 | public class ExceptionHandler { |
(1) jstack 找出最耗 CPU 的线程并定位代码
① 通过 top 命令找到占用 CPU 最高的 pid[进程 ID],定位到 pid 是 12870
② 通过 top -Hp pid 查看该进程中占用 CPU 过高的 tid[线程 id],定位到 tid 分别为 12894、12895、12896
③ 通过 printf “0x%x\n” tid 把线程 id 转化为十六进制,转换后的十六进制 tid 分别为 0x325e、0x325f、0x3260
④ 通过 jstack pid |grep tid -A 30 定位线程堆栈信息,这里的 tid 指的是转换后的十六进制 tid,定位到导致 CPU 飚高的代码为 ExceptionHandler 类 27 行处,发现里面有一个死循环。
(2) 通过在线可视化分析工具分析 threaddump
fastthread.io 是一个在线线程日志分析网站,科学上网打开速度会更快。定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待都是导致线程长时间停顿的常见原因。比较好的是它会提供一些优化的建议,可以作为参考,而且各个部分的分析也比较详细。
① 通过 top 命令找到占用 CPU 最高的 pid[进程 ID],定位到 pid 是 12870
② 通过 jstack -l pid > file-path 抓取 thread dump 文件,即 jstack -l 12870 > threaddump-1576050339106.tdump
③ 将 thread dump 文件上传至 fastthread 网站,查看分析结果
Deadlock:死锁线程,一般指多个线程调用间,进入相互资源占用,导致一直等待无法释放的情况。由于锁使用不当,导致多个线程进入死锁状态,从而导致系统整体比较缓慢。
1 | public class ExceptionHandler { |
(1) jstack 分析线程运行状况,找出死锁线程并定位代码
① 通过 top 命令找到占用 需要分析的 pid[进程 ID],定位到 pid 是 7120
② 通过 jstack pid 定位线程堆栈信息,通过分析线程快照,可以很轻松的发现死锁状况
(2) 通过在线可视化分析工具分析 threaddump
① 通过 top 命令找到占用 CPU 最高的 pid[进程 ID],定位到 pid 是 7120
② 通过 jstack -l pid > file-path 抓取 thread dump 文件,即 jstack -l 7120 > threaddump-1576050339106.tdump
③ 将 thread dump 文件上传至 fastthread 网站,查看分析结果
Full GC 次数过多,这种情况是最容易出现的,尤其是新功能上线时。对于 Full GC 较多的情况,其主要有如下两个特征:1、线上多个线程的 CPU 都超过了 100%,通过 jstack 命令可以看到这些线程主要是垃圾回收线程;2、通过 jstat 命令监控 GC 情况,可以看到 Full GC 次数非常多,并且次数在不断增加。
(1) jstack 分析线程运行状况,找出 Full GC 次数过多原因并定位代码
① 首先我们通过 top 命令查看当前 CPU 消耗过高的进程是哪个,从而得到进程 id;然后通过 top -Hp <pid> 来查看该进程中有哪些线程 CPU 过高,一般超过 80% 就是比较高的,80% 左右是合理情况。这样我们就能得到 CPU 消耗比较高的线程 id。接着通过该线程 id 的十六进制表示在 jstack 日志中查看当前线程具体的堆栈信息
② 通过 jstack pid |grep tid -A 30 定位线程堆栈信息,这里的 tid 指的是转换后的十六进制 tid,定位到导致 CPU 飚高的线程为 “VM Thread”,而 VM Thread 指的就是垃圾回收的线程。这里我们基本上可以确定,当前系统缓慢的原因主要是垃圾回收过于频繁,导致 GC 停顿时间较长
③ 通过 jstat -gcutil pid 1000 10 查看 GC 的情况,可以看到,这里 FGC 指的是 Full GC 数量,若 FGC 过高,可能是由于内存溢出导致的系统缓慢,也可能是代码或者第三方依赖的包中有显示的 System.gc() 调用
④ 通过分析 dump 文件,确定由于内存溢出导致 Full GC 次数过多还是由于代码或者第三方依赖的包中有显示的 System.gc() 调用导致 Full GC 次数过多
(2) 通过在线可视化分析工具分析 gc.log
gceasy.io 是一个在线 GC 日志分析工具,科学上网打开速度会更快。gceasy 可帮助您可帮助您分析程序运行时 GC 情况,您可以根据分析结果及时优化程序。
① 通过 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log 命令获取 gc 日志
② 将 gc.log 文件上传至 gceasy 网站,查看分析结果
对于 Full GC 次数过多,主要有以下两种原因:
同样是笔者之前所在的爬虫业务,有个网站改版后,登录时采用 RSA 加密方式,笔者根据网站的加密方式改版后项目重新上线后,发现项目内存使用率每天都会增加一点,对于一个长期稳定运行 Java 项目而言,出现这种情况一般都有可能是出现了内存泄露。
场景模拟:在线上的环境中,一个长期稳定运行的项目,内存使用率每天都增加,一般情况就是出现了内存泄露。而笔者所说的场景就是,网站采用 RSA 对网站的登录账号、密码进行加密,Java 默认的 RSA 实现是 “RSA/None/PKCS1Padding”(即 Cipher cipher = Cipher.getInstance(“RSA”););而该网站采用的 RSA 实现是 “RSA/None/NoPadding”(即 Cipher cipher = Cipher.getInstance(“RSA”, new org.bouncycastle.jce.provider.BouncyCastleProvider());)。项目长时间运行,就会出现 JceSecurity 占用的内存越来越多,而且不会释放。
1 | public class ExceptionHandler { |
(1) MAT 找出内存泄漏并定位代码
Eclipse Memory Analyzer Tool(MAT)是一个强大的基于 Eclipse 的内存分析工具,可以帮助我们找到内存泄露,减少内存消耗。MAT 是有两种安装方式的:一种安装方式是将 MAT 当做 eclipse 的插件进行安装:启动 Eclipse –> Help –> Eclipse Marketplace,然后搜索 Memory Analyzer,安装,重启 eclipse 即可;另外一种安装方式是将 MAT 作为一个独立的软件进行安装:去 官网,根据操作系统版本下载最新的 MAT。下载后解压就可以运行了。
① 通过 top 命令找到占用内存使用率持续增加的 pid[进程 ID],定位到 pid 是 12870
② 通过 jmap -dump:[live,]format=b,file=<filename> <pid> 获取堆转储文件,即 jmap -dump:live,format=b,file=heapdump-1576054225319.hprof 12870
③ 将 heap dump 文件导入 MAT,查看分析结果
④ 加载后首页如下图,在首页上比较有用的是 Histogram 和 Leak Suspects。由下图看出这个类 javax.crypto.JceSecurity 占用 896.3 MB,表示这其中很多不能够被回对象的对象
⑤ 根据 Leak Suspects 快速查看泄露的可疑点,在 Leak Suspects 页面会给出可能的内存泄露,点击 Details 进入详情页面。在详情页面 Shortest Paths To the Accumulation Point 表示 GC root 到内存消耗聚集点的最短路径,如果某个内存消耗聚集点有路径到达 GC root,则该内存消耗聚集点不会被当做垃圾被回收。由下图可以看到大量的 BouncyCastleProvider 对象没有被垃圾回收器回收,占用了大量内存空间。
(3) 通过在线可视化分析工具分析 heaphero
heaphero.io 是一个在线内存分析工具,科学上网打开速度会更快。heaphero 可帮助您查找内存泄漏并减少内存消耗,运行报告以自动提取泄漏嫌疑者,主要展示项有:堆统计,大对象,字符串重复,低效率对象,线程数,及优化建议等。
① 通过 top 命令找到占用内存使用率持续增加的 pid[进程 ID],定位到 pid 是 12870
② 通过 jmap -dump:[live,]format=b,file=<filename> <pid> 获取堆转储文件,即 jmap -dump:live,format=b,file=heapdump-1576054225319.hprof 12870
③ 将 heap dump 文件上传至 heaphero 网站,查看分析结果
(1)修改 provider 指定方式:Security.addProvider(new BouncyCastleProvider())
1 | public class ExceptionHandler { |
(2)把 BouncyCastleProvider 改成单例模式
1 | public class ExceptionHandler { |
[1]. jvm 性能调优工具之 jstat
[2]. JDK的可视化工具系列 (四) JConsole、VisualVM
[3]. 使用Eclipse Memory Analyzer Tool(MAT)分析线上故障(一) - 视图&功能篇
[4]. 一道必备面试题:系统CPU飙高和GC频繁,如何排查?
在本系列的前两篇博文中,笔者对 Git 以及 Git flow 进行了大致的介绍,相信各位读者已经对 Git 有了大致的了解。但是,在我们的日常工作中使用 Git 时常会遇到的各种突发状况,那么我们应该怎么合理的应对这些状况呢?俗话说,无规矩不成方圆,在团队协作中,如何规范 Git Commit 呢?本文将针对以上问题展开讨论,探讨一下在日常工作中,我们应该如何优雅的使用 Git?
无规矩不成方圆,编程也一样。如果在团队协作中,大家都张扬个性,那么代码将会是一团糟,好好的项目就被糟践了。不管是开发还是日后维护,都将是灾难。对于 Git Commit 同样如此,统一 Git Commit 规范可以方便管理团队代码,方便后续进行 code review 以及生成 change log;统一 Git Commit 规范容易理解提交的信息。
根据 Git flow 工作流分支模型将我们开发分支规范为五大分支:
采用三段式: v版本. 里程碑. 序号,例如 v1.2.1
目前一般采用 Angular 的提交信息规范:信息分为 Header、Body、Footer 三部分
例子:
1 | refactor: Restructure SQLRecognizer and UndoExecutor ( |
Header 信息分为三部分 type(scope): subject
type(必须),用于说明 Git 提交信息的类别,有以下几个分类
Type | 说明 |
---|---|
feat | 新增功能 |
fix | 修复 bug |
docs | 修改文档 |
refactor | 重构代码,未新增任何功能或修复任何 bug |
build | 改变构建流程、新增依赖库 |
style | 仅对样式做出修改(如空格和代码缩进等,不对逻辑进行修改) |
perf | 改善性能的修改 |
chore | 非 src 或 test 下代码的修改 |
test | 测试用例的修改 |
ci | 自动化流程配置修改 |
revert | 回滚到上一个版本 |
scope(可选),用于说明 commit 的影响范围,比如数据层、控制层、视图层等等,视项目不同而不同。
subject(必须),commit 的信息主题,尽量言简意赅,说明提交代码的主要变化。
对本次提交的详细描述。
【1】场景重现 one:当正在 feature 分支上开发某个新功能,这时项目中出现一个 bug,需要紧急修复,但是正在开发的内容只是完成一半,还不想提交,这时可以用 git stash 命令将修改的内容保存至堆栈区,然后顺利切换到 hotfix 分支进行 bug 修复,修复完成后,再次切回到 feature 分支,从堆栈中恢复刚刚保存的内容。
【2】场景重现 two:由于疏忽,本应该在 feature 分支开发的内容,却在 develop 上进行了开发,需要重新切回到 feature 分支上进行开发,可以用 git stash 将内容保存至堆栈中,切回到 feature 分支后,再次恢复内容即可。
1 | # 1. 创建 feature 分支 |
git stash 常用命令指南
1 | # 保存,save为可选项,message为本次保存的注释 |
不知怎么,git rebase 命令被赋予了一个神奇的污毒声誉,初学者应该远离它,但它实际上可以让开发团队在使用时更加轻松。
Rebase 的黄金法则:git rebase 的黄金法则是永远不要在公共分支上使用它。
【1】场景重现 one:当你在功能分支上开发新 feature 时,然后另一个团队成员在 master 分支提交了新的 commits,这会发生什么?这会导致分叉的历史记录,对于这个问题,使用 Git 作为协作工具的任何人来说都应该很熟悉。现在,假设在 master 分支上的新提交与你正在开发的 feature 相关。需要将新提交合并到你的 feature 分支中,你可以有两个选择:merge 或者 rebase。
Merge 方式:最简单的方式是通过 git merge 命令将 master 分支合并到 feature 分支中
1 | $ git checkout feature |
这会在 feature 分支中创建一个新的 merge commit,它将两个分支的历史联系在一起。使用 merge 是很好的方式,因为它是一种 非破坏性的 操作,现有分支不会以任何方式被更改;另一方面,这也意味着 feature 分支每次需要合并上游更改时,它都将产生一个额外的合并提交。如果master 提交非常活跃,这可能会严重污染你的 feature 分支历史记录。尽管可以使用高级选项 git log 缓解此问题,但它可能使其他开发人员难以理解项目的历史记录。
Rebase 方式:作为 merge 的替代方法,你可以使用以下命令将 master 分支合并到 feature分支上
1 | $ git checkout feature |
这会将整个 feature 分支移动到 master 分支的顶端,从而有效地整合了所有 master 分支上的提交。但是,与 merge 提交方式不同,rebase 通过为原始分支中的每个提交创建全新的 commits 来 重写项目历史记录。
rebase 的主要好处是可以获得更清晰的项目历史。首先,它消除了 git merge 所需的不必要的合并提交;其次,正如你在上图中所看到的,rebase 会产生完美线性的项目历史记录,你可以在 feature 分支上没有任何分叉的情况下一直追寻到项目的初始提交。这样可以通过命令 git log,git bisect 和 gitk 更容易导航查看项目。
【2】场景重现 two:当你在功能分支上开发新 feature 时,多次提交了记录,这时,想要在在合并 feature 分支到 master 之前清理其杂乱的历史记录。
交互式 rebase 使你有机会在将 commits 移动到新分支时更改这些 commits。这比自动 rebase 更强大,因为它提供了对分支提交历史的完全控制。
要使用交互式 rebase,需要使用 git rebase 和 -i 选项:
1 | $ git checkout feature |
这将打开一个文本编辑器,列出即将移动的所有提交:
1 | pick 33d5b7a Message for commit #1 |
此列表准确定义了执行 rebase 后分支的外观。通过更改 pick命令或重新排序条目,你可以使分支的历史记录看起来像你想要的任何内容。例如,如果第二次提交 fix 了第一次提交中的一个小问题,您可以使用以下 fixup 命令将它们浓缩为一个提交:
1 | pick 33d5b7a Message for commit #1 |
保存并关闭文件时,Git将根据您的指示执行 rebase,从而产生如下所示的项目历史记录:
消除这种无意义的提交使你的功能历史更容易理解。这是 git merge 根本无法做到的事情。至于 commits 条目前的 pick( 保留该 commit )、fixup( 将该 commit 和前一个 commit 合并,但我不要保留该提交的注释信息 )、squash( 将该 commit 和前一个 commit 合并 ) 等命令,在 git 目录执行 git rebase -i 即可查看到,大家按需重排或合并提交即可,注释说明非常清晰,在此不做过多说明。
git cherry-pick 可以理解为” 挑拣” 提交,它会获取某一个分支的单笔提交,并作为一个新的提交引入到你当前分支上。 当我们需要在本地合入其他分支的提交时,如果我们不想对整个分支进行合并,而是只想将某一次提交合入到本地当前分支上,那么就要使用 git cherry-pick 了。
【1】场景重现 one:当正在 feature 分支上开发某个新功能,并且进行了多个提交。这时,你切到另外一个 feature 分支,想把之前 feature 分支上的某个提交复制过来,怎么办?这时候,神奇的 git cherry-pick 就闪亮的登场了。
1 | $ git cherry-pick c2 c4 |
git reset 通过把分支记录回退几个提交记录来实现撤销改动。你可以将这想象成“改写历史”。git reset 向上移动分支,原来指向的提交记录就跟从来没有提交过一样。git reset是指将 HEAD 指针指到指定提交,历史记录中不会出现放弃的提交记录。
【1】场景重现 one:有时候,我们用 Git 的时候有可能 commit 提交代码后,发现这一次 commit 的内容是有错误的,那么有两种处理方法:1、修改错误内容,再次 commit 一次;2、使用 git reset 命令撤销这一次错误的 commit。第一种方法比较直接,但会多次一次 commit 记录。而我个人更倾向第二种方法,错误的 commit 没必要保留下来。那么今天来说一下 git reset。
Git reset 命令有三个主要选项:git reset –soft; git reset –mixed; git reset –hard;
git revert 撤销一个提交的同时会创建一个新的提交。这是一个安全的方法,因为它不会重写提交历史。
【1】场景重现 one:改完代码匆忙提交,上线发现有问题,怎么办?赶紧回滚。改完代码测试也没有问题,但是上线发现你的修改影响了之前运行正常的代码报错,必须回滚。
1 | # 撤销指定 commit 到当前 HEAD 之间所有的变化 |
git revert 用于反转提交,用一个新提交来撤销某次提交,执行 git revert 命令时要求工作树必须是干净的。git revert 之后你再 git push 既可以把线上的代码更新。git revert 是放弃指定提交的修改,但是会生成一次新的提交,需要填写提交注释,以前的历史记录都在。
如果你不能很好的应用 Git,那么这里为你提供一个非常棒的 Git 在线练习工具 Learn Git Branching。
[1]. Oh Shit, Git!?!
在微服务架构中,随着服务的逐步拆分,数据库私有已经成为共识,这也导致所面临的分布式事务问题成为微服务落地过程中一个非常难以逾越的障碍,但是目前尚没有一个完整通用的解决方案。
其实不仅仅是在微服务架构中,随着用户访问量的逐渐上涨,数据库甚至是服务的分片、分区、水平拆分、垂直拆分已经逐渐成为较为常用的提升瓶颈的解决方案,因此越来越多的原子操作变成了跨库甚至是跨服务的事务操作。最终结果是在对高性能、高扩展性、高可用性的追求的道路上,我们开始逐渐放松对一致性的追求,但是在很多场景下,尤其是账务,电商等业务中,不可避免的存在着一致性问题,使得我们不得不去探寻一种机制,用以在分布式环境中保证事务的一致性。
分布式事务有多种主流形态,包括:
接下来,本文将对这些形态的分布式事务进行剖析,然后讲解一下如何根据业务选择对应的分布式事务形态。
数据库事务(transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成。关系型数据库(例如:MySQL、SQL Server、Oracle 等)事务都有以下几个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durabilily),简称就是 ACID。
名称 | 描述 | |
---|---|---|
A | Atomicity(原子性) | 一个事务中的所有操作,要么全部完成,要么全部不完成,不会在中间某个环节结束。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。 |
C | Consistency(一致性) | 在事务开始之前和事务结束以后,数据库的完整性没有被破坏。 |
I | Isolation(隔离性) | 数据库允许多个并发事务同时对数据进行读写和修改的能力。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。 |
D | Durability(持久性) | 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。 |
本地数据库事务操作也比较简单:开始一个事务,改变(插入,删除,更新)数据,然后提交事务(如果有异常时回滚事务)。MySQL 事务处理使用到 begin 开始一个事务,rollback 事务回滚,commit 事务确认。这里,事务提交后,通过 redo log 记录变更,通过 undo log 在失败时进行回滚,保证事务的原子性。
1 | Connection con = null; |
但随着业务数据规模的快速发展,数据量越来越大,单库单表逐渐成为瓶颈。所以我们对数据库进行了水平拆分,将原单库单表拆分成数据库分片。分库分表之后,原来在一个数据库上就能完成的写操作,可能就会跨多个数据库,这就产生了跨数据库事务问题。
在条件允许的情况下,我们应该尽可能地使用单机事务,因为单机事务里,无需额外协调其他数据源,减少了网络交互时间消耗以及协调时所需的存储 IO 消耗,在修改等量业务数据的情况下,单机事务将会有更高的性能。但单机数据库由于业务逻辑解耦等因素进行了数据库垂直拆分或者由于单机数据库性能压力等因素进行了数据库水平拆分之后,数据分布于多个数据库,这时若需要对多个数据库的数据进行协调变更,则需要引入分布式事务。
微服务使得单体架构扩展为分布式架构,在扩展的过程中,逐渐丧失了单体架构中数据源单一,可以直接依赖于数据库进行事务操作的能力,而关系型数据库中,提供了强大的事务处理能力,可以满足 ACID(Atomicity,Consistency,Isolation,Durability)的特性,这种特性保证了数据操作的强一致性,这也是分布式环境中弱一致性以及最终一致性能够得以实现的基础。
数据一致性分为三个种类型:强一致性,弱一致性以及最终一致性。对于关系型数据库,要求更新过的数据能被后续的访问都能看到,这是强一致性。如果能容忍后续的部分或者全部访问不到,则是弱一致性。如果经过一段时间后要求能访问到更新后的数据,则是最终一致性。数据库实现的就是强一致性,能够保证在写入一份新的数据,立即使其可见;最终一致性是弱一致性的强化版,系统保证在没有后续更新的前提下,系统最终返回上一次更新操作的值。在没有故障发生的前提下,不一致窗口的时间主要受通信延迟,系统负载和复制副本的个数影响。
CAP 定理是由加州大学伯克利分校 Eric Brewer 教授提出来的,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼:
微服务作为分布式系统,同样受 CAP 原理的制约,在 CAP 理论中, C:Consistency、A:Availability、P:Partition tolerance 三者不可同时满足,而服务化中,更多的是提升 A 以及 P,在这个过程中不可避免的会降低对 C 的要求,因此,BASE 理论随之而来。
BASE 理论来源于 ebay 在 2008 年 ACM 中发表的论文,BASE 理论的基本原则有三个:Basically Available(基本可用),Soft state(软状态),Eventually consistent(最终一致性),主要目的是为了提升分布式系统的可伸缩性,论文同样阐述了如何对业务进行调整以及折中的手段,BASE 理论是对 CAP 定理中的一致性和可用性进行一个权衡的结果,理论的核心思想就是:我们无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。
在最终一致性的实现过程中,最基本的操作就是保证事务参与者的幂等性,所谓的幂等性,就是业务方能够使用相关的手段,保证单个事务多次提交依然能够保证达到同样的目的。
现在,业内比较常用的分布式事务解决方案,包括强一致性的两阶段提交模式、三阶段提交模式,以及最终一致性的事务消息模式、补偿事务模式、本地消息表模式、SAGA 模式,我们会在后面的章节中详细介绍与实战。
两阶段提交协议(The two-phase commit protocol,2PC)是 XA[1] 用于在全局事务中协调多个资源的机制,2PC 是一个非常经典的强一致、中心化的原子提交协议。这里所说的中心化是指协议中有两类节点:一个是中心化协调者节点(coordinator)和 N 个参与者节点(partcipant)。在分布式系统中,每一个机器节点能够知道自己在执行事务操作过程是成功或失败,却无法直接获取其他分布式节点的执行结果。因此,为保持事务处理的 ACID,则引入协调者 (即 XA 协议中的事务管理器) 来统一调度所有分布式节点的执行逻辑,而被调度的分布式节点则称为参与者(即 XA 协议中的资源管理器)。
两阶段提交协议,事务管理器(协调者)分两个阶段来协调资源管理器(参与者),第一阶段准备资源,也就是预留事务所需的资源,如果每个资源管理器都资源预留成功,则进行第二阶段资源提交,否则协调资源管理器回滚资源。两阶段提交协议属于牺牲了一部分可用性来换取一致性的分布式事务方案。
该阶段的主要目的在于打探数据库集群中的各个参与者是否能够正常的执行事务,具体步骤如下:
在经过第一阶段协调者的询盘之后,各个参与者会回复自己事务的执行情况,这时候存在三种可能性:(1)所有的参与者都回复能够正常执行事务;(2)一个或多个参与者回复事务执行失败;(3)协调者等待超时
对于第一种情况,协调者将向所有的参与者发出提交事务的通知,具体步骤如下:
对于第二、三种情况,协调者均认为参与者无法成功执行事务,为了整个集群数据的一致性,所以要向各个参与者发送事务回滚通知,具体步骤如下:
两阶段提交协议原理简单、易于实现,但是缺点也是显而易见的,主要缺点如下:
两阶段提交分布式事务,在prepare阶段需要等待所有参与子事务的反馈,因此可能造成数据库资源锁定时间过长,对性能影响很大,不适合并发高以及子事务生命周长较长的业务场景;因此适用于参与者较少,单个本地事务执行时间较少,并且参与者自身可用性很高的场景,否则,其很可能导致性能下降严重。两阶段提交分布式事务方案属于牺牲了一部分可用性来换取的一致性。
三阶段提交协议(The three-phase commit protocol,3PC)针对两阶段提交协议存在的问题,将两阶段提交协议的 “投票阶段” 过程一分为二,在两阶段提交协议的基础上增加了 “预询盘” 阶段,以及超时策略使得原先在两阶段提交协议中,参与者在投票之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的 “不确定状态” 所产生的可能相当长的延时的问题得以解决,从而来减少整个集群的阻塞时间,提升系统性能。三阶段提交协议的三个阶段分别为:can_commit,pre_commit,do_commit。
该阶段协调者会去询问各个参与者是否能够正常执行事务,参与者根据自身情况回复一个预估值,相对于真正的执行事务,这个过程是轻量的,具体步骤如下:
本阶段协调者会根据第一阶段的询盘结果采取相应操作,询盘结果主要有三种:(1)所有的参与者都返回确定信息;(2)一个或多个参与者返回否定信息;(3)协调者等待超时
针对第一种情况,协调者会向所有参与者发送事务执行请求,具体步骤如下:
针对第二、三种情况,协调者认为事务无法正常执行,于是向各个参与者发出 abort 通知,请求退出预备状态,具体步骤如下:
如果第二阶段事务未中断,那么本阶段协调者将会依据事务执行返回的结果来决定提交或回滚事务,分为三种情况:(1)所有的参与者都回复能够正常执行事务;(2)一个或多个参与者回复事务执行失败;(3)协调者等待超时
对于第一种情况,协调者将向所有的参与者发出提交事务的通知,具体步骤如下:
对于第二、三种情况,协调者均认为参与者无法成功执行事务,为了整个集群数据的一致性,所以要向各个参与者发送事务回滚通知,具体步骤如下:
在本阶段如果因为协调者或网络问题,导致参与者迟迟不能收到来自协调者的 commit 或 rollback 请求,那么参与者将不会如两阶段提交协议中那样陷入阻塞,而是等待超时后继续 commit,相对于两阶段提交虽然降低了同步阻塞,但仍然无法完全避免数据的不一致。
相比较 2PC 而言,3PC 对于协调者(Coordinator)和参与者(Partcipant)都设置了超时时间,而 2PC 只有协调者才拥有超时机制。这一优化主要避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,自动进行本地 commit 从而进行释放资源,而这种机制也侧面降低了整个事务的阻塞时间和范围。3PC 在去除阻塞的同时也引入了新问题,当参与者接收到 preCommit 消息后,如果网络出现分区,此时协调者所在节点和参与者无法进行正常的网络通信,在这种情况下,该参与者依然会进行事务的提交,这必然出现数据的不一致性。
两阶段提交协议中所存在的长时间阻塞状态发生的几率还是非常低的,所以虽然三阶段提交协议相对于两阶段提交协议对于数据强一致性更有保障,但是因为效率问题,两阶段提交协议在实际系统中反而更加受宠。
TCC(Try-Confirm-Cancel)实际上是服务化的两阶段提交协议,是一种达到最终一致性的补偿性事务,相对于 XA 等传统模型,其特征在于它不依赖 RM 对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。其核心思想是:”针对每个操作都要注册一个与其对应的确认和补偿(撤销)操作”。它分为三个阶段:Try、Confirm、Cancel,业务开发者需要实现这三个服务接口:
事务开始时,业务应用会向事务协调器注册启动事务。之后业务应用会调用所有服务的 try 接口,完成一阶段准备。之后事务协调器会根据 try 接口返回情况,决定调用 confirm 接口或者 cancel 接口。如果接口调用失败,会进行重试。事务协调器记录了全局事务的推进状态以及各子事务的执行状态,负责推进各个子事务共同进行提交或者回滚。同时负责在子事务处理超时后不停重试,重试不成功后转手工处理,用以保证事务的最终一致性。
假设现在有一个电商系统,里面有一个支付订单的场景。那对一个订单支付之后,我们需要做下面的步骤:
[1] 更改订单的状态为“已支付” - 对本地的的订单数据库修改订单状态为 “已支付”
[2] 扣减商品库存 - 调用库存服务扣减库存
[3] 给会员增加积分 - 调用积分服务增加积分
[4] 创建销售出库单通知仓库发货 - 调用仓储服务通知发货
对于分布式事务来说,上面那几个步骤,要么全部成功,如果任何一个服务的操作失败了,就全部一起回滚,撤销已经完成的操作。
订单服务:修改订单的状态为支付中 OrderStatus.UPDATING
库存服务:库存数量不变,可销售库存数量减 1,设计一个单独的冻结库存的字段 freeze_inventory 数量加 1,表示有 1 个库存被冻结
积分服务:会员积分不变,设计一个单独的预增加积分字段 prepare_add_credit 数量设置为 10,表示有 10 个积分准备增加
仓储服务:先创建一个销售出库单,但是这个销售出库单的状态是 “UNKNOWN”未知
订单服务:修改订单的状态为已支付 OrderStatus.PAYED
库存服务:将冻结库存的字段 freeze_inventory 数量清空,表示正式完成了库存的扣减
积分服务:将预增加积分字段 prepare_add_credit 10 个积分扣掉,然后加入实际的会员积分字段中
仓储服务:将销售出库单的状态正式修改为 “CREATED” 已创建,可以供仓储管理人员查看和使用
订单服务:修改订单的状态为已取消 OrderStatus.CANCELED
库存服务:将冻结库存的字段 freeze_inventory 1 个库粗扣掉,然后加入可销售库存字段中
积分服务:将预增加积分字段 prepare_add_credit 10 个积分扣掉
仓储服务:将销售出库单的状态正式修改为 “CANCELED” 已取消
如果使用基于 TCC 实现的分布式事务,最好选择某种 TCC 分布式事务框架, 事务的 Try、Confirm、Cancel 三个状态交给框架来感知 。服务调用链路依次执行 Try 逻辑,如果都正常的话,TCC 分布式事务框架推进执行 Confirm 逻辑,完成整个事务;如果某个服务的 Try 逻辑有问题,TCC 分布式事务框架感知到之后就会推进执行各个服务的 Cancel 逻辑,撤销之前执行的各种操作。这里笔者给大家推荐几个比较不错的 TCC 框架:ByteTCC,TCC-transaction,Himly。
在微服务架构下,很有可能出现网络超时、重发,机器宕机等一系列的异常 Case。一旦遇到这些 Case,就会导致我们的分布式事务执行过程出现异常。最常见的主要是这三种异常,分别是空回滚、幂等、悬挂。
什么是空回滚?事务协调器在调用 TCC 服务的一阶段 Try 操作时,可能会出现因为丢包而导致的网络超时,此时事务管理器会触发二阶段回滚,调用 TCC 服务的 Cancel 操作,而 Cancel 操作调用未出现超时。
TCC 服务在未收到 Try 请求的情况下收到 Cancel 请求,这种场景被称为空回滚;空回滚在生产环境经常出现,用户在实现 TCC 服务时,应允许允许空回滚的执行,即收到空回滚时返回成功。
事务协调器在调用 TCC 服务的一阶段 Try 操作时,可能会出现因网络拥堵而导致的超时,此时事务管理器会触发二阶段回滚,调用 TCC 服务的 Cancel 操作,Cancel 调用未超时;在此之后,拥堵在网络上的一阶段 Try 数据包被 TCC 服务收到,出现了二阶段 Cancel 请求比一阶段 Try 请求先执行的情况,此 TCC 服务在执行晚到的 Try 之后,将永远不会再收到二阶段的 Confirm 或者 Cancel ,造成 TCC 服务悬挂。
用户在实现 TCC 服务时,要允许空回滚,但是要拒绝执行空回滚之后 Try 请求,要避免出现悬挂。
无论是网络数据包重传,还是异常事务的补偿执行,都会导致 TCC 服务的 Try、Confirm 或者 Cancel 操作被重复执行;用户在实现 TCC 服务时,需要考虑幂等控制,即 Try、Confirm、Cancel 执行一次和执行多次的业务结果是一样的。
TCC 方案的处理流程与 2PC 方案的处理流程类似,不过 2PC 通常都是在跨库的 DB 层面,而 TCC 本质上就是一个应用层面的 2PC,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据库操作的粒度,使得降低锁冲突、提高吞吐量成为可能。当然 TCC 方案也有不足之处,集中表现在以下两个方面:
TCC 方案适用于时效性要求高,如转账、支付等场景,因此 TCC 方案在电商、金融领域落地较多,但是上述原因导致 TCC 方案大多被研发实力较强、有迫切需求的大公司所采用。微服务倡导服务的轻量化、易部署,而 TCC 方案中很多事务的处理逻辑需要应用自己编码实现,对业务的侵入强,复杂且开发量大。因此,TCC 实际上是最为复杂的一种情况,其能处理所有的业务场景,但无论出于性能上的考虑,还是开发复杂度上的考虑,都应该尽量避免该类事务。
Saga 事务模型又叫做长时间运行的事务(Long-running-transaction), 它是由普林斯顿大学的 Hector Garcia-Molina 和 Kenneth Salem 等人提出,它描述的是另外一种在没有两阶段提交的的情况下解决分布式系统中复杂的业务事务问题。该模型其核心思想就是拆分分布式系统中的长事务为多个短事务,或者叫多个本地事务,然后由 Sagas 工作流引擎负责协调,如果整个流程正常结束,那么就算是业务成功完成,如果在这过程中实现失败,那么 Sagas 工作流引擎就会以相反的顺序调用补偿操作,重新进行业务回滚。
Saga 的具体实现分为两种:协同式(Choreography) 以及 编排式(Orchestration)
这种模式下不存在协调器的概念,每个节点均对自己的上下游负责,在监听处理上游节点事件的同时,对下游节点发布事件。 把 Saga 的决策和执行顺序逻辑分布在 Saga 的每一个参与方中,它们通过交换事件的方式来进行沟通。
把 Saga 的决策和执行顺序逻辑集中在一个 Saga 编排器类中。Saga 编排器发出命令式消息给每个 Saga 参与方,指示这些参与方服务完成具体操作。该中心节点,即协调器知道整个事务的分布状态,相比于无中心节点方式,该方式有着许多优点:(1)能够避免事务之间的循环依赖关系;(2)参与者只需要执行命令 / 回复,降低参与者的复杂性;(3)开发测试门槛低;(4)在添加新步骤时,事务复杂性保持线性,回滚更容易管理。因此大多数 Saga 模型实现均采用了这种思路。
Saga 方案的优点在于其降低了事务粒度,使得事务扩展更加容易,同时采用了异步化方式提升性能。但是其缺点在于很多时候很难定义补偿接口,回滚代价高,而且由于 Saga 在执行过程中采用了先提交后补偿的思路进行操作,所以单个子事务在并发提交时的隔离性很难保证。
Saga 方案适用于无需马上返回业务发起方最终状态的场景,例如:你的请求已提交,请稍后查询或留意通知之类的场景。Saga 方案中所有的本地子事务执行过程中,都无需等待其调用的子事务执行,减少了加锁的时间,这在事务流程较多较长的业务中性能优势更为明显。同时,其利用队列进行进行通讯,具有削峰填谷的作用。因此该形式适用于不需要同步返回发起方执行最终结果、可以进行补偿、对性能要求较高、不介意额外编码的业务场景。
基于普通消息的最终一致性分布式事务方案存在的一致性问题:(1)以订单创建为例,订单系统先创建订单(本地事务),再发送消息给下游处理;如果订单创建成功,然而消息没有发送出去,那么下游所有系统都无法感知到这个事件,会出现脏数据;(2)如果先发送订单消息,再创建订单;那么就有可能消息发送成功,但是在订单创建的时候却失败了,此时下游系统却认为这个订单已经创建,也会出现脏数据。
此时可能有同学会想,我们可否将消息发送和业务处理放在同一个本地事务中来进行处理,如果业务消息发送失败,那么本地事务就回滚,这样是不是就能解决消息发送的一致性问题呢?
可能的情况 | 一致性 |
---|---|
订单处理成功,然后突然宕机,事务未提交,消息没有发送出去 | 一致 |
订单处理成功,由于网络原因或者 MQ 宕机,消息没有发送出去,事务回滚 | 一致 |
订单处理成功,消息发送成功,但是 MQ 由于其他原因,导致消息存储失败,事务回滚 | 一致 |
订单处理成功,消息存储成功,但是 MQ 处理超时,从而 ACK 确认失败,导致发送方本地事务回滚 | 不一致 |
对于消息发送的异常情况分析,我们可以看到,使用基于普通消息的最终一致性分布式事务方案无论如何,都无法保证业务处理与消息发送两边的一致性,其根本的原因就在于:远程调用,结果最终可能为成功、失败、超时;而对于超时的情况,处理方最终的结果可能是成功,也可能是失败,调用方是无法知晓的。为了保证两边数据的一致性,我们只能从其他地方寻找新的突破口。
由于传统的处理方式无法解决消息生成者本地事务处理成功与消息发送成功两者的一致性问题,因此事务消息就诞生了,事务消息特性可以看作是两阶段协议的消息实现方式,用以确保在以消息中间件解耦的分布式系统中本地事务的执行和消息的发送,可以以原子的方式进行。
事务消息作为一种异步确保型事务,本质就是为了解决本地事务执行与消息发送的原子性问题。目前,事务消息在多种分布式消息中间件中均有实现,但是其实现方式思路却各有不同。
传统事务消息实现,一种思路是依赖于 AMQP 协议用来确保消息发送成功,AMQP 模式下需要在发送事务消息时进行两阶段提交,首先进行 tx_select 开启事务,然后再进行消息发送,最后进行消息的 commit 或者是 rollback。这个过程可以保证在消息发送成功的同时本地事务也一定成功执行,但事务粒度不好控制,而且会导致性能急剧下降,同时依然无法解决本地事务执行与消息发送的原子性问题。
还有另外一种思路,就是通过保证多条消息的同时可见性来保证事务一致性。但是此类消息事务实现机制更多的是用到事务循环(consume-transform-produce)场景中,其本质还是用来保证消息自身事务,并没有把外部事务包含进来。
RocketMQ 事务消息设计则主要是为了解决 Producer 端的消息发送与本地事务执行的原子性问题,RocketMQ 的设计中 broker 与 producer 端的双向通信能力,使得 broker 天生可以作为一个事务协调者存在;而 RocketMQ 本身提供的存储机制,则为事务消息提供了持久化能力;RocketMQ 的高可用机制以及可靠消息设计,则为事务消息在系统在发生异常时,依然能够保证事务的最终一致性达成。
RocketMQ 事务消息的设计流程同样借鉴了两阶段提交理论,整体交互流程如下图所示:
在具体实现上,RocketMQ 通过使用 Half Topic 以及 Operation Topic 两个内部队列来存储事务消息推进状态。其中,Half Topic 对应队列中存放着 prepare 消息,Operation Topic 对应的队列则存放了 prepare message 对应的 commit/rollback 消息,消息体中则是 prepare message 对应的 offset,服务端定期扫描消息集群中的事物消息,比对两个队列的差值来找到尚未提交的超时事务,进行回查。
从用户侧来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此只需关注本地事务的执行状态即可;而在 service 层,则对事务消息的两阶段提交进行了抽象,同时针对超时事务实现了回查逻辑,通过不断扫描当前事务推进状态,来不断反向请求 Producer 端获取超时事务的执行状态,在避免事务挂起的同时,也避免了 Producer 端的单点故障。而在存储层,RocketMQ 通过 Bridge 封装了与底层队列存储的相关操作,用以操作两个对应的内部队列,用户也可以依赖其它存储介质实现自己的 service,RocketMQ 会通过 ServiceProvider 加载进来。
总结一下关于事物消息的常见问题:
答:事务消息适用于上游事务对下游事务无依赖的场景,即 producer 不会因为 consumer 消费失败而做回滚,采用事务消息的应用,其所追求的是高可用和最终一致性,消息消费失败的话,MQ 自己会负责重推消息,直到消费成功。因此,事务消息是针对生产端而言的,而消费端,消费端的一致性是通过 MQ 的重试机制来完成的。
答:基于消息的最终一致性方案必须保证消费端在业务上的操作没障碍,它只允许系统异常的失败,不允许业务上的失败,比如在你业务上抛出个 NPE 之类的问题,导致你消费端执行事务失败,那就很难做到一致了。
事务消息较好的解决了事务的最终一致性问题,事务发起方仅需要关注本地事务执行以及实现回查接口给出事务状态判定等实现,而且在上游事务峰值高时,可以通过消息队列,避免对下游服务产生过大压力。所以,事务消息不仅适用于上游事务对下游事务无依赖的场景,还可以与一些传统分布式事务架构相结合,而 MQ 的服务端作为天生的具有高可用能力的协调者,使基于可靠消息的最终一致性分布式事务解决方案,用以满足各种场景下的分布式事务需求。
不过这种方式技术实现的难度比较大,目前主流的开源 MQ(ActiveMQ、RabbitMQ、Kafka、RocketMQ)中只有 RocketMQ 实现对事物消息的支持,其余 MQ 均未实现对事务消息的支持,因此,如果我们希望强依赖一个 MQ 的事务消息来做到消息最终一致性的话,在目前的情况下,技术选型上只能去选择 RocketMQ 来解决。
由于并非所有的 MQ 都支持事务消息,假如我们不选择 RocketMQ 来作为系统的 MQ,是否能够做到消息的最终一致性呢?答案是可以的。
基于 MQ 事物消息的分布式事务方案其实是对本地消息表的封装,将本地消息表基于 MQ 内部,其他方面的协议基本与本地消息表一致。因此,我们可以以事物消息的实现方式去看待基于本地消息表的分布式事务方案。
本地消息表这种实现方式应该是业界使用最多的,该方案也是目前我参与的项目组所使用的分布式事务方案,其核心思想是将分布式事务拆分成本地事务进行处理,通过消息日志的方式来异步执行,这种思路是来源于 ebay。
方案通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,保证了业务与消息同时成功持久化;通过定时任务轮询事务消息表的数据发送事务消息,如果消息投递失败,依靠重试机制重试发送,发送成功后将消息状态更新或者消息清除;事务被动方基于消息中间件消费事务消息表中的事务,如果处理失败,那么依赖 MQ 本身的重试来完成重试执行,同时需要注意重试的幂等行设计;如果是业务上面的失败,可以给事务主动发起方发送一个业务补偿消息,通知事务主动发起方进行回滚等操作。事务主动发起和事务被动方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。这样设计可以避免”业务处理成功 + 事务消息发送失败”,或”业务处理失败 + 事务消息发送成功”的棘手情况出现,保证 2 个系统事务的数据一致性。
基于本地消息最终一致性分布式事务是一种非常经典的分布式事务实现方案,基本避免了分布式事务,实现了“最终一致性”。该方法从应用设计开发的角度实现了消息数据的可靠性,消息数据的可靠性不依赖于消息中间件,弱化了对 MQ 中间件特性的依赖。但是该方案与具体的业务场景绑定,耦合性强,不可公用。 消息数据与业务数据同库,占用业务系统资源。 业务系统在使用关系型数据库的情况下,消息服务性能会受到关系型数据库并发性能的局限。
基于消息实现的事务适用于分布式事务的提交或回滚只取决于事务发起方的业务需求,其他数据源的数据变更跟随发起方进行的业务场景。
上述几种的分布式事务方案中,笔者大致总结了其设计思路、流程、优势、劣势、使用场景等,相信读者已经有了一定的理解。其实分布式系统的事务一致性本身是一个技术难题,目前没有一种很简单很完美的方案能够应对所有场景。笔者认为对于分布式事务具体还是要使用者根据不同的业务场景去抉择,结合自己的业务分析,看看自己的业务比较适合哪一种,是在乎强一致,还是最终一致即可。上面对解决方案只是一些简单介绍,如果真正的想要落地,其实每种方案需要思考的地方都非常多,复杂度都比较大,所以最后再次提醒一定要判断好是否使用分布式事务。
微服务兴起这几年涌现出不少分布式事务框架,比如 ByteTCC、TCC-transaction、TCC-transaction 以及最近很火爆的 Seata。目前笔者也在阅读、研究 Seata 源码,如果诸位对分布式事务感兴趣,我想 Seata 框架是一个值得研究的框架!
[1]. 对分布式事务及两阶段提交、三阶段提交的理解
[2]. 分布式事务:两阶段提交与三阶段提交
[3]. 里程碑 | Apache RocketMQ 正式开源分布式事务消息
[4]. 分布式事务 Seata Saga 模式首秀以及三种模式详解 | Meetup#3 回顾
[5]. 分布式事务 Seata TCC 模式深度解析 | SOFAChannel#4 直播整理
[1]. XA:为了统一标准减少行业内不必要的对接成本,需要制定标准化的处理模型及接口标准,国际开放标准组织 Open Group 定义分布式事务处理模型 DTP(Distributed Transaction Processing Reference Model),DTP 模型定义 TM 和 RM 之间通讯的接口规范叫 XA。XA 协议由 Tuxedo 首先提出的,并交给 X/Open 组织,作为资源管理器 RM(Resource Manager)与事务管理器 TM(Transaction Manager)之间进行通信的接口标准。目前,Oracle、Informix、DB2 和 Sybase 等各大数据库厂家都提供对 XA 的支持。XA 协议采用两阶段提交方式来管理分布式事务。在 XA 规范中,数据库充当 RM 角色,应用需要充当 TM 的角色,即生成全局的 txId,调用 XAResource 接口,把多个本地事务协调为全局统一的分布式事务。
在开始本章的讲解之前,我们首先从宏观角度回顾一下 Redis 实现高可用相关的技术。它们包括:持久化、复制、哨兵和集群,在本系列的前篇文章介绍了持久化以及复制的原理以及实现。本文将对剩下的两种高可用技术哨兵、集群进行讲解,讲一讲它们是如何进一步提高系统的高可用性?
Redis 的主从复制模式下,一旦主节点由于故障不能提供服务,需要手动将从节点晋升为主节点,同时还要通知客户端更新主节点地址,这种故障处理方式从一定程度上是无法接受的。Redis 2.8 以后提供了 Redis Sentinel 哨兵机制来解决这个问题。
在 Redis 3.0 之前,使用哨兵(sentinel)机制来监控各个节点之间的状态。Redis Cluster 是 Redis 的分布式解决方案,在 3.0 版本正式推出,有效地解决了 Redis 在分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用 Cluster 架构方案达到负载均衡的目的。
Sentinel(哨岗、哨兵)是 Redis 的高可用(high availability)解决方案:由一个或多个 Sentinel 实例(instance)组成的 Sentinel 系统(system)可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。
当 server1 的下线时长超过用户设定的下线时长上限时,Sentinel 系统就会对 server1 执行故障转移操作:
Sentinel 系统用于管理多个 Redis 服务器(instance), 该系统执行以下三个任务:
由于本人没有这么多服务器,因此在一台机器上模拟一个 Redis Sentinel 集群。
角色 | IP 地址 | 端口号 |
---|---|---|
Redis Master | 127.0.0.1 | 6380 |
Redis Slave-01 | 127.0.0.1 | 6381 |
Redis Slave-02 | 127.0.0.1 | 6382 |
Redis Slave-03 | 127.0.0.1 | 6383 |
Redis Sentinel-01 | 127.0.0.1 | 26381 |
Redis Sentinel-02 | 127.0.0.1 | 26382 |
Redis Sentinel-03 | 127.0.0.1 | 26383 |
1、下载 Redis 服务软件包到服务器,解压后并编译安装。
1 | [root@VM_24_98_centos ~] |
2、设置 Redis 主服务器
a. 创建目录以及复制配置文件
1 | [root@VM_24_98_centos redis]# mkdir -p /usr/local/redis/redis-master/redis-6380 |
b. 设置 Redis Master 主服务器配置环境
1 | # 开启远程连接 |
c. 启动 Redis Master 主服务器
1 | [root@VM_24_98_centos redis-6380] |
d. 客户端测试连接
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-server /usr/local/redis/redis-master/redis-6380/redis.conf |
3、设置 Redis 从服务器
a. 创建目录以及复制配置文件
1 | [root@VM_24_98_centos redis]# mkdir -p /usr/local/redis/redis-slave/redis-6381 |
b. 设置 Redis Slave 从服务器配置环境
1 | # 开启远程连接 |
c. 启动 Redis Slave 从服务器
1 | [root@VM_24_98_centos redis-6381] |
d. 客户端测试连接
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-server /usr/local/redis/redis-slave/redis-6381/redis.conf |
e. 同理,从服务器 redis-6382、redis-6383 按照上面的步骤部署。
4、Redis Sentinel 部署
a. 创建目录以及复制配置文件
1 | [root@VM_24_98_centos redis]# mkdir -p /usr/local/redis/redis-sentinel/redis-26381 |
b. 设置 Redis Sentinel 哨兵服务器配置环境
1 | # 端口号 |
c. 启动 Redis Sentinel 哨兵服务器
1 | [root@VM_24_98_centos redis-26381] |
d. 客户端测试连接
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli -h 127.0.0.1 -p 26381 |
e. 同理,哨兵服务器 redis-26382、redis-26383 按照上面的步骤部署
f. 查看 Redis Master 主服务器连接状况
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli -h 127.0.0.1 -p 6380 -a foobared |
模拟场景:Redis Master 节点挂掉,查看 Redis 集群状态。
Step1、关掉 Master 节点
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli -h 127.0.0.1 -p 6380 -a foobared |
Step2、通过哨兵查看集群状态
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli -h 127.0.0.1 -p 26381 |
通过 Sentinel 信息可以看到,Master 节点已经自动切换到 6381 端口了,说明主节点挂掉后,6381 Slave 节点自动升级成为了 Master 节点。
通过 Sentinel 日志文件显示了 failover 的过程:
Step3、启动 6380 Redis 服务,然后查看节点角色,此时 6380 变成了 Slave,6381 为 Master 节点
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-server /usr/local/redis/redis-master/redis-6380/redis.conf |
Redis 集群是 Redis 提供的分布式数据库方案,集群通过分片(sharding)而非一致性哈希(consistency hashing)来进行数据分享,并提供复制和故障转移功能。Redis Cluster,主要是针对海量数据 + 高并发 + 高可用的场景。Redis Cluster 支撑 N 个 Redis Master Node,每个 Master Node 都可以挂载多个 Slave Node。Redis Cluster 节点间采用 Gossip 协议[2]进行通信。
节点:一个 Redis 集群通常由多个节点(node)组成,连接各个节点的工作可以使用 CLUSTER MEET <ip> <port> 命令来完成,将各个独立的节点连接起来,构成一个包含多个节点的集群。向一个节点 node 发送 CLUSTER MEET 命令,可以让 node 节点与 ip 和 port 所指定的节点进行握手(handshake),当握手成功时,node 节点就会将 ip 和 port 所指定的节点添加到 node 节点当前所在的集群中。
槽指派:Redis 集群通过分片的方式来保存数据库中的键值对,集群的整数据库被分为 16384 个槽(slot),数据库中的每个键都属于这 16384 个槽的其中一个,集群中的每个节点可以处理 0 个或最多 16384 个槽。Redis 集群有固定的 16384 个 hash slot,对每个 key 计算 CRC16 值,然后对 16384 取模,可以获取 key 对应的 hash slot。当数据库中的 16384 个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中任何一个槽没有得到处理,那么集群处于下线状态(fail)。
由于资源有限,因此在一台机器上模拟一个 Redis Cluster。
角色 | IP 地址 | 端口号 |
---|---|---|
Redis Cluster-Master-01-6391 | 127.0.0.1 | 6391 |
Redis Cluster-Master-02-6393 | 127.0.0.1 | 6393 |
Redis Cluster-Master-02-6395 | 127.0.0.1 | 6395 |
Redis Cluster-Slave-01-6394 | 127.0.0.1 | 6394 |
Redis Cluster-Slave-02-6396 | 127.0.0.1 | 6396 |
Redis Cluster-Slave-03-6392 | 127.0.0.1 | 6392 |
1、下载 Redis 服务软件包到服务器,解压后并编译安装。
1 | [root@VM_24_98_centos ~] |
2、设置 Redis Cluster 服务器
a. 创建目录以及复制配置文件
1 | [root@VM_24_98_centos ~]# mkdir -p /usr/local/redis/redis-cluster/redis-6391 |
b. 设置 Redis Cluster 服务器配置环境
1 |
|
c. 启动 Redis Cluster 服务器
1 | [root@VM_24_98_centos ~] |
d. 客户端测试连接
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-server /usr/local/redis/redis-cluster/redis-6391/redis.conf |
e. 同理,集群服务器 redis-6392、redis-6393 、redis-6394、redis-6395、redis-6396 按照上面的步骤部署
3、Redis 5.0 开始不再使用 ruby 搭建集群,而是直接使用客户端命令 redis-cli 来创建。
a. 创建顺序三主三从,前面三个是主后面三个是从。由于我们设置了redis集群的密码,所以要带上密码。
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli --cluster create 127.0 :6391 127.0 :6393 127.0 :6395 127.0 :6392 127.0 :6394 127.0 :6396 --cluster-replicas 1 -a foobared |
b. 客户端测试连接
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli -h 127.0.0.1 -p 6391 -a foobared |
(1)模拟场景:Redis Cluster 中 某个 Master 节点挂掉,查看 Redis Cluster 状态。
Step1、关掉 Cluster-Master-6391 节点
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli -h 127.0.0.1 -p 6391 -a foobared |
Step2、查看集群状态
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli -h 127.0.0.1 -p 6393 -a foobared |
通过 CLUSTER NODES 信息可以看到,Cluster-Master-01-6391 主节点处于下线状态(fail),其 Cluster-Master-01-6391 节点的从节点 Cluster-Slave-01-6394 变为主节点;说明主节点挂掉后,6394 Slave 节点自动升级成为了 Master 节点。
Step3、启动 6391 Redis 服务,然后查看节点角色,此时 6391 变成了 Slave,6394 为 Master 节点
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-server /usr/local/redis/redis-cluster/redis-6391/redis.conf |
(2)模拟场景:为 Redis Cluster 添加一个新主(master)节点
Step1、按照上面的步骤新增一 Redis Cluster 服务器 Cluster-Master-04-6397
Step2、将 Cluster-Master-04-6397 节点加入 Redis Cluster 中(127.0.0.1:6391 为集群中任意可用的节点)
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli --cluster add-node 127.0 :6397 127.0 :6391 -a foobared |
Step3、为节点 Cluster-Master-04-6397 分配 slots(127.0.0.1:6391 为集群中任意可用的节点)
1 | [root@VM_24_98_centos redis-cluster]# /usr/local/redis/bin/redis-cli --cluster reshard 127.0.0.1:6391 -a foobared |
(3)模拟场景:为 Redis Cluster 某个 Master 节点添加 一个新从(slave)节点
Step1、按照上面的步骤新增一 Redis Cluster 服务器 Cluster-Slave-04-6398
Step2、将 Cluster-Slave-04-6398 节点加入 Redis Cluster 中(127.0.0.1:6391 为集群中任意可用的节点)
1 | // 这种方法随机为 6398 指定一个 master |
Step3、查看集群状态
1 | [root@VM_24_98_centos ~]# /usr/local/redis/bin/redis-cli -h 127.0.0.1 -p 6391 -a foobared |
[1]. 一文搞懂 Raft 算法
[2]. Redis 哨兵模式实现主从故障互切换
[3]. Redis cluster tutorial
[4]. redis cluster 的Gossip协议介绍
[1]. 领头 Sentinel:Sentinel 系统选举领头 Sentinel 的方式是对 Raft 算法的领头选举方法的实现,Raft 算法是一个共识算法,是工程上使用较为广泛的强一致性、去中心化、高可用的分布式协议。
[2]. Gossip 协议:Gossip protocol 也叫 Epidemic Protocol(流行病协议),实际上它还有很多别名,比如:“流言算法”、“疫情传播算法” 等。Gossip 过程是由种子节点发起,当一个种子节点有状态需要更新到网络中的其他节点时,它会随机的选择周围几个节点散播消息,收到消息的节点也会重复该过程,直至最终网络中所有的节点都收到了消息。这个过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个“最终一致性协议”。
“一个网站有 20 亿 url 存在一个黑名单中,这个黑名单要怎么存?若此时随便输入一个 url,你如何快速判断该 url 是否在这个黑名单中?并且需在给定内存空间(比如:500M)内快速判断出。” 这是一道经常在面试中出现的算法题。
很多人脑海中首先想到的可能是 HashSet,因为 HashSet 的底层是采用 HashMap 实现的,理论上时间复杂度为:O(1)。达到了快速的目的,但是空间复杂度呢?URL 字符串通过 Hash 得到一个 Integer 的值,Integer 占 4 个字节,那 20 亿个 URL 理论上需要:4 字节 (byte) * 20 亿 = 80 亿 (byte) ≈ 7.45G 的内存空间,不满足空间复杂度的要求。
还有一种方法就是位图法[1],每个 URL 取整数哈希值,置于位图相应的位置上,看上去是可行的。但位图适合对海量的、取值分布很均匀的集合去重。位图法的所占空间随集合内最大元素的增大而增大,即空间复杂度随集合内最大元素增大而线性增大。要设计冲突率很低的哈希函数,势必要增加哈希值的取值范围,4G 的位图最大值是 320 亿左右,为 50 亿条 URL 设计冲突率很低、最大值为 320 亿的哈希函数比较困难。这就会带来一个问题,如果查找的元素数量少但其中某个元素的值很大,比如数字范围是 1 到 1000 亿,那消耗的空间不容乐观。因此,出于性能和内存占用的考虑,在这里使用布隆过滤器才是最好的解决方案:布隆过滤器是对位图的一种改进。
这里就引出本文要介绍的 “布隆过滤器”。
布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的。
布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。
布隆过滤器的原理是,当一个元素被加入集合时,通过 K 个散列函数将这个元素映射成一个位数组中的 K 个点,把它们置为 1。检索时,我们只要看看这些点是不是都是 1 就(大约)知道集合中有没有它了:如果这些点有任何一个 0,则被检元素一定不在;如果都是 1,则被检元素很可能在。这就是布隆过滤器的基本思想。
Bloom Filter 跟单哈希函数 Bit-Map 不同之处在于:Bloom Filter 使用了 k 个哈希函数,每个字符串跟 k 个 bit 对应,从而降低了冲突的概率。
主要是解决大规模数据下不需要精确过滤的场景。
利用 HashSet 实现黑名单过滤,写入和判断元素是否存在都有对应的 API,所以实现起来也比较简单。
通过单元测试演示 HashSet 实现黑名单过滤功能;同时为了前后的对比将堆内存写死(-Xms64m -Xmx64m),为了方便调试加入了 GC 日志的打印(-XX:+PrintHeapAtGC),以及内存溢出后 Dump 内存(-XX:+HeapDumpOnOutOfMemoryError)。
1 | -Xms64m -Xmx64m -XX:+PrintHeapAtGC -XX:+HeapDumpOnOutOfMemoryError |
1、写入 100 条数据时:
1 | @Test |
2、写入 1000 W 条数据时:
1 | @Test |
执行后马上就内存溢出,可见在内存有限的情况下我们不能使用这种方式。
BloomFilter 实现的一个重点就是怎么利用 hash 函数把数据映射到 bit 数组中。Guava 的实现是对元素通过 MurmurHash3 计算 hash 值,将得到的 hash 值取高 8 个字节以及低 8 个字节进行计算,以得当前元素在 bit 数组中对应的多个位置。
Guava 会通过你预计的数量以及误报率帮你计算出你应当会使用的数组大小 numBits 以及需要计算几次 Hash 函数 numHashFunctions 。
1、组件依赖:通过 Maven 引入 Guava 开源组件,在 pom.xml 文件加入下面的代码:
1 | <dependency> |
2、代码实现:通过 Java 代码实现布隆过滤器
1 | @Test |
代码分析:我们的定义了一个预期数据量为 1000 W、预期误判率为 0.001 的布隆过滤器,接下来向布隆过滤器中插入了 0-10000000 数据,然后用 0- 10000000 以及 10000000-20000000 数据来测试误判率。
(1)经过测试:“预期数据量:10000000,判断数量:10000000,预期误判率:0.001,存在比率:1.0 ”,可发现当过滤器判断 0-10000000 的数据时,存在比率为 1.0,即布隆过滤器对于已经见过的元素肯定不会误判,它只会误判那些没见过的元素。
(2)经过测试:“预期数据量:10000000,误判数量:10132,预期误判率:0.001,实际误判率:0.0010132” ,符合预期误判率:0.001。
1、安装 Rebloom 插件:Redis 安装在这里就不介绍了,这里讲一下 Rebloom 插件安装。
1 | // 下载 Rebloom 源文件 |
2、加载 Rebloom 插件方法
(1)、在启动的 client 中使用 MODULE LOAD 命令去加载(重启 Redis 后失效)
1 | MODULE LOAD /usr/lib64/redis/modules/rebloom/redisbloom.so |
(2)、命令行加载 Rebloom 插件
1 | /usr/bin/redis-server /etc/redis.conf --loadmodule /usr/lib64/redis/modules/rebloom/redisbloom.so |
(3)、在 redis.conf 文件中加入配置信息
1 | loadmodule /usr/lib64/redis/modules/rebloom/redisbloom.so |
3、通过命令测试 Redis Bloom Filter
1 | // 创建一个空的布隆过滤器,并设置一个期望的错误率和初始大小。 |
1、组件依赖:通过 Maven 引入 Redisson [2]开源组件,在 pom.xml 文件加入下面的代码:
1 | <dependency> |
2、代码实现:通过 Java 代码实现布隆过滤器
1 | @Test |
代码分析:Redisson 利用 Redis 实现了 Java 分布式布隆过滤器(Bloom Filter),通过 RedissonClient 初始化布隆过滤器,预计统计元素数量为 10000,期望误差率为 0.001。“预期数据量:10000,误判数量:51,预期误判率:0.001,实际误判率:0.00255 ”。我们看到了误判率大约 0.255%,比预计的 0.1% 高,不过布隆的概率是有误差的,只要不比预计误判率高太多,都是正常现象。
在使用 Bloom Filter 时,绕不过的两点是预估数据量 n 以及期望的误判率 fpp,在实现 Bloom Filter 时,绕不过的两点就是 hash 函数的选取以及 bit 数组的大小。
期望的误判率越低,需要的空间越大。预估数据量,当实际数量超出这个数值时,误判率会上升。因此用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避免实际元素可能会意外高出估计值很多导致误判率升高。
布隆过滤器的空间占用有一个简单的计算公式,但是推导比较繁琐,这里就省去推导过程了,感兴趣的读者可以点击「延伸阅读」深入理解公式的推导过程。虽然存在布隆过滤器的空间占用的计算公司,但是有很多现成的网站已经支持计算空间占用的功能了,我们只要把参数输进去,就可以直接看到结果,比如 布隆计算器。
[1]. 如何判断一个元素在亿级数据中是否存在?
[2]. 大数据量下的集合过滤—Bloom Filter
[3]. 布隆过滤器 (Bloom Filter) 的原理、实现和探究
[1]. 位图法:位图法就是 BitMap 的缩写,所谓 BitMap,就是用每一位来存放某种状态,适用于大规模数据,但数据状态又不是很多的情况。通常是用来判断某个数据存不存在的。
[2]. Redisson:
Redisson 在基于 NIO 的 Netty 框架上,充分的利用了 Redis 键值数据库提供的一系列优势,在 Java 实用工具包中常用接口的基础上,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。
ConcurrentHashMap 从 JDK 1.5 开始随 java.util.concurrent 包一起引入 JDK 中,主要为了解决 HashMap 线程不安全和 Hashtable 效率不高的问题。
HashMap 是我们日常开发中最常见的一种容器,根据键值对键的哈希值来确定值对键在集合中的存储位置,因此具有良好的存取和查找功能。但众所周知,它在高并发的情境下是线程不安全的。尤其是在 JDK 1.8 之前,rehash 的过程中采用头插法转移结点,高并发下,多个线程同时操作一条链表将直接导致闭链,易出现逆序且环形链表死循环问题,导致死循环并占满 CPU。JDK 1.8 以来,对 HashMap 的内部进行了很大的改进,采用数组 + 链表 + 红黑树的形式来进行数据的存储。rehash 的过程也进行了改动,基于复制的算法思想,不直接操作原链,而是定义了两条链表分别完成对原链的结点分离操作,在多线程的环境下,采用了尾插法,扩容后,新数组中的链表顺序依然与旧数组中的链表顺序保持一致,所有即使是多线程的情况下也是安全的。JDK 1.8 中的 HashMap 虽然不会导致死循环,但是因为 HashMap 多线程下内存不共享的问题,两个线程同时指向一个 hash 桶数组时,会导致数据覆盖的问题,所以 HashMap 是依旧是线程不安全的。
HashTable 是线程安全的容器,它在所有涉及到多线程的操作都加上了 synchronized 关键字来锁住整个 table,这就意味着所有的线程都在竞争一把锁,在多线程的环境下,它是安全的,但是无疑是效率低下的,因此 Hashtable 已经是 Java 中的遗留容器,已经不推荐使用。
因此在多线程条件下,需要满足线程安全,我们可使用 Collections.synchronizedMap 方法构造出一个同步的 Map,使 HashMap 具有线程安全的能力;或者直接使用线程安全的 ConcurrentHashMap。本篇文章将要介绍的 ConcurrentHashMap 是 HashMap 的并发版本,它是线程安全的,并且在高并发的情境下,性能优于 HashMap 很多。
由于 HashMap 是非线程安全的容器,遇到多线程操作同一容器的场景,可能会导致数据不一致: JDK 1.7 中 HashMap 采用了数组 + 链表的数据结构,有线程安全问题(统计不准确,丢失数据,环形链表死循环导致 Cpu 100%),JDK 1.8 中 HashMap 采用了数组 + 链表 + 红黑树的结构,有线程安全问题(统计不准确,丢失数据)。
HashTable 容器使用 synchronized 关键字来保证线程安全,但在线程竞争激烈的情况下 HashTable 的效率非常低下。因为当一个线程访问 HashTable 的同步方法时,其他线程访问 HashTable 的同步方法时,可能会进入阻塞或轮询状态。如线程 1 使用 put 进行添加元素,线程 2 不但不能使用 put 方法添加元素,并且也不能使用 get 方法来获取元素,所以竞争越激烈效率越低。
HashTable 容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问 HashTable 的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是 ConcurrentHashMap 所使用的锁分段技术。JDK1.7 中的 ConcurrentHashMap 的底层数据结构是数组 + 链表。与 HashMap 不同的是,ConcurrentHashMap 最外层不是一个大的数组,而是一个 Segment 的数组。每个 Segment 包含一个与 HashMap 数据结构差不多的链表数组。
JDK 1.7 中 ConcurrentHashMap 采用 Segment 分段锁的数据结构,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。有些方法需要跨段,比如 size() 和 containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这种做法,就称之为“分离锁”(lock striping)[1] 。
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。Segment 是一种可重入锁 ReentrantLock,在 ConcurrentHashMap 里扮演锁的角色,HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的结构和 HashMap 类似,是一种数组和链表结构, 一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素, 每个 Segment 守护者一个 HashEntry 数组里的元素, 当对 HashEntry 数组的数据进行修改时,必须首先获得它对应的 Segment 锁。
1 | public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> |
它把区间按照并发级别(concurrentLevel),分成了若干个 segment。默认情况下内部按并发级别为 16 来创建。对于每个 segment 的容量,默认情况也是 16。concurrentLevel,segment 可以通过构造函数设定的。通过按位与的哈希算法来定位 segments 数组的索引,必须保证 segments 数组的长度是 2 的 N 次方(power-of-two size),所以必须计算出一个是大于或等于 concurrencyLevel 的最小的 2 的 N 次方值来作为 segments 数组的长度。假如concurrencyLevel等于14,15或16,ssize都会等于16,即容器里锁的个数也是16。
1 | public ConcurrentHashMap(int initialCapacity, |
对于读操作,通过 Key 哈希值的高 N 位对 Segment 个数取模从而得到该 Key 应该属于哪个 Segment,接着如同操作 HashMap 一样操作这个 Segment。Segment 继承自 ReentrantLock,所以我们可以很方便的对每一个 Segment 上锁。对于写操作,并不要求同时获取所有 Segment 的锁,因为那样相当于锁住了整个 Map。它会先获取该键值对所在的 Segment 的锁,获取成功后就可以像操作一个普通的 HashMap 一样操作该 Segment,并保证该 Segment 的安全性。为了保证不同的值均匀分布到不同的 Segment,需要通过如下方法计算哈希值。
1 | private int hash(Object k) { |
Segment 继承自 ReentrantLock,所以我们可以很方便的对每一个 Segment 上锁。
对于读操作,获取 Key 所在的 Segment 时,需要保证可见性。具体实现上可以使用 volatile 关键字,也可使用锁。但使用锁开销太大,而使用 volatile 时每次写操作都会让所有 CPU 内缓存无效,也有一定开销。ConcurrentHashMap 使用如下方法保证可见性,取得最新的 Segment。
1 | private Segment<K, V> segmentForHash(int h) { |
对于写操作,并不要求同时获取所有 Segment 的锁,因为那样相当于锁住了整个 Map。它会先获取该 Key-Value 对所在的 Segment 的锁,获取成功后就可以像操作一个普通的 HashMap 一样操作该 Segment,并保证该 Segment 的安全性。
同时由于其它 Segment 的锁并未被获取,因此理论上可支持 concurrencyLevel(等于 Segment 的个数)个线程安全的并发读写。
获取锁时,并不直接使用 lock 来获取,因为该方法获取锁失败时会挂起(参考可重入锁)。事实上,它使用了自旋锁,如果 tryLock 获取锁失败,说明锁被其它线程占用,此时通过循环再次以 tryLock 的方式申请锁。如果在循环过程中该 Key 所对应的链表头被修改,则重置 retry 次数。如果 retry 次数超过一定值,则使用 lock 方法申请锁。
这里使用自旋锁是因为自旋锁的效率比较高,但是它消耗 CPU 资源比较多,因此在自旋次数超过阈值时切换为互斥锁。
put、remove 和 get 操作只需要关心一个 Segment,而 size 操作需要遍历所有的 Segment 才能算出整个 Map 的大小。一个简单的方案是,先锁住所有 Sgment,计算完后再解锁。但这样做,在做 size 操作时,不仅无法对 Map 进行写操作,同时也无法进行读操作,不利于对 Map 的并行操作。
为更好支持并发操作,ConcurrentHashMap 会在不上锁的前提逐个 Segment 计算 3 次 size,如果某相邻两次计算获取的所有 Segment 的更新次数(每个 Segment 都与 HashMap 一样通过 modCount 跟踪自己的修改次数,Segment 每修改一次其 modCount 加一)相等,说明这两次计算过程中无更新操作,则这两次计算出的总 size 相等,可直接作为最终结果返回。如果这三次计算过程中 Map 有更新,则对所有 Segment 加锁重新计算 Size。该计算方法代码如下
1 | public int size() { |
JDK 1.7 中 ConcurrentHashMap 为实现并行访问,引入了 Segment 这一结构,实现了分段锁,理论上最大并发度与 Segment 个数相等。JDK 1.8 中 ConcurrentHashMap 为进一步提高并发性,摒弃了分段锁的方案,而是直接使用一个大的数组。
JDK 1.8 中 ConcurrentHashMap 参考了 JDK 1.8 HashMap 的实现,采用了数组 + 链表 + 红黑树的实现方式进行数据存储,提高哈希碰撞下的寻址性能,在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)));进一步提高并发性,取消了基于 Segment 的分段锁思想,改用 CAS[2] + synchronized 控制并发操作,在某些方面提升了性能。对于读操作,通过 Key 的哈希值与数组长度取模确定该 Key 在数组中的索引。对于写操作,如果 Key 对应的数组元素为 null,则通过 CAS 操作将其设置为当前值。如果 Key 对应的数组元素不为 null,则对该元素使用 synchronized 关键字申请锁,然后进行操作。
Node :Node 是最核心的内部类,它包装了 key-value 键值对,所有插入 ConcurrentHashMap 的数据都包装在这里面。它与 HashMap 中的定义很相似,但是但是有一些差别它对 value 和 next 属性设置了 volatile 同步锁,它不允许调用 setValue 方法直接改变 Node 的 value 域,它增加了 find 方法辅助 map.get() 方法。
1 | static class Node<K,V> implements Map.Entry<K,V> { |
TreeNode:树节点类,当链表长度过长的时候,会转换为 TreeNode。但是与 HashMap 不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成 TreeNode 放在 TreeBin 对象中,由 TreeBin 完成对红黑树的包装。而且 TreeNode 在 ConcurrentHashMap 集成自 Node 类,而并非 HashMap 中的集成自 LinkedHashMap.Entry<K,V> 类,也就是说 TreeNode 带有 next 指针,这样做的目的是方便基于 TreeBin 的访问。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58/**
* Nodes for use in TreeBins
*/
static final class TreeNode<K,V> extends ConcurrentHashMap.Node<K,V> {
// 存储当前节点的父节点
ConcurrentHashMap.TreeNode<K,V> parent; // red-black tree links
// 存储当前节点的左孩子
ConcurrentHashMap.TreeNode<K,V> left;
// 存储当前节点的右孩子
ConcurrentHashMap.TreeNode<K,V> right;
// 存储当前节点的前一个节点
ConcurrentHashMap.TreeNode<K,V> prev; // needed to unlink next upon deletion
// 存储当前节点的颜色(红、黑)
boolean red;
TreeNode(int hash, K key, V val, ConcurrentHashMap.Node<K,V> next,
ConcurrentHashMap.TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
ConcurrentHashMap.Node<K,V> find(int h, Object k) {
return findTreeNode(h, k, null);
}
/**
* Returns the TreeNode (or null if not found) for the given key
* starting at given root.
*/
final ConcurrentHashMap.TreeNode<K,V> findTreeNode(int h, Object k, Class<?> kc) {
if (k != null) {
ConcurrentHashMap.TreeNode<K,V> p = this;
do {
int ph, dir; K pk; ConcurrentHashMap.TreeNode<K,V> q;
ConcurrentHashMap.TreeNode<K,V> pl = p.left, pr = p.right;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.findTreeNode(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
}
return null;
}
}
concurrencyLevel,能够同时更新 ConccurentHashMap 且不产生锁竞争的最大线程数,在 JDK 1.8 之前实际上就是 ConcurrentHashMap 中的分段锁个数,即 Segment[] 的数组长度。在 JDK 1.8 里,仅仅是为了兼容旧版本而保留,唯一的作用就是保证构造 map 时初始容量不小于 concurrencyLevel。
1 | public ConcurrentHashMap(int initialCapacity) { |
1 | // node 数组最大容量:2^30=1073741824 |
JDK 1.8 的 ConcurrentHashMap 同样是通过 Key 的哈希值与数组长度取模确定该 Key 在数组中的索引。同样为了避免不太好的 Key 的 hashCode 设计,它通过如下方法计算得到 Key 的最终哈希值。不同的是,JDK 1.8 的 ConcurrentHashMap 作者认为引入红黑树后,即使哈希冲突比较严重,寻址效率也足够高,所以作者并未在哈希值的计算上做过多设计,只是将 Key 的 hashCode 值与其高 16 位作异或并保证最高位为 0(从而保证最终结果为正整数)。
1 | static final int spread(int h) { |
对于写操作,如果 Key 对应的数组元素为 null,则通过 CAS 操作将其设置为当前值。如果 Key 对应的数组元素(也即链表表头或者树的根元素)不为 null,则对该元素使用 synchronized 关键字申请锁,然后进行操作。如果该 put 操作使得当前链表长度超过一定阈值,则将该链表转换为树,从而提高寻址效率。
对于读操作,由于数组被 volatile 关键字修饰,因此不用担心数组的可见性问题。同时每个元素是一个 Node 实例(Java 7 中每个元素是一个 HashEntry),它的 Key 值和 hash 值都由 final 修饰,不可变更,无须关心它们被修改后的可见性问题。而其 Value 及对下一个元素的引用由 volatile 修饰,可见性也有保障。
1 | static class Node<K, V> implements Map.Entry<K, V> { |
对于 Key 对应的数组元素的可见性,由 Unsafe 的 getObjectVolatile 方法保证。它是对 tab[i] 进行原子性的读取,因为我们知道 putVal 等对 table 的桶操作是有加锁的,那么一般情况下我们对桶的读也是要加锁的,但是我们这边为什么不需要加锁呢?因为我们用了 Unsafe[3] 的 getObjectVolatile,因为 table 是 volatile 类型,所以对 tab[i] 的原子请求也是可见的。因为如果同步正确的情况下,根据 happens-before 原则,对 volatile 域的写入操作 happens-before 于每一个后续对同一域的读操作。所以不管其他线程对 table 链表或树的修改,都对 get 读取可见。
1 | // 获得数组中位置i上的节点 |
put 方法和 remove 方法都会通过 addCount 方法维护 Map 的 size。size 方法通过 sumCount 获取由 addCount 方法维护的 Map 的 size。
1 | public int size() { |
该方法的核心思想就是,只允许一个线程对表进行初始化,如果不巧有其他线程进来了,那么会让其他线程交出 CPU 等待下次系统调度。这样,保证了表同时只会被一个线程初始化。
1 | private final Node<K,V>[] initTable() { |
get 方法比较简单,给定一个 key 来确定 value 的时候,必须满足两个条件 key 相同 、hash 值相同,对于节点可能在链表或树上的情况,需要分别去查找。
1 | public V get(Object key) { |
ConcurrentHashMap 的 get 操作的流程很简单,可以分为三个步骤来描述:
假设 table 已经初始化完成,put 操作采用 CAS+synchronized 实现并发插入或更新操作,具体实现如下。
1 | public V put(K key, V value) { |
ConcurrentHashMap 的 put 操作的流程就是对当前的 table 进行无条件自循环直到 put 成功,可以分成以下七步流程来概述:
其实可以看出 JDK 1.8 版本的 ConcurrentHashMap 的数据结构已经接近 HashMap,相对而言,ConcurrentHashMap 只是增加了同步的操作来控制并发,从 JDK 1.7 版本的 ReentrantLock + Segment + HashEntry,到 JDK 1.8 版本中 synchronized + CAS + HashEntry + 红黑树,相对而言,JDK 1.7 中 ConcurrentHashMap 和 JDK 1.8 中 ConcurrentHashMap 的实现区別总结如下:
[1]. Java 进阶(六)从 ConcurrentHashMap 的演进看 Java 多线程核心技术
[2]. ConcurrentHashMap 原理分析(1.7 与 1.8)
[3]. 为并发而生的 ConcurrentHashMap(Java 8)
[1]. 分离锁(lock striping):分拆锁 (lock spliting) 就是若原先的程序中多处逻辑都采用同一个锁,但各个逻辑之间又相互独立,就可以拆 (Spliting) 为使用多个锁,每个锁守护不同的逻辑。分拆锁有时候可以被扩展,分成可大可小加锁块的集合,并且它们归属于相互独立的对象,这样的情况就是分离锁(lock striping)。(摘自《Java 并发编程实践》)
[2]. CAS:CAS 是 compare and swap 的缩写,即我们所说的比较并替换,是用于实现多线程同步的原子指令。CAS 是一种基于锁的操作,是乐观锁。
在 Java 中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,线程一旦得到锁,会导致其它所有需要锁的线程挂起,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加 version 来获取数据,性能较悲观锁有很大的提高。CAS 操作包含三个基本操作数 —— 内存位置(V)、预期原值(A)和新值 (B)。更新一个变量的时候,只有当变量的预期原值(A)和内存位置(V)当中的实际值相同时,才会将内存位置(V)对应的值修改为新值 (B)。CAS 是通过无限循环来获取数据的,若果在第一轮循环中,a 线程获取地址里面的值被 b 线程修改了,那么 a 线程需要自旋,到下次循环才有可能机会执行。CAS 可以有效的提升并发的效率,但同时也会引入 ABA 问题。如线程 1 从内存位置(V)中取出预期原值(A),这时候另一个线程 2 也从内存位置(V)中取出预期原值(A),并且线程 2 进行了一些操作将内存位置(V)中的值变成了新值 (B),然后线程 2 又将内存位置(V)中的数据变成预期原值(A),这时候线程 1 进行 CAS 操作发现内存位置(V)中仍然是预期原值(A),然后线程 1 操作成功。虽然线程 1 的 CAS 操作成功,但是整个过程就是有问题的。比如链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。解决方式,在对象中额外再增加一个标记来标识对象是否有过变更【AtomicMarkableReference(通过引入一个 boolean 来反映中间有没有变过)、AtomicStampedReference(通过引入一个 int 来累加来反映中间有没有变过)】。
[3]. Unsafe:Unsafe 是 Java 留给开发者的后门,用于直接操作系统内存且不受 Jvm 管辖,实现类似 C++ 风格的操作。Java 不能直接访问操作系统底层,而是通过本地方法来访问,Unsafe 类提供了硬件级别的原子操作。Oracle 官方一般不建议开发者使用 Unsafe 类,因为正如这个类的类名一样,它并不安全,使用不当会造成内存泄露。Unsafe 类在 sun.misc 包下,不属于 Java 标准。很多 Java 的基础类库,包括一些被广泛使用的高性能开发库都是基于 Unsafe 类开发,比如 Netty、Hadoop、Kafka 等。
]]>函数式接口 (Functional Interface) 就是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口。函数式接口可以被隐式转换为 lambda 表达式。Java 允许利用 Lambda 表达式创建这些接口的实例。java.util.function 包是 Java 8 增加的一个新技术点 “函数式接口”,此包共有 43 个接口。别指望能够全部记住他们,但是如果能记住其中 6 个基础接口,必要时就可以推断出其余接口了。这些接口是为了使 Lamdba 函数表达式使用的更加简便,当然你也可以自己自定义接口来应用于 Lambda 函数表达式。
JDK 1.8 API 包含了很多内建的函数式接口,比如 Comparator 或者 Runnable 接口,这些接口都增加了 @FunctionalInterface 注解以便能用在 Lamdba 上。现如今,我们则从 Function 常用函数入口,真正了解一下函数式接口。
接口 | 描述 | 函数签名 | 范例 |
---|---|---|---|
UnaryOperator<T> | 接收 T 对象,返回 T 对象 | T apply(T t) | String::toLowerCase |
BinaryOprator<T> | 接收两个 T 对象,返回 T 对象 | T apply(T t1, T t2) | BigInteger::add |
Predicate<T> | 接收 T 对象,返回 boolean | boolean test(T t) | Collection::isEmpty |
Function<T, R> | 接收 T 对象,返回 R 对象 | R apply(T t) | Arrays::asList |
Supplier<T> | 提供 T 对象(例如工厂),不接收值 | T get() | Instant::new |
Consumer<T> | 接收 T 对象,不返回值 | void accept(T t) | System.out::println |
标注为 @FunctionalInterface 的接口被称为函数式接口,该接口有且仅有一个抽象方法,但是可以有多个非抽象方法的接口。是否是一个函数式接口,需要注意的有以下几点:
1 |
|
函数式接口 | 描述 |
---|---|
Consumer<T> | 提供一个 T 类型的输入参数,不返回执行结果 |
BiConsumer<T, U> | 提供两个自定义类型的输入参数,不返回执行结果 |
DoubleConsumer | 提供一个 double 类型的输入参数,不返回执行结果 |
IntConsumer | 提供一个 int 类型的输入参数,不返回执行结果 |
LongConsumer | 提供一个 long 类型的输入参数,不返回执行结果 |
ObjDoubleConsumer<T> | 提供一个 double 类型的输入参数和一个 T 类型的输入参数,不返回执行结果 |
ObjIntConsumer<T> | 提供一个 int 类型的输入参数和一个 T 类型的输入参数,不返回执行结果 |
ObjLongConsumer<T> | 提供一个 long 类型的输入参数和一个 T 类型的输入参数,不返回执行结果 |
(1)作用:消费某个对象
(2)使用场景:Iterable 接口的 forEach 方法需要传入 Consumer,大部分集合类都实现了该接口,用于返回 Iterator 对象进行迭代。
(3)主要方法
方法 | 描述 |
---|---|
void accept(T t) | 对给定的参数执行操作 |
default Consumer<T> andThen(Consumer< ? super T> after) | 返回一个组合函数,after 将会在该函数执行之后应用 |
(4)代码示例
Consumer<T>:提供一个 T 类型的输入参数,不返回执行结果
1 | @Test |
BiConsumer<T, U> :提供两个自定义类型的输入参数,不返回执行结果
1 |
|
DoubleConsumer :提供一个 double 类型的输入参数,不返回执行结果
1 |
|
ObjDoubleConsumer<T> : 提供一个 double 类型的输入参数和一个 T 类型的输入参数,不返回执行结果
1 | @Test |
函数式接口 | 描述 |
---|---|
Predicate<T> | 提供一个 T 类型的输入参数,返回一个 boolean 类型的结果 |
BiPredicate<T,U> | 提供两个自定义类型的输入参数,返回一个 boolean 类型的结果 |
DoublePredicate | 提供一个 double 类型的输入参数,返回一个 boolean 类型的结果 |
IntPredicate | 提供一个 int 类型的输入参数,返回一个 boolean 类型的结果 |
LongPredicate | 提供一个 long 类型的输入参数,返回一个 boolean 类型的结果 |
(1)作用:判断对象是否符合某个条件
(2)使用场景:ArrayList 的 removeIf(Predicate):删除符合条件的元素。如果条件硬编码在 ArrayList 中,它将提供无数的实现,但是如果让调用者传入条件,这样 ArrayList 就可以从复杂和无法猜测的业务中解放出来。
(3)主要方法
方法 | 描述 |
---|---|
boolean test(T t) | 根据给定的参数进行判断 |
Predicate<T> and(Predicate< ? super T> other) | 返回一个组合判断,将 other 以短路并且的方式加入到函数的判断中 |
Predicate<T> or(Predicate< ? super T> other) | 返回一个组合判断,将 other 以短路或的方式加入到函数的判断中 |
Predicate<T> negate() | 将函数的判断取反 |
(4)代码示例
Predicate<T> : 提供一个 T 类型的输入参数,返回一个 boolean 类型的结果
1 | @Test |
函数式接口 | 描述 |
---|---|
Function<T, R> | 提供一个 T 类型的输入参数,返回一个 R 类型的结果 |
BiFunction<T, U, R> | 提供两个自定义类型的输入参数,返回一个 R 类型的结果 |
DoubleFunction<R> | 提供一个 double 类型的输入参数,返回一个 R 类型的结果 |
DoubleToIntFunction | 提供一个 double 类型的输入参数,返回一个 int 类型的结果 |
DoubleToLongFunction | 提供一个 double 类型的输入参数,返回一个 long 类型的结果 |
IntFunction<R> | 提供一个 int 类型的输入参数,返回一个 R 类型的结果 |
IntToDoubleFunction | 提供一个 int 类型的输入参数,返回一个 double 类型的结果 |
IntToLongFunction | 提供一个 int 类型的输入参数,返回一个 long 类型的结果 |
LongFunction<R> | 提供一个 long 类型的输入参数,返回一个 R 类型的结果 |
LongToDoubleFunction | 提供一个 long 类型的输入参数,返回一个 double 类型的结果 |
LongToIntFunction | 提供一个 long 类型的输入参数,返回一个 int 类型的结果 |
ToDoubleBiFunction<T, U> | 提供两个自定义类型的输入参数,返回一个 double 类型的结果 |
ToDoubleFunction<T> | 提供一个 T 类型的输入参数,返回一个 double 类型的结果 |
ToIntBiFunction<T, U> | 提供两个自定义类型的输入参数,返回一个 int 类型的结果 |
ToIntFunction<T> | 提供一个 T 类型的输入参数,返回一个 int 类型的结果 |
ToLongBiFunction<T, U> | 提供两个自定义类型的输入参数,返回一个 long 类型的结果 |
ToLongFunction<T> | 提供一个 T 类型的输入参数,返回一个 long 类型的结果 |
(1)作用:实现一个”一元函数“,即传入一个值经过函数的计算返回另一个值。
(2)使用场景:V HashMap.computeIfAbsent(K , Function<K, V>):如果指定的 key 不存在或相关的 value 为 null 时,设置 key 与关联一个计算出的非 null 值,计算出的值为 null 的话什么也不做(不会去删除相应的 key)。如果 key 存在并且对应 value 不为 null 的话什么也不做。
(3)主要方法
方法 | 描述 |
---|---|
R apply(T t) | 将此参数应用到函数中 |
Function<T, V> andThen(Function< ? super R, ? extends V> after) | 返回一个组合函数,该函数结果应用到 after 函数中 |
Function<V, R> compose(Function< ? super V, ? extends T> before) | 返回一个组合函数,首先将入参应用到 before 函数,再将 before 函数结果应用到该函数中 |
(4)代码示例
Function<T, R> : 提供一个 T 类型的输入参数,返回一个 R 类型的结果
1 |
|
函数式接口 | 描述 |
---|---|
Supplier<T> | 不提供输入参数,返回一个 T 类型的结果 |
BooleanSupplier | 不提供输入参数,返回一个 boolean 类型的结果 |
DoubleSupplier | 不提供输入参数,返回一个 double 类型的结果 |
IntSupplier | 不提供输入参数,返回一个 int 类型的结果 |
LongSupplier | 不提供输入参数,返回一个 long 类型的结果 |
(1)作用:创建一个对象(工厂类)
(2)使用场景:Optional.orElseGet(Supplier< ? extends T>):当 this 对象为 null,就通过传入 supplier 创建一个 T 返回。
(3)主要方法
方法 | 描述 |
---|---|
T get() | 获取结果值 |
(4)代码示例
Supplier<T> : 不提供输入参数,返回一个 T 类型的结果
1 | @Test |
函数式接口 | 描述 |
---|---|
UnaryOperator<T> | 提供一个 T 类型的输入参数,返回一个 T 类型的结果 |
BinaryOperator<T> | 提供两个 T 类型的输入参数,返回一个 T 类型的结果 |
DoubleBinaryOperator | 提供两个 double 类型的输入参数,返回两个 double 类型的结果 |
DoubleUnaryOperator | 提供一个 double 类型的输入参数,返回一个 double 类型的结果 |
IntBinaryOperator | 提供两个 int 类型的输入参数,返回一个 int 类型的结果 |
IntUnaryOperator | 提供一个 int 类型的输入参数,返回一个 int 类型的结果 |
LongBinaryOperator | 提供两个 long 类型的输入参数,返回一个 long 类型的结果 |
LongUnaryOperator | 提供一个 long 类型的输入参数,返回一个 long 类型的结果 |
(1)作用:实现一个”一元函数“,即传入一个值经过函数的计算返回另一个同类型的值。
(2)使用场景:UnaryOperator 继承了 Function,与 Function 作用相同,不过 UnaryOperator,限定了传入类型和返回类型必需相同。
(3)主要方法
方法 | 描述 |
---|---|
T apply(T t) | 将给定参数应用到函数中 |
Function<T, V> andThen(Function< ? super T, ? extends V> after) | 返回一个组合函数,该函数结果应用到 after 函数中 |
Function<V, T> compose(Function< ? super V, ? extends T> before) | 返回一个组合函数,首先将入参应用到 before 函数,再将 before 函数结果应用到该函数中 |
(4)代码示例
UnaryOperator<T> : 提供一个 T 类型的输入参数,返回一个 T 类型的结果
1 | @Test |
BinaryOperator<T> :提供两个 T 类型的输入参数,返回一个 T 类型的结果
1 | @Test |
java.util.function 包已经为大家提供了大量标注的函数接口。只要标准的函数接口能够满足需求,通常应该优先考虑,而不是专门再构建一个新的函数接口。这样会使 API 更加容易学习,通过减少它的概念内容,显著提升互操作性优势,因为许多标准的函数接口都提供了有用的默认方法。
[1]. JDK8 新特性 - java.util.function-Function 接口
[2]. JAVA8 的 java.util.function 包
分布式消息队列中间件是是大型分布式系统不可缺少的中间件,通过消息队列,应用程序可以在不知道彼此位置的情况下独立处理消息,或者在处理消息前不需要等待接收此消息。所以消息队列主要解决应用耦合、异步消息、流量削锋等问题,实现高性能、高可用、可伸缩和最终一致性架构。消息队列已经逐渐成为企业应用系统内部通信的核心手段,当前使用较多的消息队列有 RabbitMQ、RocketMQ、ActiveMQ、Kafka、ZeroMQ、MetaMQ 等,而部分数据库如 Redis、MySQL 以及 PhxSQL 也可实现消息队列的功能。
在日常学习与开发过程中,消息队列作为系统不可缺少的中间件,显得十分的重要。在现代云架构中,应用程序被分解为多个规模较小且更易于开发、部署和维护的独立构建块。消息队列可为这些分布式应用程序提供通信和协调。而本人也在工作的过程中,前前后后后接触到了 Kafka、RabbitMQ 两款消息队列。所以,本系列文章也主要以 RabbitMQ 和 Kafka 两款典型的消息中间件来做分析。本文是该系列的开篇,主要讲解消息队列的概述、特点等,然后对消息队列使用场景进行分析,最后对市面上比较常见的消息队列产品进行技术对比。
消息队列(Message Queue,简称 MQ)是指利用高效可靠的消息传递机制进行与平台无关的数据交流,并基于数据通信来进行分布式系统的集成。通过提供消息传递和消息排队模型,它可以在分布式环境下提供应用解耦、弹性伸缩、冗余存储、流量削峰、异步通信、数据同步等等功能,其作为分布式系统架构中的一个重要组件,有着举足轻重的地位。消息队列是构建分布式互联网应用的基础设施,通过 MQ 实现的松耦合架构设计可以提高系统可用性以及可扩展性,是适用于现代应用的最佳设计方案。
讲解该特点之前,我们先了解一下同步架构和异步架构的区别:
如上图,在不使用消息队列服务器的时候,用户的请求数据直接写入数据库,在高并发的情况下数据库压力剧增,使得响应速度变慢。但是在使用消息队列之后,用户的请求数据发送给消息队列之后立即 返回,再由消息队列的消费者进程从消息队列中获取数据,异步写入数据库。由于消息队列服务器处理速度快于数据库(消息队列也比数据库有更好的伸缩性),因此响应速度得到大幅改善。
通过以上分析我们可以得出消息队列具有很好的削峰作用的功能——即通过异步处理,将短时间高并发产生的事务消息存储在消息队列中,从而削平高峰期的并发事务。 举例:在电子商务一些秒杀、促销活动中,合理使用消息队列可以有效抵御促销活动刚开始大量订单涌入对系统的冲击。如下图所示:
因为用户请求数据写入消息队列之后就立即返回给用户了,但是请求数据在后续的业务校验、写数据库等操作中可能失败。因此使用消息队列进行异步处理之后,需要适当修改业务流程进行配合,比如用户在提交订单之后,订单数据写入消息队列,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功,以免交易纠纷。这就类似我们平时手机订火车票和电影票。
我们知道如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,这样系统的可扩展性无疑更好一些。
我们最常见的事件驱动架构类似生产者消费者模式,在大型网站中通常用利用消息队列实现事件驱动结构。如下图所示:
消息队列使利用发布 - 订阅模式工作,消息发送者(生产者)发布消息,一个或多个消息接受者(消费者)订阅消息。 从上图可以看到消息发送者(生产者)和消息接受者(消费者)之间没有直接耦合,消息发送者将消息发送至分布式消息队列即结束对消息的处理,消息接受者从分布式消息队列获取该消息后进行后续处理,并不需要知道该消息从何而来。对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展性设计。
消息接受者对消息进行过滤、处理、包装后,构造成一个新的消息类型,将消息继续发送出去,等待其他消息接受者订阅该消息。因此基于事件(消息对象)驱动的业务架构可以是一系列流程。
另外为了避免消息队列服务器宕机造成消息丢失,会将成功发送到消息队列的消息存储在消息生产者服务器上,等消息真正被消费者服务器处理后才删除消息。在消息队列服务器宕机后,生产者服务器会选择分布式消息队列服务器集群中的其他服务器发布消息。
Java 消息服务(Java Message Service,JMS)应用程序接口是一个 Java 平台中关于面向消息中间件(MOM)的 API,用于在两个应用程序之间,或分布式系统中发送消息,进行异步通信。JMS 的客户端之间可以通过 JMS 服务进行异步的消息传输。JMS PI 是一个消息服务的标准或者说是规范,允许应用程序组件基于 JavaEE 平台创建、发送、接收和读取消息。它使分布式通信耦合度更低,消息服务更加可靠以及异步性。点对点与发布订阅最初是由 JMS 定义的。这两种模式主要区别或解决的问题就是发送到队列的消息能否重复消费。
JMS 规范目前支持两种消息模型:点对点(point to point,queue)和发布 / 订阅(publish/subscribe,topic)。
消息生产者向消息队列中发送了一个消息之后,只能被一个消费者消费一次。点对点(P2P)使用队列(Queue)作为消息通信载体;满足生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。
Queue 实现了负载均衡,一个消息只能被一个消费者接受,当没有消费者可用时,这个消息会被保存直到有 一个可用的消费者,一个 queue 可以有很多消费者,他们之间实现了负载均衡, 所以 Queue 实现了一个可靠的负载均衡。
特点:
消息生产者向频道发送一个消息之后,多个消费者可以从该频道订阅到这条消息并消费。发布订阅模型(Pub/Sub) 使用主题(Topic)作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者,在一条消息广播之后才订阅的用户则是收不到该条消息的。
Topic 实现了发布和订阅,当你发布一个消息,所有订阅这个 Topic 的服务都能得到这个消息,所以从 1 到 N 个订阅者都能得到一个消息的拷贝, 只有在消息代理收到消息时有一个有效订阅时的订阅者才能得到这个消息的拷贝。
特点:
注意:
- 发布者和订阅者有时间依赖:接受者和发布者只有建立订阅关系才能收到消息;
- 持久订阅:订阅关系建立后,消息就不会消失,不管订阅者是否都在线;
- 非持久订阅:订阅者为了接受消息,必须一直在线。 当只有一个订阅者时约等于点对点模型。
比如说发送邮件这样一个操作。因为发送邮件比较耗时,而且应用程序其实也并不太关心邮件发送是否成功,发送邮件的逻辑也相对比较独立,所以它只需要把邮件消息丢到消息队列中就可以返回了,而消费者也不需要关心是哪个生产者去发送的邮件,它只需要把邮件消息内容取出来以后进行消费,通过远程服务器将邮件发送出去就可以了。而且每个邮件只需要被发送一次。所以消息只被一个消费者消费就可以了。
比如新用户注册,一个新用户注册成功以后,需要给用户发送一封激活邮件,发送一条欢迎短信,还需要将用户注册数据写入数据库,甚至需要将新用户信息发送给关联企业的系统,比如淘宝新用户信息发送给支付宝,这样允许用户可以一次注册就能登录使用多个关联产品。一个新用户注册,会把注册消息发送给一个主题,多种消费者可以订阅这个主题。比如发送邮件的消费者、发送短信的消费者、将注册信息写入数据库的消费者,跨系统同步消息的消费者等。
AMQP(advanced message queuing protocol)在 2003 年时被提出,最早用于解决金融领不同平台之间的消息传递交互问题。顾名思义,AMQP 是一种协议,更准确的说是一种 binary wire-level protocol(链接协议)。这是其和 JMS 的本质差别,AMQP 不从 API 层进行限定,而是直接定义网络交换的数据格式。这使得实现了 AMQP 的 provider 天然性就是跨平台的。意味着我们可以使用 Java 的 AMQP provider,同时使用一个 python 的 producer 加一个 rubby 的 consumer。
在 AMQP 中,消息路由(message routing)和 JMS 存在一些差别,在 AMQP 中增加了 Exchange 和 binding 的角色。producer 将消息发送给 Exchange,binding 决定 Exchange 的消息应该发送到那个 queue,而 consumer 直接从 queue 中消费消息。
AMQP 提供五种消息模型:①Direct Exchange;②Fanout Exchange;③Topic Exchange;④Headers Exchange;⑤System Exchange。本质来讲,后四种和 JMS 的 pub/sub 模型没有太大差别,仅是在路由机制上做了更详细的划分。
总结:
Push 推消息模型:消息生产者将消息发送给消息队列,消息队列又将消息推给消息消费者。
Pull 拉消息模型:消费者请求消息队列接受消息,消息生产者从消息队列中拉该消息。
RabbitMQ 实现了 AQMP 协议,AQMP 协议定义了消息路由规则和方式。生产端通过路由规则发送消息到不同 queue,消费端根据 queue 名称消费消息。此外 RabbitMQ 是向消费端推送消息,订阅关系和消费状态保存在服务端。
Kafka 只支持消息持久化,消费端为拉模型,消费状态和订阅关系由客户端端负责维护,消息消费完后不会立即删除,会保留历史消息。因此支持多订阅时,消息只会存储一份就可以了。同一个订阅组会消费 topic 所有消息,每条消息只会被同一个订阅组的一个消费节点消费,同一个订阅组内不同消费节点会消费不同消息。
对一些比较耗时的操作,可以把处理过程通过消息队列进行异步处理。这样做可以推迟耗时操作的处理,使耗时操作异步化,而不必阻塞客户端的程序,客户端的程序在得到处理结果之前就可以继续执行,从而提高客户端程序的处理性能。非核心流程异步化,减少系统响应时间,提高吞吐量。
例如:短信通知、终端状态推送、App 推送、用户注册等。
可以多个生产者发布消息,多个消费者处理消息,共同完成完整的业务处理逻辑,但是它们的不需要直接的交互调用,没有代码的依赖耦合。在传统的同步调用中,调用者代码必须要依赖被调用者的代码,也就是生产者代码必须要依赖消费者的处理逻辑代码,代码需要直接的耦合,而使用消息队列,这两部分的代码不需要进行任何的耦合。因为耦合程度越低的代码越容易维护,也越容易进行扩展。
比如新用户注册,如果用传统同步调用的方式,那么发邮件、发短信、写数据库、通知关联系统这些代码会和用户注册代码直接耦合起来,整个代码看起来就是完成用户注册逻辑后,后面必然跟着发邮件、发短信这些代码。如果要新增一个功能,比如将监控用户注册情况,将注册信息发送到业务监控系统,就必须要修改前面的代码,至少增加一行代码,发送注册信息到监控系统,我们知道,任何代码的修改都可能会引起 bug。
而使用分布式消息队列实现生产者和消费者解耦合以后,用户注册以后,不需要调用任何后续处理代码,只需要将注册消息发送到分布式消息队列就可以了。如果要增加新功能,只需要写个新功能的消费者程序,在分布式消息队列中,订阅用户注册主题就可以了,不需要修改原来任何一行代码。
当上下游系统处理能力存在差距的时候,利用消息队列做一个通用的 “漏斗”,进行限流控制。在下游有能力处理的时候,再进行分发。
使用消息队列,即便是访问流量持续的增长,系统依然可以持续地接收请求。这种情况下,虽然生产者发布消息的速度比消费者消费消息的速度快,但是可以持续的将消息纳入到消息队列中,用消息队列作为消息的缓冲,因此短时间内,发布者不会受到消费处理能力的影响。
在访问高峰,用户的并发访问数可能超过了系统的处理能力,所以在高峰期就可能会导致系统负载过大,响应速度变慢,更严重的可能会导致系统崩溃。这种情况下,通过消息队列将用户请求的消息纳入到消息队列中,通过消息队列缓冲消费者处理消息的速度。
消息的生产者它有高峰有低谷,但是到了消费者这里,只会按照自己的最佳处理能力去消费消息。高峰期它会把消息缓冲在消息队列中,而在低谷期它也还是使用自己最大的处理能力去获取消息,将前面缓冲起来、来不及及时处理的消息处理掉。那么,通过这种手段可以实现系统负载消峰填谷,也就是说将访问的高峰消掉,而将访问的低谷填平,使系统处在一个最佳的处理状态之下,不会对系统的负载产生太大的冲击。
举个例子:用户在支付系统成功结账后,订单系统会通过短信系统向用户推送扣费通知。短信系统可能由于短板效应,速度卡在网关上(每秒几百次请求),跟前端的并发量不是一个数量级。于是,就造成支付系统和短信系统的处理能力出现差异化。
然而用户晚上个半分钟左右收到短信,一般是不会有太大问题的。如果没有消息队列,两个系统之间通过协商、滑动窗口等复杂的方案也不是说不能实现。但系统复杂性指数级增长,势必在上游或者下游做存储,并且要处理定时、拥塞等一系列问题。而且每当有处理能力有差距的时候,都需要单独开发一套逻辑来维护这套逻辑。所以,利用中间系统转储两个系统的通信内容,并在下游系统有能力处理这些消息的时候,再处理这些消息,是一套相对较通用的方式。
耗时的任务可以通过分布式消息队列,向多台消费者服务器并行发送消息,然后在很多台消费者服务器上并行处理消息,也就是说可以在多台物理服务器上运行消费者。那么当负载上升的时候,可以很容易地添加更多的机器成为消费者。
例如:用户上传文件后,通过发布消息的方式,通知后端的消费者获取数据、读取文件,进行异步的文件处理操作。那么当前端发布更多文件的时候,或者处理逻辑比较复杂的时候,就可以通过添加后端的消费者服务器,提供更强大的处理能力。
因为发布者不直接依赖消费者,所以分布式消息队列可以将消费者系统产生的错误异常与生产者系统隔离开来,生产者不受消费者失败的影响。当在消息消费过程中出现处理逻辑失败的时候,这个错误只会影响到消费者自身,而不会传递给消息的生产者,也就是应用程序可以按照原来的处理逻辑继续执行。
所以,这也就意味着在任何时候都可以对后端的服务器执行维护和发布操作。可以重启、添加或删除服务器,而不影响生产者的可用性,这样简化了部署和服务器管理的难度。
日志处理是指将消息队列用在日志处理中,比如 Kafka 的应用,解决大量日志传输和缓冲的问题。日志采集客户端,负责日志数据采集,定时写受写入 Kafka 队列;Kafka 消息队列,负责日志数据的接收,存储和转发;日志处理应用,订阅并消费 kafka 队列中的日志数据。
ActiveMQ 是 Apache 出品的、采用 Java 语言编写的完全基于 JMS1.1 规范的面向消息的中间件,为应用程序提供高效的、可扩展的、稳定的和安全的企业级消息通信。不过由于历史原因包袱太重,目前市场份额没有后面三种消息中间件多,其最新架构被命名为 Apollo,号称下一代 ActiveMQ,有兴趣的同学可行了解。
RabbitMQ 是采用 Erlang 语言实现的 AMQP 协议的消息中间件,最初起源于金融系统,用于在分布式系统中存储转发消息。RabbitMQ 发展到今天,被越来越多的人认可,这和它在可靠性、可用性、扩展性、功能丰富等方面的卓越表现是分不开的。主要特点是性能好,社区活跃,但是 RabbitMQ 用 Erlang 开发,我们的应用很少用 Erlang,所以不便于二次开发和维护。
Kafka 是由 LinkedIn 公司采用 Scala 语言开发的一个分布式、多分区、多副本且基于 zookeeper 协调的分布式消息系统,现已捐献给 Apache 基金会。它是一种高吞吐量的分布式发布订阅消息系统,以可水平扩展和高吞吐率而被广泛使用。目前越来越多的开源分布式处理系统如 Cloudera、Apache Storm、Spark、Flink 等都支持与 Kafka 集成。
RocketMQ 是阿里开源的消息中间件,目前在 Apache 孵化,使用纯 Java 开发,具有高吞吐量、高可用性、适合大规模分布式系统应用的特点。RocketMQ 思路起源于 Kafka,但并不是简单的复制,它对消息的可靠传输及事务性做了优化,目前在阿里集团被广泛应用于交易、充值、流计算、消息推送、日志流式处理、binglog 分发等场景,支撑了阿里多次双十一活动。
ZeroMQ 是基于 C 语言开发,号称史上最快的消息队列。ZeroMQ 是一个消息处理队列库,可在多线程、多内核和主机之间弹性伸缩,虽然大多数时候我们习惯将其归入消息队列家族之中,但是其和前面的几款有着本质的区别,ZeroMQ 本身就不是一个消息队列服务器,更像是一组底层网络通讯库,对原有的 Socket API 上加上一层封装而已。
总结:
[1]. 浅谈消息队列及常见的消息中间件
[2]. 消息中间件选型分析
[3]. 新手也能看懂,消息队列其实很简单
[4]. 10 分钟搞懂:95% 的程序员都拎不清的分布式消息队列中间件
Java 并发编程是整个 Java 开发体系中最难以理解但也是最重要的知识点,也是各类开源分布式框架(如 ZooKeeper、Kafka、Spring Cloud、Netty 等)中各个并发组件实现的基础。J.U.C 并发包,即 java.util.concurrent 包,大大提高了并发性能,是 JDK 的核心工具包,是 JDK 1.5 之后,由 Doug Lea 实现并引入。而 AQS 被认为是 J.U.C 的核心。
AQS 是一个抽象类,并没有对并发类提供了一个统一的接口定义,而是由子类根据自身的情况实现相应的方法,AQS 中一般包含两个方法 acquire(int)、release(int),获取同步状态和释放同步状态,AQS 根据其状态是否独占分为独占模式和共享模式。
同步器根据同步状态分为独占模式和共享模式,独占模式包括类:ReentrantLock、ReentrantReadWriteLock.WriteLock,共享模式包括:Semaphore、CountDownLatch、ReentrantReadWriteLock.ReadLock,本文将着重介绍一下 java.util.concurrent 包下一些辅助同步器类:CountDownLatch、CyclicBarrier、Semaphore、Exchanger、Phaser。
CountDownLatch 是一个同步辅助工具类,通过它可以完成类似于阻塞当前线程的功能,也就是一个或多个线程一直等待直到其他线程执行完成。即允许一个或多个线程一直等待,直到其他线程执行完后再执行。例如,应用程序的主线程希望在负责启动框架服务的线程已经启动所有框架服务之后执行。
CountDownLatch 用了一个给定的计数器 cnt 来进行初始化,该计数器的操作是原子操作,即同时只能有一个线程操作该计数器,调用该类 await 方法的线程会一直处于阻塞状态,直到其他线程调用 countDown 方法时计数器的值变成 0,每次调用 countDown 时计数器的值会减 1,当计数器的值为 0 时所有因 await 方法而处于等待状态的线程就会继续执行。计数器 cnt 是闭锁需要等待的线程数量,只能被设置一次,且 CountDownLatch 没有提供任何机制去重新设置计数器 count,如果需要重置,可以考虑使用 CyclicBarrier。
(1)开启多个线程分块下载一个大文件,每个线程只下载固定的一截,最后由另外一个线程来拼接所有的分段。
(2)应用程序的主线程希望在负责启动框架服务的线程已经启动所有的框架服务之后再执行。
(3)确保一个计算不会执行,直到所需要的资源被初始化。
(4)并行计算,处理量很大时可以将运算任务拆分成多个子任务,当所有子任务都完成之后,父任务再将所有子任务都结果进行汇总。
CountDownLatch 内部依赖 Sync 实现,而 Sync 继承 AQS。CountDownLatch 关键接口如下:
(1)作为一个开关 / 入口
将初始计数值为 1 的 CountDownLatch 作为一个的开关或入口,在调用 countDown() 的线程打开入口前,所有调用 await 的线程都一直在入口处等待。
1 | public class Driver { |
(2)作为一个完成信号
将初始计数值为 N 的 CountDownLatch 作为一个完成信号点,使某个线程在其它 N 个线程完成某项操作之前一直等待。
1 | public class Driver { |
CyclicBarrier 和 CountDownLatch 是非常类似的,CyclicBarrier 核心的概念是在于设置一个等待线程的数量边界,到达了此边界之后进行执行。CyclicBarrier 也是一个同步辅助工具类,它允许一组线程相互等待直到到达某个公共的屏障点(Common Barrier Point),通过它可以完成多个线程之间相互等待时,只有当每个线程都准备就绪后才能各自继续执行后面的操作。
CyclicBarrier 也是通过计数器来实现,当某个线程调用 await 方法后就进入等待状态,计数器执行加一操作。当计数器的值达到了设置的初始值时等待状态的线程会被唤醒继续执行。通过调用 CyclicBarrier 对象的 await() 方法,两个线程可以实现互相等待。一旦 N 个线程在等待 CyclicBarrier 达成,所有线程将被释放掉去继续执行。由于 CyclicBarrier 在释放等待线程后可以重用,所以可以称之为循环栅栏。
CyclicBarrier 特别适用于并行迭代计算,每个线程负责一部分计算,然后在栅栏处等待其他线程完成,所有线程到齐后,交换数据和计算结果,再进行下一次迭代。
CyclicBarrier 并没有自己去实现 AQS 框架的 API,而是利用了 ReentrantLock 和 Condition。
CyclicBarrier 提供的关键方法如下:
CyclicBarrier 提供的两个构造函数:
(1)简单例子
1 | public class Solver { |
(2)执行 barrierAction
在 ready 状态时日志是每秒输出一条,当有 5 条 ready 时会一次性输出 5 条 continue。这就是前面讲的全部线程准备就绪后同时开始执行。在初始化 CyclicBarrier 时还可以在等待线程数后指定一个 runnable,含义是当线程到达这个屏障时优先执行这里的 runnable。
1 | public class Solver { |
CyclicBarrier 与 CountDownLatch 可能容易混淆,我们强调下其区别:
CountDownLatch 的参与线程是有不同角色的,有的负责倒计时,有的在等待倒计时变为 0,负责倒计时和等待倒计时的线程都可以有多个,它用于不同角色线程间的同步。
CyclicBarrier 的参与线程角色是一样的,用于同一角色线程间的协调一致。
CountDownLatch 是一次性的,而 CyclicBarrier 是可以重复利用的。
Semaphore,又名信号量,这个类的作用有点类似于 “许可证”。信号量 Semaphore 是一个控制访问多个共享资源的计数器,和 CountDownLatch 一样,其本质上是一个 “共享锁”。从源码角度来看,Semaphore 的实现方式和 CountDownLatch 非常相似,基于 AQS 做了一些定制。通过维持 AQS 的锁全局计数 state 字段来实现定量锁的加锁和解锁操作。Semaphore 通常用于限制可以访问某些资源(物理或逻辑的)的线程数目。
有时,我们因为一些原因需要控制同时访问共享资源的最大线程数量,比如出于系统性能的考虑需要限流,或者共享资源是稀缺资源,我们需要有一种办法能够协调各个线程,以保证合理的使用公共资源。当有线程想要访问共享资源时,需要先获取 (acquire) 的许可;如果许可不够了,线程需要一直等待,直到许可可用。当线程使用完共享资源后,可以归还 (release) 许可,以供其它需要的线程使用;然而,实际上并没有真实的许可证对象供线程使用,Semaphore 只是对可用的数量进行管理维护。
Semaphore 可以用于做流量控制,特别公用资源有限的应用场景,比如数据库连接。
Semaphore 内部包含公平锁(FairSync)和非公平锁(NonfairSync),继承内部类 Sync,其中 Sync 继承 AQS,作为 Semaphore 的公平锁和非公平锁的基类。
CyclicBarrier 提供的关键方法如下:
Semaphore 提供了两个构造函数:
1 | public class SemaphoreExample { |
Exchanger(交换器)是一个用于线程间协作的工具类,是 JDK 1.5 开始提供的一个用于两个工作线程之间交换数据的封装工具类。Exchanger 有点类似于 CyclicBarrier,我们知道 CyclicBarrier 是一个栅栏,到达栅栏的线程需要等待其它一定数量的线程到达后,才能通过栅栏,Exchanger 可以看成是一个双向栅栏。它提供一个同步点,在这个同步点两个线程可以交换彼此的数据。
可简单地将 Exchanger 对象理解为一个包含两个格子的容器,通过 exchanger 方法可以向两个格子中填充信息。当两个格子中的均被填充时,该对象会自动将两个格子的信息交换,然后返回给线程,从而实现两个线程的信息交换。这两个线程通过 exchange 方法交换数据,如果第一个线程先执行 exchange 方法,它会一直等待第二个线程也执行 exchange,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。
Exchanger 是最简单的也是最复杂的,简单在于 API 非常简单,就一个构造方法和两个 exchange() 方法,最复杂在于它的实现是最复杂的。
Exchanger 提供的关键方法如下:
可以看出,当一个线程到达 exchange 调用点时,如果其他线程此前已经调用了此方法,则其他线程会被调度唤醒并与之进行对象交换,然后各自返回;如果其他线程还没到达交换点,则当前线程会被挂起,直至其他线程到达才会完成交换并正常返回,或者当前线程被中断或超时返回。
1 | public class ExchangerExample { |
CountDownLatch 和 CyclicBarrier 都是 JDK 1.5 引入的,而 Phaser 是 JDK 1.7 引入的。Phaser 的功能与 CountDownLatch 和 CyclicBarrier 有部分重叠,它几乎可以取代 CountDownLatch 和 CyclicBarrier, 其功能更灵活,更强大,支持动态调整需要控制的线程数。
CountDownLatch,闭锁,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待,它提供了 await()、countDown() 两个方法来进行操作;CyclicBarrier,循环栅栏,允许一组线程互相等待,直到到达某个公共屏障点,它提供的 await() 可以实现让所有参与者在临界点到来之前一直处于等待状态;Phaser,多阶段栅栏,它把多个线程协作执行的任务划分为多个阶段,编程时需要明确各个阶段的任务,每个阶段都可以有任意个参与者,线程都可以随时注册并参与到某个阶段,当到达的参与者数量满足栅栏设定的数量后,会进行阶段升级(advance)。
Phaser 顾名思义,与阶段相关。Phaser 比较适合这样一种场景,一种任务可以分为多个阶段,现希望多个线程去处理该批任务,对于每个阶段,多个线程可以并发进行,但是希望保证只有前面一个阶段的任务完成之后才能开始后面的任务。这种场景可以使用多个 CyclicBarrier 来实现,每个 CyclicBarrier 负责等待一个阶段的任务全部完成。但是使用 CyclicBarrier 的缺点在于,需要明确知道总共有多少个阶段,同时并行的任务数需要提前预定义好,且无法动态修改。而 Phaser 可同时解决这两个问题。
Phaser 主要接口如下:
Phaser 提供的关键方法如下:
(1)通过 Phaser 实现 CyclicBarrier 控制多个线程的执行时机的功能
通过 Phaser 控制多个线程的执行时机:有时候我们希望所有线程到达指定点后再同时开始执行,我们可以利用 CyclicBarrier 来实现,这里给出使用 Phaser 的版本。
1 | public class PhaserExample { |
(2)通过 Phaser 实现 CyclicBarrier 执行 barrierAction
CyclicBarrier 支持 barrier action, Phaser 同样也支持。不同之处是 Phaser 的 barrier action 需要改写 onAdvance 方法来进行定制。
1 | public class PhaserExample { |
(3)通过 Phaser 实现 CountDownLatch 作为一个开关 / 入口功能
1 | public class PhaserExample { |
(4)通过 Phaser 实现分层
1 | public class PhaserExample { |
[1]. 【并发编程】J.U.C 之 AQS 介绍、实现及其子类使用演示
[2]. Java 进阶(四)线程间通信剖析
[3]. 透彻理解 Java 并发编程
[4]. 死磕 Java 并发
索引类似大学图书馆建书目索引,可以提高数据检索的效率,降低数据库的 IO 成本。MySQL 在 300w 条记录左右性能开始逐渐下降,虽然官方文档说 500~800w 记录,所以大数据量建立索引是非常有必要的。MySQL 提供了 EXPLAIN,用于显示 SQL 执行的详细信息,可以进行索引的优化。使用 EXPLAIN 关键字可以模拟优化器执行 SQL 查询语句,从而知道 MySQL 是如何处理你的 SQL 语句的,分析你的查询语句或是表结构的性能瓶颈。 可以帮助选择更好的索引和写出更优化的查询语句。
本章首先介绍如何通过存储过程随机生成大量随机数据作为 EXPLIAN 的测试数据,然后通过例子详解 EXPLIAN 用法以及各字段含义,最后对 EXPLIAN 用途进行总结。
EXPLAIN 命令是查看查询优化器如何决定执行查询的主要方法,使用 EXPLAIN,只需要在查询中的 SELECT 关键字之前增加 EXPLAIN 这个词即可,MYSQL 会在查询上设置一个标记,当执行查询时,这个标记会使其返回关于在执行计划中每一步的信息,而不是执行它,它会返回一行或多行信息,显示出执行计划中的每一部分和执行的次序,从而可以从分析结果中找到查询语句或是表结构的性能瓶颈。
通过 EXPLAIN,我们可以分析出以下结果:
利用 MySQL 内存表插入速度快的特点,先利用函数和存储过程在内存表中生成数据,然后再从内存表插入普通表中。
(1)登录 MySQL
1 | # 1. 连接到远程主机上的 MySQL |
(2)创建内存表
如果一条一条插入普通表的话,效率太低下,但内存表插入速度是很快的,可以先建立一张内存表,插入数据后,在导入到普通表中。
1 | DROP TABLE IF EXISTS `big_data_user_memory`; |
(3)创建普通表
创建普通表,参数设置和内存表相同,否则从内存表往普通标导入数据会报错。
1 | DROP TABLE IF EXISTS `big_data_user`; |
(4)创建存储函数
1 | -- 生成随机 UserId |
1 |
|
1 | # 生成随机手机号 |
1 | # 生成随机'yyyy-MM-dd'至'yyyy-MM-dd'时间 |
(5)创建存储过程
1 | CREATE DEFINER=`root`@`localhost` PROCEDURE `generateBigDataUser`(IN num INT) |
(6)调用存储过程
1 | CALL generateBigDataUser(1000000); |
在调用存储过程的过程中内存表大小的问题抛出 “The table ‘big_data_memory’ is full”,这是就需要我们修改一下 MySQL 的配置信息。
1 | # 1. 查看 tmp_table_size 大小, tmp_table_size: 控制内存临时表的最大值, 超过限值后就往硬盘写, 写的位置由变量 tmpdir 决定 |
(7)将内存表中的数据导入普通表
1 | mysql> INSERT INTO big_data_user SELECT * FROM big_data_user_memory; |
以上,我们通过存储过程快速产生百万条随机测试数据的工作就大功告成了。接下来,我们将用我们产生的数据为基础详解 EXPLIAN 用法以及各字段含义。
(8)准备关联查询数据
1 | CREATE TABLE `big_data_group` ( |
1 | CREATE DEFINER=`root`@`localhost` PROCEDURE `generateBigDataGroup`(IN num INT) |
1 | mysql> CALL generateBigDataGroup(100) |
EXPLIAN 模拟优化器执行 SQL 语句,在 5.6 以及以后的版本中,除过 SELECT,其他比如 INSERT,UPDATE 和 DELETE 均可以使用 EXPLIAN 查看执行计划,从而知道 MySQL 是如何处理 SQL 语句,分析查询语句或者表结构的性能瓶颈。
本次 EXPLIAN 以根据手机号码过滤测试数据中手机号码重复的、保留 ID 最小数据的滤重 SQL 语句为例子。
1 | EXPLAIN |
EXPLIAN 出来的信息有 12 列,分别是 id、select_type、table、partitions、type、possible_keys、key、key_len、ref、rows、filtered、Extra
查询标识,表示 SQL 语句中执行 SELECT 子句或者是操作的顺序。
查询类型,主要是用于区分普通查询、联合查询、子查询等复杂的查询。
1 | EXPLAIN SELECT * FROM big_data_user WHERE user_id='Jt2BHyxQqsPBoZAO9adp'; |
1 | EXPLAIN SELECT *, (SELECT group_code FROM big_data_group WHERE id=group_id) AS group_code FROM big_data_user WHERE user_id='Jt2BHyxQqsPBoZAO9adp'; |
1 | EXPLAIN SELECT * FROM big_data_user WHERE group_id = (SELECT id FROM big_data_group WHERE group_code='cqlhc1nBKNAlOTQ'); |
1 | EXPLAIN SELECT *, (SELECT group_code FROM big_data_group WHERE id=group_id) AS group_code FROM big_data_user WHERE user_id='Jt2BHyxQqsPBoZAO9adp'; |
1 | EXPLAIN SELECT * FROM (SELECT * FROM big_data_user LIMIT 5) AS bdu |
1 | EXPLAIN SELECT * FROM big_data_user WHERE user_id = 'Jt2BHyxQqsPBoZAO9adp' UNION SELECT * FROM big_data_user WHERE phone = '13982711661'; |
1 | EXPLAIN SELECT * FROM big_data_user WHERE user_id = 'Jt2BHyxQqsPBoZAO9adp' UNION SELECT * FROM big_data_user WHERE phone = '13982711661'; |
查询涉及表,显示这一行的数据是关于哪张表的。这也可以是下列值之一:
匹配到的分区信息,由查询匹配记录的分区。对于非分区表,值为 NULL。
连接类型,对表访问方式,表示 MySQL 在表中找到所需行的方式,又称 “访问类型”。常用的类型有: ALL、index、range、 ref、eq_ref、const、system、NULL(从左到右,性能从差到好)。SQL 性能优化的目标:至少要达到 range 级别,要求是 ref 级别,如果可以是 consts
最好。
1 | EXPLAIN SELECT * FROM big_data_user WHERE id = 1; |
1 | EXPLAIN SELECT * FROM big_data_user bdu LEFT JOIN big_data_group bdg ON bdu.group_id = bdg.id; |
1 | EXPLAIN SELECT * FROM big_data_user WHERE user_id='Jt2BHyxQqsPBoZAO9adp'; |
1 | EXPLAIN SELECT * FROM big_data_user WHERE id BETWEEN 2 AND 8; |
1 | EXPLAIN SELECT id FROM big_data_user; |
1 | EXPLAIN SELECT * FROM big_data_user; |
可能选择的索引,它表示 MySQL 在查询时,可能使用到的索引。 注意,即使有些索引在 possible_keys 中出现,但是并不表示此索引会真正地被 MySQL 使用到。 MySQL 在查询时具体使用了哪些索引,由 key 字段决定。
实际使用的索引,实际使用的索引,如果为null,则没有使用索引,因此会出现possible_keys列有可能被用到的索引,但是key列为null,表示实际没用索引。
实际使用的索引的长度,表示索引中使用的字节数,而通过该列计算查询中使用的索引长度,在不损失精确性的情况下,长度越短越好,key_len显示的值为索引字段的最大可能长度,并非实际使用长度,即key_len是根据表定义计算而得而不是通过表内检索出的。
和索引进行比较的列,表示哪些列或常量与键列中命名的索引相比较,以从表中选择行。
1 | EXPLAIN SELECT * FROM big_data_user bdu LEFT JOIN big_data_group bdg ON bdu.group_id = bdg.id; |
需要被检索的大致行数,根据表统计信息及索引选用情况,大致估算出找到所需的记录所需要读取的行数。
按表条件过滤的行百分比,该列表示将被表条件过滤的表行的估计百分比。 最大值为100,这意味着没有发生行过滤。值从100下降表明过滤量增加。
额外信息,不适合在其他字段中显示,但是十分重要的额外信息。
1 | EXPLAIN |
想要优化 SQL,必须清楚知道 SQL 的执行顺序,这样再配合 explain 才能事半功倍!
完整 SQL 语句:
1 | select distinct |
SQL 执行顺序:
1、from <left_table><join_type>
2、on <join_condition>
3、<join_type> join <right_table>
4、where <where_condition>
5、group by <group_by_list>
6、having <having_condition>
7、select
8、distinct <select_list>
9、order by <order_by_condition>
10、limit <limit_number>
我们使用 EXPLAIN 解析 SQL 执行计划时,如果有下面几种情况,就需要特别关注下了:
[1]. MySQL 的索引是什么?怎么优化?
[2]. EXPLAIN Output Format
[3]. MySQL 快速生成 100W 条测试数据
[4]. MySQL EXPLAIN 详解
[5]. MySQL 高级 之 explain 执行计划详解
经过 Redis 深度探险系列的学习相信大家对 Redis 的数据结构、对象、持久化机制、过期键删除策略等知识有了大致的了解,本篇博文主要讲述 Redis 的安装步骤,然后介绍一下 Redis 配置说明,最后对 Redis 集群搭建进行详细的讲解。
Mac 中使用 brew 安装 Redis 的方法
1 | $ brew install redis |
Ubuntu 中使用 apt-get 安装 Redis 的方法
1 | $ sudo apt-get install redis-server |
CentOS7 中使用 yum 安装 Redis 的方法
1 | // 通过 yum 3.2.12 redis 可知 Centos7 中 Redis 源的版本为 |
1 | $ yum install -y redis |
1 | $ systemctl start redis |
1 | $ systemctl status redis |
1 | $ ps -ef | grep redis |
1 | // 安装 Remi 的软件源 |
1 | $ systemctl enable redis.service |
修改 /etc/redis.conf 配置文件,执行 systemctl restart redis 重启 Redis 即生效。
修改 /etc/redis.conf 配置文件,在 requirepass foobared 前面去掉注释,将 foobared 改为自己的密码,我在这里改为 requirepass foobared,执行 systemctl restart redis 重启 Redis 即生效。
1 | // 通过 redis-cli 连接 redis 客户端,使用 AUTH 进行认证 |
Redis 配置文件 redis.conf 以及Redis 配置文件 redis.conf 中文详解:
1 |
|
[1]. Redis 配置文件 redis.conf
[2]. Redis 命令
[3]. 阿里云 Redis 开发规范
当在团队开发中使用版本控制系统时,商定一个统一的工作流程是至关重要的。Git 的确可以在各个方面做很多事情,然而,如果在你的团队中还没有能形成一个特定有效的工作流程,那么混乱就将是不可避免的。基本上你可以定义一个完全适合你自己项目的工作流程,或者使用一个别人定义好的。就像代码需要代码规范一样,代码管理同样需要一个清晰的流程和规范。
Git flow 工作流是经典模型,体现了工作流的经验和精髓。随着项目过程复杂化,会感受到这个工作流中深思熟虑和威力!Git flow 工作流没有用超出功能分支工作流的概念和命令,而是为不同的分支分配一个很明确的角色,并定义分支之间如何和什么时候进行交互。在这章节中我们将一起学习一个当前非常流行的工作流 Git flow。
虽然有这么优秀的版本管理工具,但是我们面对版本管理的时候,依然有非常大得挑战,我们都知道大家工作在同一个仓库上,那么彼此的代码协作必然带来很多问题和挑战,如下:
大部分开发人员现在使用 Git 就只是用三个甚至两个分支,一个是 Master, 一个是 Develop, 还有一个是基于 Develop 打得各种分支。这个在小项目规模的时候还勉强可以支撑,因为很多人做项目就只有一个 Release, 但是人员一多,而且项目周期一长就会出现各种问题。
荷兰程序员 Vincent Driessen 曾发表了一篇博客 A Successful Git Branching Model,让一个分支策略广为人知。
下面是 Git flow 的流程图:
这一流程最大的亮点是考虑了紧急 Bug 的应对措施,整个流程显得过于复杂,所以在实施该方案前,需要对整个开发流程进行系统的学习。也需要借助 Git flow 等工具的辅助。
工具 Git-flow 是按照 Vincent Driessen 的 branch 模型,实现的一个高层次(级别)的 git 仓库操作扩展集合。
Ubuntu 中使用 apt-get 安装 Git flow 的方法
1 | $ sudo apt-get install git-flow |
CentOS7 中使用 wget 安装 Git flow 的方法
1 | $ wget --no-check-certificate -q https://raw.githubusercontent.com/petervanderdoes/gitflow-avh/develop/contrib/gitflow-installer.sh && sudo bash gitflow-installer.sh install develop; rm gitflow-installer.sh |
Mac 中使用 brew 安装 Git flow 的方法
1 | $ brew install git-flow-avh |
Windows 中使用 wget 安装 Git flow 的方法
1 | $ wget -q -O - --no-check-certificate https://raw.github.com/petervanderdoes/gitflow-avh/develop/contrib/gitflow-installer.sh install stable | bash |
回答几个关于分支的命名约定的问题,建议使用默认值。
1 | $ git flow init |
Git flow 工作流仍然用中央仓库作为所有开发者的交互中心。和其它的工作流一样,开发者在本地工作并 push 分支到要中央仓库中。
功能分支:通常为即将发布或者未来发布版开发新的功能,这通常只存在开发者的库中。当新功能开始研发,包含该功能的发布版本在这个还是无法确定发布时间的。功能版本的实质是只要这个功能处于开发状态它就会存在,但是最终会或合并到 develop 分支(确定将新功能添加到不久的发布版中)或取消(譬如一次令人失望的测试)。
分支命名规则:分支名称以 feature/* 开头
1 | # 1. 开始一项功能的开发工作时, 基于'develop'创建分支 |
–no-ff 标志导致合并操作创建一个新 commit 对象,即使该合并操作可以 fast-forward。这避免了丢失这个功能分支存在的历史信息,将该功能的所有提交组合在一起。
1 | # 1. 增加新特性 P.S.[创建了一个基于'develop'的功能分支'some-feature', 并切换到这个分支之下] |
1 | # 1. 发布新特性 P.S.[发布新特性分支到远程服务器, 所以, 其它用户也可以使用这分支] |
热修复分支:热修复分支与发布分支很相似,他们都为新的生成环境发布做准备,尽管这是未经计划的。他们来自生产环境的处于异常状态压力。当生成环境验证缺陷必须马上修复是,热修复分支可以基于 master 分支上对应与线上版本的 tag 创建。
分支命名规则:分支名称以 hotfix/* 开头
1 | 1. 基于'master'创建热修复分支 |
1 | 1. 开始 git flow 紧急修复 P.S.[创建了一个基于'master'的热修复分支, 并切换到这个分支之下] |
发布分支:Release 分支是为新产品的发布做准备的,它允许我们在最后时刻做一些细小的修改,它们允许小 bugs 的修改和准备发布元数据(版本号,开发时间等等)。Release 分支基于 develop 分支创建; 一旦创建了 release 分支,不能在从 develop 分支合并新的改动到 release 分支,可以基于 release 分支进行测试和 bug 修改,测试不用再另外创建用于测试的分支。
分支命名规则:分支名称以 release/* 开头
1 | 1. 基于'develop'创建发布分支, 在此分支上小 bugs 的修改和准备发布元数据 |
1 | # 1. 开始准备 release 版本 P.S.[创建了一个基于'develop'的热修复分支, 并切换到这个分支之下] |
Pull request 是 github/bitbucket 给开发人员实现便利合作提供的一个 feature。他们提供一个用户友好的 web 界面在进代码之前来讨论这些变更。
简单说,Pull request 是一种为了开发人员通知 team member 他们已经完成了一个 feature 的机制。一旦他们的 feature branch ready 了,开发人员就通过他们的 github 帐号执行一个 pull request。这将使得每个相干人知晓这个事件,他们需要 review 这个 feature branch 的代码,并且需要决定是否 merge 到 master 分支上去。
但是 pull request 并不仅仅是一种 notification, 他也是一个专门用于讨论这些即将落地代码的细节的论坛。如果有任何问题或意见,同事们可以在 pull request 中提 comments,甚至直接在这个 Pull request 中修改要落地的代码。所有这些活动都由 pull request 来跟踪。
下面通过例子介绍:热修复分支 - Hotfix 分支 Git flow 工作流和 Pull Request
1 | # 1. 'master'创建'hotfix'分支并发布'hotfix'分支到远程服务器 |
[1]. 「译」浅谈 Gitflow
[2]. git-flow 备忘清单
[3]. 图解 Git
博客《Java 并发编程之美(四):深入剖析 ThreadLocal》提到 ThreadLocal 变量的基本使用方式,ThreadLocal 是一个本地线程副本变量工具类,主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不同的变量值完成操作的场景。但是在实际的开发中,有这样的一种需求:父线程生成的变量需要传递到子线程中进行使用,那么在使用 ThreadLocal 似乎就解决不了这个问题。由于 ThreadLocal 设计之初就是为了绑定当前线程,如果希望当前线程的 ThreadLocal 能够被子线程使用,实现方式就会相当困难。在此背景下,InheritableThreadLocal 应运而生,使用 InheritableThreadLocal 这个变量就可以轻松的在子线程中依旧使用父线程中的本地变量。
ThreadLocal 声明的变量是线程私有的成员变量,每个线程都有该变量的副本,线程对变量的修改对其他线程不可见。
InheritableThreadLocal 声明的变量同样是线程私有的,但是子线程可以使用同样的 InheritableThreadLocal 类型变量从父线程继承 InheritableThreadLocal 声明的变量,父线程无法拿到其子线程的。即使可以继承,但是子线程对变量的修改对父线程也是不可见的。
InheritableThreadLocal 类是 ThreadLocal 类的子类。ThreadLocal 中每个线程拥有它自己的值,与 ThreadLocal 不同的是,InheritableThreadLocal 允许一个线程以及该线程创建的所有子线程都可以访问它保存的值。
ThreadLocal 是不支持继承性的,所谓继承性也是针对父线程和子线程来说,代码示例:
1 | class Scratch {public static final ThreadLocal<String> localVariable = new ThreadLocal<>(); |
InheritableThreadLocal 用于子线程能够拿到父线程往 ThreadLocal 里设置的值,代码示例:
1 | class Scratch { |
InheritableThreadLocal 类重写了 ThreadLocal 的 3 个函数:
1 | public class InheritableThreadLocal<T> extends ThreadLocal<T> { |
从源码上看,跟 ThreadLocal 不一样的无非是 ThreadLocalMap 的引用不一样了,从逻辑上来讲,这并不能做到子线程得到父线程里的值。那么秘密在那里呢?通过跟踪 Thread 的构造方法,你能够发现是在构造 Thread 对象的时候对父线程的 InheritableThreadLocal 进行了赋值。下面是 Thread 的部分源码:
1 | public class Thread implements Runnable { |
通过跟踪 Thread 的构造方法,我们发现只要父线程在构造子线程(调用 new Thread())的时候 inheritableThreadLocals 变量不为空。新生成的子线程会通过 ThreadLocal.createInheritedMap 方法将父线程 inheritableThreadLocals 变量有的对象复制到子线程的 inheritableThreadLocals 变量上。这样就完成了线程间变量的继承与传递。
1 | public class ThreadLocal<T> { |
代码示例:
1 | class Scratch { |
前后两次调用获取的值是一开始赋值的值,因为线程池中是缓存使用过的线程,当线程被重复调用的时候并没有再重新初始化 init() 线程,而是直接使用已经创建过的线程,所以这里的值并不会被再次操作。因为实际的项目中线程池的使用频率非常高,每一次从线程池中取出线程不能够直接使用之前缓存的变量,所以要解决这一个问题,网上大部分是推荐使用 alibaba 的开源项目 transmittable-thread-local。
JDK 的 InheritableThreadLocal 类可以完成父线程到子线程的值传递。但对于使用线程池等会池化复用线程的组件的情况,线程由线程池创建好,并且线程是池化起来反复使用的;这时父子线程关系的 ThreadLocal 值传递已经没有意义,应用需要的实际上是把任务提交给线程池时的 ThreadLocal 值传递到任务执行时。
在 ThreadLocal 的需求场景即是 TTL(装饰器模式)的潜在需求场景,如果你的业务需要『在使用线程池等会池化复用线程的组件情况下传递 ThreadLocal』则是 TTL 目标场景。下面是几个典型场景例子:1、分布式跟踪系统;2、日志收集记录系统上下文;3 应用容器或上层框架跨应用代码给下层 SDK 传递信息。
1 | <dependency> |
代码示例:
1 | class Scratch { |
整个过程的完整时序图:
[1]. ThreadLocal 和 InheritableThreadLocal 深入分析
[2]. transmittable-thread-local
[3]. InheritableThreadLocal 详解