【总结系列】互联网服务端技术体系:高性能之并发(Java)

分而合之,并行不悖。html


技术人首先应当拥有良好的技术素养。技术素养首先是一丝不苟地严谨探索与求知的能力和精神,其次是对一个事物可以构建自底向上的逻辑,具备宏观清晰的视野和对细节的把握。java

要设法阅读第一手的资料,论文、官方文档、源码、经典著做,与发明者交流;要努力追溯本质与本源。知识是相对客观的,不能浮夸和造假。redis

技术人还应当保持谦逊,要明白本身所知的永远是冰山一角,以冰山一角去评判另外一个冰山一角,只是五十步比百步。

算法

引子

并发,就是在同一时间段内有多个任务同时进行着。这些任务或者互不影响互不干扰,或者共同协做来完成一个更大的任务。编程

好比我在作项目 A,修改工程 a ; 你在作项目 B, 修改工程 b 。咱们各自作完本身的项目后上线。我和你作的事情就是并发的。若是我和你修改同一个工程,就可能须要协调处理冲突。并发是一种高效的运做方式,但每每也要处理并发带来的冲突和协做。后端

世界自然是并发的。本文总结并发相关的知识和实践。缓存

总入口见:“互联网应用服务端的经常使用技术思想与机制纲要”

安全

基础

计算机中实现并发的方式有:多核、多进程、多线程;共享内存模型。基本方法是分而治之、划分均衡任务、独立工做单元、隔离访问共享资源。能够将一个大任务划分为多个互相协做的子任务,将一个大数据集划分为多个小的子数据集,分别处理后合并起来完成整个任务。并发须要解决执行实体之间的资源共享和通讯机制。服务器

  • 多核:有多个 CPU 核心,每一个 CPU 核心都拥有专享的寄存器、高速缓存。多个 CPU 核心能够分别处理不一样的指令和数据集。多核心之间的通讯机制是系统总线和共享内存。多核是并发的硬件基础。网络

  • 多进程模型:进程是程序的一次执行实例,具备私有地址空间,由内核调度。进程有父子关系。进程间通讯方式:管道(无名管道和命名管道 FIFO)、消息队列、共享内存、套接字、信号机制。进程建立和切换的开销都比较大。多进程是多任务执行的上下文基础。

  • 多线程模型:线程是运行在进程上下文中的共享同一个进程私有地址空间的执行单元,亦由内核调度。线程之间是对等的。线程通讯方式:消息队列、共享内存。线程的建立和切换开销比进程要小不少。多线程是多任务的调度基础。

在 Java 应用语境中,执行实体对应着线程。如下涉及到执行实体的时候,直接以线程代替。并发能有效利用多个线程同时工做,大幅提高性能。同时,也是有必定代价的:线程阻塞与上下文切换(5000-10000 CPU 时钟)、内存同步开销(使 CPU 缓存失效、禁止编译器优化等)。不良的并发设计,可能致使大量线程等待、阻塞、切换,反而不如串行的执行效率高。

分析

如何去思考和分析并发问题呢? 并发的难点在于,(不一样线程里的)任务执行的不一样顺序会引起不一样的结果,而这些顺序都是有必定几率性存在的。

所以,并发的关键点在于如何在合理的程度上协调任务执行顺序产生预期结果,同时又不对任务的进展产生过大的干预。就像宏观调控之于市场经济。市场经济是很是有活力的经济形式,但听凭市场经济的自由发展,会有失衡的风险。此时,就须要必定的宏观调控来干预一下。而宏观调控也不能过分,不然会抑制市场经济的活力。

注意,是协调执行顺序而不是控制。实际上,执行顺序是难以控制的。大多数时候,能作的是对少数步骤执行施加一些影响,使执行顺序符合某些前后约束,从而可以产生预期结果。绝大多数的步骤执行,仍是任之天然进行。

资源依赖

要正确协调执行顺序,先得弄清楚要协调哪些任务,或者说,任务执行受什么影响:

  • 有限共享的同一资源。好比两我的去争用仅有的一台打印机,只有一我的用完释放后,才能让另外一我的用。
  • 资源之间的依赖。好比任务 A 读变量 x , 任务 B 写变量 y ,x = y + 1, 则 A 读和 B 写的前后顺序不一样,会产生不一样的结果。假设 x = 3, y= 2 。B 要写入 y = 5 。若 A 先读 B 再写, 则 A 读到的是 x = 3; 若 B 先写再 A 读,则 A 读到的是 x = 6。当两个变量是同一个时,是一种“写后读”的依赖,姑且称之为“变化依赖”。

好比,同一个订单的下单过程,两个线程去分别读写订单数据(假设都是读 DB 主库):

  • 共享的资源:订单数据行、网络带宽、请求处理池、DB 链接;
  • 变化依赖:订单状态的读强依赖于订单状态的写。

所以,任务执行受有限共享的资源及资源依赖影响。若是多个任务并发执行,首先要理清楚这些任务所依赖的资源以及资源之间的依赖。资源类型包括:变量、数据行(记录)、文件句柄、网络链接、端口等。若是两个任务没有资源依赖,则各自执行便可;若是有共享资源依赖,则须要在合适的时候自动调节彼此获取共享资源的顺序。

值得说起的是,有一种隐式的资源依赖。好比一个大的任务拆分为 A,B,C 三个任务,A 和 B 都执行完成后,才能执行 C。此时 C 的执行依赖于 A,B 的执行完成状态(也极可能依赖 A,B 的执行结果集)。 这种隐式的资源依赖,也称为任务协做。

逻辑时钟

如何判断并发执行结果是准确的呢?好比 x = 1 。任务 A 在 t 时刻读 x ,任务 B 在 t+1 时刻写 x =5, 任务 C 在 t+2 读 x,按理 A 读到是 1 ,C 读到是 5 。 但因为网络延时,可能 C 的读请求在 B 的写请求提交以前就到达了,所以 C 也可能读到 1。因为网络的不可靠及机器各自的时钟是有细微不一样步的,所以,执行读写 x 的服务器没法判断 B, C 请求的前后性。

须要有一个逻辑时钟,给任务进行顺序编号,根据任务编号以及读写的因果性,就能判断 C 读到 1 的结果是错误的了。

happen-before

定一些基本的准则是必要的。就像欧几里得几何首先定义了五条公理而后才开始推导同样。

happen-before 是可见性判断的基本准则:符合准则的两个操做,前面的操做必然先行于/可见于后面的操做。换句话说,就是关于并发的基本定理。若是定理都不成立,那么并发的肯定性结果就无从谈起了。 happen-before 的具体细则:

  • 在同一个线程的顺序控制流中,有依赖关系的前面操做可见于后续操做;
  • 同一个锁的 unlock 可见于 lock 操做,即 lock 时总能看到前一个 unlock 操做;
  • 同一个 volatile 变量的写可见于读操做;
  • 同一线程 start 先行于该线程内的全部操做,线程内的全部操做先行于该线程的 exit ;
  • 对象的构造器方法结束先行于对象的全部操做,对象的全部操做先行于对象的 finalize 方法开始;
  • 传递性。A 可见于 B, B 可见于 C ,则 A 可见于 C 。

思路

要正确协调任务的执行顺序,须要解决任务之间的协做与同步。任务之间的协做与同步方式主要有:快照机制、原子操做、指令屏障、锁机制、信号机制、消息/管道机制。

快照机制

生成某个时间点的历史版本的不可变的快照数据,以必定策略去生成新的快照;直接读快照而不是读最新数据。将数据与版本号绑定,根据版本号来读取对应的数据;更新时不会修改已有的快照,而是生成新的版本号和数据。快照机制能够用来回溯历史数据。Git 是运用快照机制的典范。

快照机制并无对任务的天然进展施加影响,只是记录了某个数据集的某个时刻的状态。应用能够根据须要去读取不一样时刻的状态,作进一步处理。快照机制通常用来提高并发读的吞吐量。

原子操做

将多个操做封装为一个不可分割的总体操做,其它操做不可能在这个总体操做之间插入更新相关变量。

实现原子操做有两种方式:

  • 对变量更新加锁。但加锁会致使线程阻塞和等待,且须要释放锁,开销很大。
  • CAS 操做。对于单个简单变量的读写同步,加锁的开销可能远高于变量更新的开销。能够采用轮询式的 CAS 原子操做。CAS 是封装了变量的“比较相等-更新”的原子操做。

指令屏障

指令屏障是在普通指令中插入特殊指令,从而在读写指令的执行之间加以执行顺序的前后约束,控制某些指令必须在另外一些指令以前执行且执行结果可见,禁止 CPU 经过指令重排序来优化内存读写(有性能损失)。最经常使用的指令屏障是内存屏障 Memory Barrier。

锁机制

锁机制用于有限共享资源的保护性访问,每次只容许一个执行体来访问可得到的共享资源。

锁机制的基础是 P-V 原语和阻塞/唤醒机制:

  • P(s) 操做:若是 s 是非零的,那么 P 将 s 减一,并当即返回,若是 s 为零,就挂起该线程。
  • V(s) 操做:将 s 加一,若是有任何线程阻塞在 P 操做等待 s 变成非零,则 V 操做会重启这些线程中的一个,重启以后,P 将 s 减一,并将控制返回给调用者。

信号机制

信号机制是发出特定的信号,让接受信号的任务作相应的处理。中断是信号机制的一种典型场景。中断由某个中断源发出一个信号给某个线程,当线程收到这个信号时,能够作一些特定的动做。

Java 线程有一个中断标志位。处于不一样状态时,线程对于中断有不一样的反应。处于 New 和 Terminated 时,无心义;处于 Running 和 Blocked 时,只是设置中断标志位,不会影响线程状态; 处于 Time Sleep 时,会抛出异常并清空中断标志位。Java 将中断的具体处理的权力交给了应用。

消息/管道

经过在两个任务之间传递消息或者创建管道,来串联起两个任务的顺序执行。消息机制经常使用于解耦服务,而管道经常使用于 Pipeline 流水线模式中。

模式

从并发思路中能够推导出一些经常使用的同步模式,来确保并发访问的安全性。主要有:Immutable、Unshared Copies、Monitor Locks 、Memory Barrier、Protected Lock、CAS。

Immutable

不可变数据。典型的不可变数据有字符串、快照。ES 分片里的倒排索引就是不可变的。ES 会将不可变的倒排索引与更新的倒排索引进行查询合并,获得最终的查询结果。

Unshared Copies

每一个线程都有一份本身的拷贝,不共享,互不影响。ThreadLocal 便是应用 Unshared Copies 模式。

Monitor locks

Java synchronized 块应用 Monitor locks 模式,基于 object monitor 和 monitorenter/2 monitorexit 实现,由编译器和 JVM 共同协做实现。JVM 规范指明:每一个对象关联一个 object monitor ,当线程执行 monitorenter 时会去获取 monitor 的 ownership ,而执行 monitorexit 则会释放 monitor 的 ownership。第二个 monitorexit 是为了在异常退出时与 monitorenter 匹配。在 hotSpot 虚拟机中,monitor 是由 ObjectMonitor 实现的。其源码位于 hotSpot 虚拟机源码 ObjectMonitor.hpp 文件中。

synchronized 方法是基于方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 标识符实现。synchronized 是可重入的,同一线程连续屡次获取同一个锁,不须要每次都加锁,只需记录加锁次数。同步容器 SynchronizedList, SynchronizedMap 是基于 synchronized(mutex) { // target.operation(); } 实现的对应容器的简单并发版。

Memory Barrier

Memory Barrier 内存屏障。当插入内存屏障后,其后的指令不会当即放在 CPU 缓存里,而是和内存屏障一块儿放在 FIFO 队列里,待 CPU 缓存里的指令都执行完成后,从 FIFO 中取出内存屏障后的指令来执行。

内存屏障主要有两种:

  • 写内存屏障(Store Barrier):处理器将 CPU 缓存值写回主存(阻塞方式);
  • 读内存屏障(Load Barrier): 处理器处理失效队列(阻塞方式)。

两两组合,有四种:StoreStoreBarrier, StoreLoadBarrier, LoadStoreBarrier, LoadLoadBarrier。 XYBarrier 是指,在 XYBarrier 以前的全部 X 操做都必须在 XYBarrier 以后的任一 Y 操做以前执行完成。而且写操做对全部处理器可见。

volatile 关键字在写操做以后插入 StoreStore Barrier, 在读操做以前操做 LoadLoad Barrier。volatile 适合作单个简单状态标识符的更新、生命周期里的初始化或退出。volatile 是不加锁的。

Protected Lock

轻量级更灵活的锁。形式一般以下:

Lock lock = lock.lock(lockKey, time, timeUnit);
        try {
            // doBizLogic;
        } finally {
            lock.unlock();
        }

CAS

Compare-And-Swap 。CAS(V,E,N) 操做便是“先将 V 与 E 比较是否相等,若是相等,则更新到指定值 N ,不然什么都不作”。CAS 是无锁的非阻塞的,没有线程切换开销,所以在并发程度不高的状况下性能更优。Java 并发包里的绝大多数同步工具都有 CAS 的影子。 Java CAS 操做是经过 Unsafe 类的 native 方法支持的。

CAS 操做的原子语义是经过底层硬件和指令来支持的。相关指令以下:

  • 测试并设置(Tetst-and-Set)
  • 获取并增长(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-Swap)
  • 加载连接/条件存储(Load-Linked/Store-Conditional)

在IA64,x86 指令集中有 cmpxchg 指令完成 CAS 功能。CPU 的原子操做在底层能够经过总线锁定和缓存锁定来实现。总线锁定是 CPU 在总线上输出一个 LOCK# 信号,阻塞其它处理器操做该共享变量的缓存的请求,独占内存;缓存锁定是经过 MESI 缓存一致性机制来保证操做的原子性。在以下状况下只能使用总线锁定:当操做的数据不能被缓存在处理器内部,或者操做的数据跨多个缓存行时,或者 CPU 不支持缓存锁定。

CAS 有两个问题:

  • 并发度高时的空耗问题:CAS 是须要消耗 CPU 周期的。若是并发激烈,则可能陷入空耗 CPU 周期的 CAS 循环中。此时,能够采用分段的方式,将要更新的变量分为多段,对不一样的段进行 CAS ,而后合并。好比 LongAdder 。
  • A-B-A 问题:先更新为 A,再更新为 B,又更新为 A。能够采用版本号/时间戳来区分两次相同值。好比 AtomicStampedReference。

工具

理解了并发的模型、思路和模式以后,再来看并发工具如何实现。Java 并发包里的绝大多数同步工具都是基于 CAS 和 AQS 的。所以,深刻理解 CAS 和 AQS 是很是重要的。

AQS

实现 Java 同步工具的基本框架,也是整个 Java 并发包的核心基础类。AQS 实现了“根据某种许可获取的状况将线程入队/出队以及相应的线程阻塞/唤醒”的通用机制,而将什么时候入队/出队(是否可以得到许可)的控制权交给了库的使用者。AQS 支持按照中断(互斥)或者超时两种模式来获取/释放许可,协调线程执行顺序。

AQS 包含一个同步队列和一个条件队列。两个队列都是基于链表实现的。

  • 同步队列:CLH 变体,双向链表实现的队列,从尾部入队,从头部出队。入队是针对尾节点 tail 的 CAS 操做,将 tail 赋给为入队线程建立的新节点;出队则是更新首节点 head。同步队列初始化时,须要对 head 进行 CAS 操做。head 节点至关于一个哨兵元素,head 节点没有 prev 和 thread ,且 waitStatus 不会为 CANCELLED。同步队列的遍历每每是从 tail 开始往前遍历。
  • 条件队列:采用单链表,互斥模式。ConditionObject 对象实现了条件等待/通知机制。调用 await 方法时会从同步队列转移到条件队列,调用 signal 唤醒方法时则从条件队转移到同步队列。
  • 二者联系:同步队列和条件队列复用了相同的链表节点,经过链表节点上的节点指针来标识节点在哪一个队列上。同步用来获取锁,而条件队列在获取锁的基础上用来实如今特定条件的等待/唤醒,二者能够配合使用。

链表节点包含以下成员:

  • 被阻塞或等待唤醒的线程 Thead 。
  • 节点状态 waitStatus 。 CANCELLED -- 超时或中断取消,一旦进入, 就不可改变; SIGNAL -- 须要唤醒后继节点 , CONDITION -- 线程等待唤醒, PROPAGATE -- acquireShare 须要无条件传播下去;处于 CANCELLED 的节点是不可被阻塞或唤醒的,所以在阻塞或唤醒时须要遍历跳过 CANCELLED 节点。
  • 实现同步队列的节点指针 head, tail ; prev, next 。prev 用来处理取消,经过 prev 的节点状态来判断当前线程该如何处理;next 当前节点释放时须要唤醒它的 next 节点,CANCELLED 节点的 next 是它自身。因为前驱或后继节点可能由于超时或中断已被取消,所以须要遍从来找到第一个没有取消的前驱或后继节点。
  • 实现条件队列的节点指针 firstWaiter, lastWaiter ; nextWaiter 。nextWaiter 在条件队列中指向下一个节点,或者指向 Share 节点。

如前所述,AQS 实现了通用的入队/出队以及相应的阻塞/唤醒机制,那么什么时候会入队/出队呢?这就是自定义方法的做用了。使用 AQS 开发同步工具,须要定义好 state 的同步语义,实现以下方法:tryAcquire/tryRelease,tryAcquireShared/tryReleaseShared,isHeldExclusively。

AtomicXXX

原子类,提供基本数据类型的原子化更新操做。经过 volatile variable + offset (字段的固定的内存地址偏移量) + Unsafe 来获取的状态字段的可见值,CAS 实现原子操做,适用于计数、安全引用更新等。可阅读 AtomicInteger 和 LongAdder 的实现。

ReentrantLock

Protected Lock 模式的一种实现。基于 CAS 和 AQS 实现,提供公平锁 FairSync 和非公平锁 NonfairSync。默认非公平锁。非公平锁吞吐量更高,公平锁倾向于访问授予等待时间最长的线程,吞吐量可能较低,适合防线程饥饿上波动小一点。

  • ReentrantLock 的 lock 实现默认委托给 NonfairSync,该类继承 AQS 来实现锁机制。
  • nonfairTryAcquire: 分为两种状况处理 -- 线程第一次获取锁和已经获取锁。
  • tryRelease:分两种状况处理 -- 最后一次释放锁和屡次获取锁后的某一次释放。
  • state 同步语义: state > 0 表示已有线程获取锁的许可数,只有获取锁的线程可以继续获取锁或者释放锁; state = 0 表示线程能够去获取锁。

ReentrantLock 能够返回一个ConditionObject 对象,用做条件等待阻塞和唤醒。

  • CopyOnWriteArrayList 基于 array + ReentrantLock + System.arraycopy 实现,读多写少场景。读列表不加锁,更新列表使用 ReentrantLock 进行保护性访问。
  • ArrayBlockingQueue 使用一个 ReentrantLock 及一对 Condition ( notEmpty & notFull ) 对队列进行保护性访问,并在队列空/满时阻塞相应线程,在队列非空/非满时唤醒相应线程。

ConcurrentHashMap

HashMap 的并发加锁版。要点以下:

  • 为保证高并发,使用了分段锁机制,每一个桶关联一个锁;
  • 定位桶索引时使用 CAS ,由于定位桶索引是一个轻量操做;
  • 访问某个桶的数据时使用分段锁(synchronized(tab)) ;
  • 链表冲突转换为红黑树时,插入新节点后将树转平衡时使用 CAS 。
  • ConcurrentHashMap 可用于并发环境中的缓存实现。

ConcurrentHashMap 体现了一些提高并发性能的技巧:减小串行化部分的耗时、减小持锁逻辑耗时(下降锁粒度)、减小锁竞争程度(数据分段及分段锁)。使用多个细粒度锁交互时要注意防止死锁。

ThreadLocal

ThreadLocal 类里维护了一个哈希表 ThreadLocalMap[ThreadLocal, Value] ,每一个线程都持有一个对 ThreadLocalMap 的引用,在该线程里调用 ThreadLocal.setInitialValue 方法时被初始化。当调用某个 ThreadLocal 对象的 set 方法时,会先获取当前线程,而后将当前线程的 TheadLocal 对象及对应的值写入所持有的 ThreadLocalMap 中。ThreadLocal 对象的哈希码值是经过一个 AtomicInteger 每次自增 0x61c88647 获得的。0x61c88647 是斐波那契乘数,可保证哈希散列分布均匀一些。

ThreadLocal 在一个长流程中存储须要的 Context 。ThreadLocal 使用要注意的问题:

  • 内存泄露。因为 ThreadLocalMap 的 key 是弱引用,ThreadLocalMap 是强引用对象,当 key 被回收时,对应的 value 可能不会被回收,会形成内存泄露;
  • ThreadLocal 与线程池联合使用时,退出线程前必须清除残留的 ThreadLocal 变量数据。

线程池

线程池是受控的可执行多任务的线程管理器。Java 线程池实现是 ThreadPoolExecutor。 线程池的主要组成部分以下:

  • 一个阻塞任务队列,用来存放待处理的任务 BlockingQueue[Runnable] workQueue;关联任务拒绝策略 RejectedExecutionHandler handler,当队列满时如何处理后面的任务请求。
  • 一个线程工厂 threadFactory,用来生产和标识线程,能够作一点线程定制化的事情;
  • 一组可控的复用和回收的工做线程 Set[Worker] works;关联访问工做线程的可重入锁 ReentrantLock mainLock;
  • 线程池的配置:核心线程数、最大容许线程数、最小容许线程数、最大线程空闲时间。

线程池的实现要点以下:

  • 线程池总状态控制 ctl : (3位 runState rs, 29 位 workCount wc)。 ctl = rs | wc。rs 用来表示线程池的状态:RUNNING-- 运行,能够接受新任务; SHUTDOWN -- 关闭,不接受新任务,但能够运行队列中任务;STOP -- 中止,不接受新任务,中断全部正运行的任务;TIDYING -- 线程池已空,将运行 terminated 钩子方法;TERMINATED -- terminated 方法执行完成,线程池完全终止。技巧:1. 将多个值打包到一个值的技巧;2. 状态值递增,有利于状态的判断。
  • worker:用来执行任务。同时继承 AQS 根据 0 & 1 状态实现了简单的非重入互斥锁,这能够防止某些中断,这些中断旨在唤醒等待任务的工做线程,而不是中断正在运行的任务。
  • workers & mainLock : 配合起来访问工做线程集合,用来作线程统计,以及线程池终止时防止中断风暴。技巧:轻量级并发访问容器里的对象。
  • 任务运行: run(Worker) 方法。使用 Protected Lock 模式。
  • 线程池终止:SHUTDOWN -> STOP -> TIDYING -> TERMINATED。
  • 扩展:能够继承 ThreadPoolExecutor,并覆写 beforeExecute 和 afterExecute 方法,定义在任务执行以前和执行以后的行为。能够用来申请/释放资源、打日志等。

陷阱

要作到并发的准确与安全,须要很是当心地避免一些常见陷阱:

应用

InnoDB锁

  • InnoDB 使用锁机制实现事务隔离性级别。避免:脏读(读到未提交数据)、不可重复读(两次查询读到不一致的数据)、幻读(两次查询读到不同的行)。丢失更新问题须要应用层来控制。InnoDB 锁主要有行锁、页锁和表锁。
  • 表锁:开销小,加锁快,锁粒度最大,冲突几率高,并发度低,不会死锁。使用表锁的状况:没有索引时,更新数据会锁表;串行化隔离级别会锁表; 部分 DDL 会锁表或者阻塞写,不要在业务高峰期进行。
  • 行锁:开销小,加锁慢,锁粒度最小,冲突几率较低,并发度较高,会死锁。InnoDB 行锁是经过给索引树上的索引项加锁来实现的。有索引时,锁定读会锁行,更新数据行会锁行。行锁可分为共享锁(读锁、S 锁)和排他锁(写锁、X 锁)。 S 锁与 S 锁能够并发,其它都须要等待已有锁的释放。
  • 锁定读:select … for update (X 锁), select … lock in share mode(S 锁)。 自增加键的锁使用 X 锁定读;外键列的 SELECT 会生成 S 锁定读。
  • 自增加键的锁。AUTO-INC Locking --- 含自增加键的表逐渐插入记录时,会生成 select for update 的加锁读。 特殊表锁机制,锁在完成 SQL 插入语句以后当即释放,而不是等事务执行完成后释放。MySQL 5.1.22 以后提供了一种轻量级互斥量的机制,来实现自增加值插入的性能提高。innodb_autoinc_lock_mode 参数能够选择使用何种机制。默认值为 1 ,对于插入前能够肯定插入行数的 simple inerts ,使用互斥量机制,不能肯定行数的使用 AUTO-INC Locking 机制。innodb_autoinc_lock_mode = 2 时,始终使用 AUTO-INC Locking 机制,性能最优,但容易致使不一致问题。
  • 外键列:外键列的 SELECT 会对父表中的相应行加 S 锁。若是父表中的相应行已经有 X 锁,则外键的 SELECT 须要等待锁释放后才能执行。
  • 行锁算法:Record 锁、Gap 锁、Next-Key 锁。Record 锁一般是索引列的读写引发,锁定行记录自己;Gap 锁定范围边界但不锁定记录;Next-Key 锁是 Record + Gap 的结合,右闭区间,锁定范围内的记录以及范围右端的记录,但不锁定左端的记录,能够防止幻读。InnoDB 默认隔离级别是 Repeatable Read ,该级别下,辅助索引的默认行锁是 Next-key 锁;若查询列为惟一索引列时,Next-Key 锁会降级为 Record 锁。

分布式锁

  • 锁的要求: 容易替换实现、可重入、高性能、高可用。实现时要考虑异常(应用宕机、网络延时与中断、集群节点宕机等)。
  • 基本思想: 锁 + 超时 + 持锁线程的惟一标识 + 加锁/释放锁的必要检测。一般使用 Redis, ZK 实现。
  • 锁释放: 1. 须要加超时,避免线程不响应时没法释放锁; 2. 加锁时必须加该线程的标识信息,避免释放锁时释放错误。考虑这样一种状况:线程 A 申请了带超时的锁 l ,因某种缘由被阻塞或者不响应,锁 l 因超时被释放,被线程 B 申请到; 接着 A 从阻塞或不响应中恢复过来,释放原来申请的锁,若是锁没有线程标识的信息,就极可能把 B 申请的锁给释放掉了。这就是说,释放锁时须要严格的检测。
  • Redis: 加锁 -- SET NX key unique_value EXPIRE_TIME ,若已持有锁则加 EXPIRE_TIME,若是 NX 和 EXPIRE_TIME 不一样时在一块儿,当进程加锁后就崩溃,则该锁将没法释放;释放锁 -- get-and-del 使用 Lua 脚本保证原子性, if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) 。 可使用 Redisson API 。
  • ZK: 先建立一个持久化节点,当第一个客户端须要加锁时,在持久化节点下建立一个最小编号的临时顺序节点;后续要加锁的客户端依次建立编号次小的临时顺序节点,并对临近的前一个节点建立 watch 监放任务。 每一个客户端释放锁时,都须要检测本身的节点编号是不是最小的那个,每次仅能释放编号最小的那个节点。当释放成功后,释听任务会触发下一个节点所关联的任务和客户端,这个客户端就能够拿到锁进行操做。当客户端崩溃时,这个节点也会被删除,后面的节点则依次往前挪一位。不一样业务的锁采用不一样的前缀。可以使用 Curator SDK。
  • 实现注意事项:1. 最好提供一个分布式锁的接口,隔离应用程序对具体实现的直接依赖; 2. 在加锁时考虑释放,避免使用者忘记释放锁; 3. 降级处理,好比 Redis 锁不可用时可降级为 DB 锁 或 ZK 锁; 4. 加锁和释放锁的监控(加锁和释放锁的时间、锁中业务执行时间、次数、并发量、失败次数等)。

挑战

大流量

并发大流量是引发应用不稳定甚至将应用击溃的常见杀手之一。应对并发大流量的措施:1. 缓存,减小对后端存储压力;2. 降级,暂时移除非核心链路;3. 限流; 4. 架构升级,作到动静分离、冷热分离、读写分离、服务器分离、服务分离、分库分表、负载均衡、NoSQL 技术、(多机房)冗余、容器化、上云。

不一致

因为任务顺序的不肯定性及脑力思考的局限性,加上大流量,在少量情形下,可能会触发程序的细微 BUG, 引发数据的不一致。

因为人力的有限性,对于高并发引发的不一致,最好能构建准实时的监控、对帐、补偿和对帐报表。

死锁

多个线程同时要获取多个类型的共享资源时,申请锁的顺序不当,可能致使死锁。

  • 四要件:1. 互斥、请求与保持、不可剥夺、循环等待;
  • 解决方案: 1. 加锁超时释放,破坏不可剥夺; 2. 加锁顺序控制,破坏循环等待; 3. 使用等待图来检测死锁。

小结

世界自然是并发的。并发既是一种高效的运做方式,亦是一种符合天然的设计。本文总结了并发的基础知识、思路、模式、工具、陷阱、应用、挑战。

PS: 当我回过头来看写下的这些知识时,发现大部分都是描述性的,只有少部分是原理性的。或者说,在某个层次上是原理性的知识,在更底层看来是描述性的知识。从描述性的知识中,应当提炼出原理与思想。

我忽然感到:不只要对整个模型和机制有宏观清晰的视野,也要能扎下去,研究第一手的论文和资料。如此,才能自下而上地融会贯通。而这样的探索,才是技术的精神本质。

参考资料

相关文章
相关标签/搜索