导图社区 架构师学习-0610
涵盖了计算机组成原理、硬件、分析、安全、JAVA、架构、数据库、缓存、大数据、开源项目、消息队列、领域建模等详细内容,架构师必备!
编辑于2024-08-31 09:48:07架构师必备
计算机组成原理
冒险和预测
CPU里的“线程池”
描述:在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。
顺序执行 乱序执行 顺序执行
在乱序执行的情况下,只有CPU内部指令的执行层面,可能是“乱序”的。只要我们能在指令的译码阶段正确地分析出指令之间的数据依赖关系,这个“乱序”就只会在互相没有影响的指令之间发生。 即便指令的执行过程中是乱序的,在最终指令的计算结果写入到寄存器和内存之前,依然会进行一次排序,以确保所有指令在外部看来仍然是有序完成的。
a = b + c d = a * e x = y * z
里面的d依赖于a的计算结果,不会在a的计算完成之前执行。但是我们的CPU并不会闲着,因为x=y*z的指令同样会被分发到保留站里。因为x所依赖的y和z的数据是准备好的,这里的乘法运行不会等待计算d,而会先去计算x的值。 在这整个过程中,整个计算乘法的FU都没有闲置,意味着CPU的吞吐率最大化了。
解决了什么问题
解决了流水线阻塞的技术方案。
今天下雨了,明天还会下雨吗
分支预测
描述:让CPU来猜一猜,条件跳转后执行的指令,应该是哪一条
静态预测
通过控制信号清除掉已经在流水线中执行的指令。
“假装分支不发生”。自然就是仍然按照顺序,把指令往下执行。 如果预测是正确的,就会节省待待时间。如果失败了,就把后面已经取出指令已经执行部分,给丢弃掉。比如,清空已经使用的寄存器里面的数据等等。
动态预测
根据今天的天气来猜。如果今天下雨,我们就预测明天下雨。
状态机
如果连续发生下雨的情况,我们就认为更有可能下雨。之后如果只有一天放晴,我们仍然认为会下雨。在连续下雨之后,要连续两天放晴,我们才会认为之后会放晴。如图
硬件
计算机组成
程序
存在硬盘
进程
加载到内存
PC
指令指针,指向下一条指令的位置
Registers
数据放在这里
ALU-运算单元
计算器
cache
从内存取数据放入高速缓存,一层层的放入高速缓存
启动
流程
通电->BIOS加电自检->第一扇区选择加载哪个系统---
CPU
CPU每个cache line标记四种状态 表示每一行的状态
Modified:被修改过
Exclusive:独享
Shared:和其他工享
Invalid:无效
基本组成
子主题
乱序执行
表示指令之间没有关系,才会出现并行执行的情况
重排序
volatile禁止指令重排序
JVM用一条lock指令,将之前的操作全部刷到内存中
CPU层面禁止重排序
内存屏障
对某部分内存做操作时前后添加的屏障,屏障前后的操作不可以乱序执行。
子主题
JVM屏障
volatile的实现细节
1.volatile内存修饰,前一个store(写),volatile 也是写不可互换,后一个load(读)必须等前面的两个写完成才能读,保证了可见性,还没有重排序问题。 2.volatile内存修饰,前一个load(读),volatile 也是读不可互换,后一个load(写)必须等前面的两个读完成才能读,保证了可见性,还没有重排序问题。 总结:在写操作和读操作上都加了屏障,用lock指令实现。
4个内存屏障
8条重排序规则
JVM规定了8条重排序规则 有重排序问题
as-if-serial
子主题
分配内存优先分配该线程所在CPU的最近内存
IO
网络工具:netstat -natp lsof -p 查看是否连通 抓包:tcpdump -nn -i eth0 port 9090
虽未连接,但已经推送了4个字节
TCP
面向连接的,可靠的传输协议
三次握手
内核开辟资源
子主题
socket
四元组(CIP CPORT + SIP SPORT)
内核级的
即便你不调用accept
为什么TCP传输是可靠的
1、应用数据被分割成TCP认为最适合发送的数据块。这和UDP完全不同,应用程序产生的数据报长度将保持不变。 (将数据截断为合理的长度) 2、当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。 3、当TCP收到发自TCP连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒 。 (对于收到的请求,给出确认响应) (之所以推迟,可能是要对包做完整校验) 4、 TCP将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP将丢弃这个报文段和不确认收到此报文段。 (校验出包有错,丢弃报文段,不给出响应,TCP发送数据端,超时时会重发数据) 5、既然TCP报文段作为IP数据报来传输,而IP数据报的到达可能会失序,因此TCP报文段的到达也可能会失序。如果必要,TCP将对收到的数据进行重新排序,将收到的数据以正确的顺序交给应用层。 (对失序数据进行重新排序,然后才交给应用层) 6、既然IP数据报会发生重复,TCP的接收端必须丢弃重复的数据。(对于重复数据,能够丢弃重复数据) 7、TCP还能提供流量控制。TCP连接的每一方都有固定大小的缓冲空间。TCP的接收端只允许另一端发送接收端缓冲区所能接纳的数据。这将防止较快主机致使较慢主机的缓冲区溢出。(TCP可以进行流量控制,防止较快主机致使较慢主机的缓冲区溢出)TCP使用的流量控制协议是可变大小的滑动窗口协议。 字节流服务:: 两个应用程序通过TCP连接交换8bit字节构成的字节流。TCP不在字节流中插入记录标识符。我们将这称为字节流服务(bytestreamservice)。 TCP对字节流的内容不作任何解释:: TCP对字节流的内容不作任何解释。TCP不知道传输的数据字节流是二进制数据,还是ASCII字符、EBCDIC字符或者其他类型数据。对字节流的解释由TCP连接双方的应用层解释。
TCP,控制传输协议,它充分实现了数据传输时的各种控制功能: 针对发送端发出的数据包确认应答信号ACK; 针对数据包丢失或者出现定时器超时的重发机制; 针对数据包到达接收端主机顺序乱掉的顺序控制; 针对高效传输数据包的滑动窗口控制; 针对避免网络拥堵时候的流量控制; 针对刚开始启动的时候避免一下子发送大量数据包而导致网络瘫痪的慢启动算法和拥塞控制。 此外,TCP作为一种面向有连接的控制传输协议,只有在确认对端主机存在时才会发送数据,从而可以控制通信流量的浪费。 如何确保TCP协议传输稳定可靠? TCP通过序列号、超时重传、检验和、流量控制、滑动窗口、拥塞控制实现可靠性。 1、应用数据被分割成TCP认为最适合发送的数据块。 2、TCP给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。 3、TCP的接收端会丢弃重复的数据。 4、超时重传:当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。 5、校验和:TCP将保持它首部和数据的检验和,发送的数据包的二进制相加然后取反。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP将丢弃这个报文段和不确认收到此报文段。 6、流量控制:TCP连接的每一方都有固定大小的缓冲空间,TCP的接收端只允许发送端发送接收端缓冲区能接纳的我数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP使用的流量控制协议是可变大小的滑动窗口协议。接收方有即时窗口(滑动窗口),随ACK报文发送。(TCP 利用滑动窗口实现流量控制) 7、滑动窗口:实际中的传输方式, 需要说明一下,如果你不了解TCP的滑动窗口这个事,你等于不了解TCP协议。 我们都知道,TCP必需要解决的可靠传输以及包乱序的问题, 所以,TCP必需要知道网络实际的数据处理带宽或是数据处理速度,这样才不会引起网络拥塞,导致丢包。 8、拥塞控制:当网络拥塞时,减少数据的发送。发送方有拥塞窗口,发送数据前比对接收方发过来的即使窗口,取小慢启动、拥塞避免、拥塞发送、快速恢复。应用数据被分割成TCP认为最适合发送的数据块,TCP的接收端会丢弃重复的数据。 停止等待协议也是为了TCP协议传输稳定可靠,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。
客户端连服务端
两个端,有窗口要机制,客户端与服务端窗口大小相互通知,为的是数据无法安放。
当服务端未接通端口时,内核为接收数据,但是内核缓存大小有限,所以客户端发送不要太多
网络IO 变化 模型
同步 异步 阻塞 非阻塞
指令
strace -ff -o out cmd
追踪命令
vi out.8384 tail -f out.8384
子主题
clone 操作系统的一个线程 主线程继续接收新的连接
man 学习知识
内核
管理CPU调度、内存管理、文件系统、进程调度、设备驱动
微内核
可以通过网络调用 可插拔式 场景:5G
例:鸿蒙
宏内核
PC、phone
VMM
硬件资源过剩
中间层可以虚拟多个OS
内核态、用户态
linux内核跑在ring 0级,用户程序跑在ring 3级 内核执行操作->200多个系统调用 sendfile read write pthread fork
分析
故障排查
线上故障主要会包括cpu、磁盘、内存以及网络问题
四个方面依次排查一遍,df、free、top 三连,然后依次jstack、jmap伺候,具体问题具体分析即可。
CPU
CPU 异常往往还是比较好定位的。原因包括业务逻辑问题(死循环)、频繁gc以及上下文切换过多。业务逻辑(或者框架逻辑)导致的,可以使用jstack来分析对应的堆栈情况。
使用 jstack 分析 CPU 问题
命令
使用ps或是top查看CPU使用率较高的一些线程
Linux中top命令参数详解
top的使用方式 top [-d number] | top [-bnp] 参数解释: -d:number代表秒数,表示top命令显示的页面更新一次的间隔。默认是5秒。 -b:以批次的方式执行top。 -n:与-b配合使用,表示需要进行几次top命令的输出结果。 -p:指定特定的pid进程号进行观察。 在top命令显示的页面还可以输入以下按键执行相应的功能(注意大小写区分的): ?:显示在top当中可以输入的命令 P:以CPU的使用资源排序显示 M:以内存的使用资源排序显示 N:以pid排序显示 T:由进程使用的时间累计排序显示 k:给某一个pid一个信号。可以用来杀死进程 r:给某个pid重新定制一个nice值(即优先级) q:退出top(用ctrl+c也可以退出top)。
子主题
top -H -p pid
将占用最高的 pid 转换为 16 进制printf '%x\n' pid得到 nid
在 jstack 中找到相应的堆栈信息jstack pid |grep 'nid' -C5 –color
找到了 nid 为 0x42 的堆栈信息,接着只要仔细分析一番即可。
文件分析
对整个 jstack 文件进行分析,通常我们会比较关注 WAITING 和 TIMED_WAITING 的部分,BLOCKED 就不用说了。我们可以使用命令
cat jstack.log | grep "java.lang.Thread.State" | sort -nr | uniq -c
来对 jstack 的状态有一个整体的把握,如果 WAITING 之类的特别多,那么多半是有问题啦。
频繁 gc
使用 jstack 来分析问题
先确定下 gc 是不是太频繁,jstat -gc pid 1000命令来对 gc 分代变化情况进行观察
1000 表示采样间隔(ms),S0C/S1C、S0U/S1U、EC/EU、OC/OU、MC/MU 分别代表两个 Survivor 区、Eden 区、老年代、元数据区的容量和使用量。
上下文切换
vmstat命令来进行查看
cs(context switch)一列则代表了上下文切换的次数。
对特定的 pid 进行监控
pidstat -w pid命令,cswch 和 nvcswch 表示自愿及非自愿切换。
磁盘
磁盘空间
df -hl来查看文件系统状态
磁盘问题还是性能上的问题
iostat -d -k -x来进行分析
通过 lsof 命令来确定具体的文件读写情况 lsof -p pid
最后一列%util可以看到每块磁盘写入的程度,而rrqpm/s以及wrqm/s分别表示读写速度,一般就能帮助定位到具体哪块磁盘出现问题了。另外我们还需要知道是哪个进程在进行读写,一般来说开发自己心里有数,或者用 iotop 命令来进行定位文件读写的来源。 不过这边拿到的是 tid,我们要转换成 pid,可以通过 readlink 来找到 pidreadlink -f /proc/*/task/tid/../..。
内存
内存问题排查起来相对比 CPU 麻烦一些,场景也比较多
包括 OOM、GC 问题和堆外内存。
堆内内存
内存问题大多还都是堆内内存问题。表象上主要分为 OOM 和 Stack Overflow。
OOM
例
Exception in thread “main” java.lang.OutOfMemoryError: unable to create new native thread
这个意思是没有足够的内存空间给线程分配 Java 栈,基本上还是线程池代码写的有问题,比如说忘记 shutdown,所以说应该首先从代码层面来寻找问题,使用 jstack 或者 jmap。如果一切都正常,JVM 方面可以通过指定Xss来减少单个 thread stack 的大小。另外也可以在系统层面,可以通过修改/etc/security/limits.confnofile 和 nproc 来增大 os 对线程的限制。
Exception in thread “main” java.lang.OutOfMemoryError: Java heap space
这个意思是堆的内存占用已经达到-Xmx 设置的最大值,应该是最常见的 OOM 错误了。解决思路仍然是先应该在代码中找,怀疑存在内存泄漏,通过 jstack 和 jmap 去定位问题。如果说一切都正常,才需要通过调整Xmx的值来扩大内存。
Caused by: java.lang.OutOfMemoryError: Meta space
这个意思是元数据区的内存占用已经达到XX:MaxMetaspaceSize设置的最大值,排查思路和上面的一致,参数方面可以通过XX:MaxPermSize来进行调整(这里就不说 1.8 以前的永久代了)。
Stack Overflow
Exception in thread “main” java.lang.StackOverflowError
表示线程栈需要的内存大于 Xss 值,同样也是先进行排查,参数方面通过Xss来调整,但调整的太大可能又会引起 OOM。
使用 JMAP 定位代码内存泄漏
OOM 和 Stack Overflow 的代码排查方面 --> 用 jmap -dump:format=b,file=filename pid来导出 dump 文件。 --> 启动参数中指定-XX:+HeapDumpOnOutOfMemoryError来保存 OOM 时的 dump 文件。 通过 mat(Eclipse Memory Analysis Tools)导入 dump 文件进行分析 --> 内存泄漏问题一般我们直接选 Leak Suspects 即可,mat 给出了内存泄漏的建议。另外也可以选择 Top Consumers 来查看最大对象报告。 --> 线程相关的问题可以选择 thread overview 进行分析。除此之外就是选择 Histogram 类概览来自己慢慢分析
日常开发中,代码产生内存泄漏是比较常见的事,并且比较隐蔽,需要开发者更加关注细节。比如说每次请求都 new 对象,导致大量重复创建对象;进行文件流操作但未正确关闭;手动不当触发 gc;ByteBuffer 缓存分配不合理等都会造成代码 OOM。
gc 问题和线程
gc 问题除了影响 CPU 也会影响内存,排查思路也是一致的。一般先使用 jstat 来查看分代变化情况,比如 youngGC 或者 fullGC 次数是不是太多呀; 线程的话太多而且不被及时 gc 也会引发 oom,大部分就是之前说的unable to create new native thread。 除了 jstack 细细分析 dump 文件外,我们一般先会看下总体线程,通过 pstree -p pid |wc -l。
总体线程: pstree -p pid |wc -l ls -l /proc/pid/task | wc -l
堆外内存
堆外内存溢出表现就是物理常驻内存增长快,报错的话视使用方式都不确定
堆外内存溢出往往是和 NIO 的使用相关,一般我们先通过 pmap 来查看下进程占用的内存情况
pmap -x pid | sort -rn -k3 | head -30 //查看对应 pid 倒序前 30 大的内存段
确定有可疑的内存端,需要通过 gdb 来分析
gdb --batch --pid {pid} -ex "dump memory filename.dump {内存起始地址} {内存起始地址+内存块大小}"
获取 dump 文件后可用 heaxdump 进行查看hexdump -C filename | less,不过大多数看到的都是二进制乱码。
NMT 是 Java7U40 引入的 HotSpot 新特性,配合 jcmd 命令我们就可以看到具体内存组成了。需要在启动参数中加入 -XX:NativeMemoryTracking=summary 或者 -XX:NativeMemoryTracking=detail,会有略微性能损耗。 一般对于堆外内存缓慢增长直到爆炸的情况来说,可以先设一个基线 jcmd pid VM.native_memory baseline 然后等放一段时间后再去看看内存增长的情况,通过jcmd pid VM.native_memory detail.diff(summary.diff)做一下 summary 或者 detail 级别的 diff。 可以看到 jcmd 分析出来的内存十分详细,包括堆内、线程以及 gc(所以上述其他内存异常其实都可以用 nmt 来分析),这边堆外内存我们重点关注 Internal 的内存增长,如果增长十分明显的话那就是有问题了。
使用 strace 命令来监控内存分配 strace -f -e “brk,mmap,munmap” -p pid
关键还是要看错误日志栈,找到可疑的对象,搞清楚它的回收机制,然后去分析对应的对象。比如 DirectByteBuffer 分配内存的话,是需要 full GC 或者手动 system.gc 来进行回收的(所以最好不要使用-XX:+DisableExplicitGC)。
那么其实我们可以跟踪一下 DirectByteBuffer 对象的内存情况,通过jmap -histo:live pid手动触发 fullGC 来看看堆外内存有没有被回收。如果被回收了,那么大概率是堆外内存本身分配的太小了,通过-XX:MaxDirectMemorySize进行调整。如果没有什么变化,那就要使用 jmap 去分析那些不能被 gc 的对象,以及和 DirectByteBuffer 之间的引用关系了。
gc 问题
堆内内存泄漏总是和 GC 异常相伴。不过 GC 问题不只是和内存问题相关,还有可能引起 CPU 负载、网络问题等系列并发症,只是相对来说和内存联系紧密些
--> 使用 jstat 来获取当前 GC 分代变化信息 --> 通过 GC 日志来排查问题的,在启动参数中加上-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps来开启 GC 日志。
youngGC 过频繁
youngGC 频繁一般是短周期小对象较多,先考虑是不是 Eden 区/新生代设置的太小了,看能否通过调整-Xmn、-XX:SurvivorRatio 等参数设置来解决问题。如果参数正常,但是 young gc 频率还是太高,就需要使用 Jmap 和 MAT 对 dump 文件进行进一步排查了。
youngGC 耗时过长
耗时过长问题就要看 GC 日志里耗时耗在哪一块了。以 G1 日志为例,可以关注 Root Scanning、Object Copy、Ref Proc 等阶段。Ref Proc 耗时长,就要注意引用相关的对象。Root Scanning 耗时长,就要注意线程数、跨代引用。Object Copy 则需要关注对象生存周期。
触发 fullGC
G1 中更多的还是 mixedGC,但 mixedGC 可以和 youngGC 思路一样去排查
触发 fullGC 了一般都会有问题,G1 会退化使用 Serial 收集器来完成垃圾的清理工作,暂停时长达到秒级别,可以说是半跪了。
fullGC 的原因
并发阶段失败:在并发标记阶段,MixGC 之前老年代就被填满了,那么这时候 G1 就会放弃标记周期。这种情况,可能就需要增加堆大小,或者调整并发标记线程数-XX:ConcGCThreads。 晋升失败:在 GC 的时候没有足够的内存供存活/晋升对象使用,所以触发了 Full GC。这时候可以通过-XX:G1ReservePercent来增加预留内存百分比,减少-XX:InitiatingHeapOccupancyPercent来提前启动标记,-XX:ConcGCThreads来增加标记线程数也是可以的。 大对象分配失败:大对象找不到合适的 region 空间进行分配,就会进行 fullGC,这种情况下可以增大内存或者增大-XX:G1HeapRegionSize。 程序主动执行 System.gc():不要随便写就对了。
我们可以在启动参数中配置-XX:HeapDumpPath=/xxx/dump.hprof来 dump fullGC 相关的文件,并通过 jinfo 来进行 gc 前后的 dump 这样得到 2 份 dump 文件,对比后主要关注被 gc 掉的问题对象来定位问题。
jinfo -flag +HeapDumpBeforeFullGC pid jinfo -flag +HeapDumpAfterFullGC pid jinfo -flag +HeapDumpBeforeFullGC pid jinfo -flag +HeapDumpAfterFullGC pid
安全
java
知识点
静态内部类
特点
1.全局唯一,任何一次的修改都是全局性的影响 2.只加载一次,优先于非静态 3.使用方式上不依赖于实例对象。 4.生命周期属于类级别,从JVM 加载开始到JVM卸载结束。
静态类和非静态类区别
1.内部静态类不需要有指向外部类的引用。但非静态内部类需要持有对外部类的引用 2.非静态内部类能够访问外部类的静态和非静态成员。静态类不能访问外部类的非静态成员。他只能访问外部类的静态成员 3.一个非静态内部类不能脱离外部类实体被创建,一个非静态内部类可以访问外部类的数据和方法,因为他就在外部类里面
总结
其实就是静态类不用先创建外部类。可以静态类看做外部类的静态变量,使用就不要外部类实例;而非静态就必须先实例化。
JMM(Java Memory Model,Java 内存模型)
程序最终执行的效果会依赖于具体的处理器,而不同的处理器的规则又不一样,不同的处理器之间可能差异很大,因此同样的一段代码,可能在处理器 A 上运行正常,而在处理器 B 上运行的结果却不一致。同理,在没有 JMM 之前,不同的 JVM 的实现,也会带来不一样的“翻译”结果。 Java 非常需要一个标准,来让 Java 开发者、编译器工程师和 JVM 工程师能够达成一致。达成一致后,我们就可以很清楚的知道什么样的代码最终可以达到什么样的运行效果,让多线程运行结果可以预期,这个标准就是 JMM,这就是需要 JMM 的原因。
JMM 是规范
JMM 是和多线程相关的一组规范,需要各个 JVM 的实现来遵守 JMM 规范,以便于开发者可以利用这些规范,更方便地开发多线程程序
JMM 是工具类和关键字的原理
各种同步工具和关键字,包括 volatile、synchronized、Lock 等,其实它们的原理都涉及 JMM。正是 JMM 的参与和帮忙,才让各个同步工具和关键字能够发挥作用,帮我们开发出并发安全的程序。
指令重排序
编译器、JVM 或者 CPU 都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是重排序。
(1)编译器优化 编译器(包括 JVM、JIT 编译器等)出于优化的目的,例如当前有了数据 a,把对 a 的操作放到一起效率会更高,避免读取 b 后又返回来重新读取 a 的时间开销,此时在编译的过程中会进行一定程度的重排。不过重排序并不意味着可以任意排序,它需要保证重排序后,不改变单线程内的语义,否则如果能任意排序的话,程序早就逻辑混乱了。 (2)CPU 重排序 CPU 同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。所以即使之前编译器不发生重排,CPU 也可能进行重排,我们在开发中,一定要考虑到重排序带来的后果。 (3) 内存的“重排序” 内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果,所以这里的“重排序”打了双引号。由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。
原子操作
内存可见性
案例1
public class Visibility { int x = 0; public void write() { x = 1; } public void read() { int y = x; } }
write 方法,作用是给 x 赋值,代码中,把 x 赋值为 1,由于 x 的初始值是 0,所以执行 write 方法相当于改变了 x 的值; read 方法,作用是把 x 读取出来,读取的时候我们用了一个新的 int 类型变量的 y 来接收 x 的值。
假设线程 1 的工作内存还未同步给主内存,此时假设线程 2 开始读取,那么它读到的 x 值不是 1,而是 0,也就是说虽然此时线程 1 已经把 x 的值改动了,但是对于第 2 个线程而言,根本感知不到 x 的这个变化,这就产生了可见性问题。
解决问题
加了 volatile 关键字之后,只要第 1 个线程修改完了 x 的值,那么当第 2 个线程想读取 x 的时候,它一定可以读取到 x 的最新的值,而不可能读取到旧值。
synchronized 不仅保证了原子性,还保证了可见性 synchronized 不仅保证了临界区内最多同时只有一个线程执行操作,同时还保证了在前一个线程释放锁之后,之前所做的所有修改,都能被获得同一个锁的下一个线程所看到,也就是能读取到最新的值。因为如果其他线程看不到之前所做的修改,依然也会发生线程安全问题。
主内存和工作内存的关系
从下往上分别是内存,L3 缓存、L2 缓存、L1 缓存,寄存器,然后最上层是 CPU 的 4个核心。从内存,到 L3 缓存,再到 L2 和 L1 缓存,它们距离 CPU 的核心越来越近了,越靠近核心,其容量就越小,但是速度也越快。正是由于缓存层的存在,才让我们的 CPU 能发挥出更好的性能。
线程间对于共享变量的可见性问题,并不是直接由多核引起的,而是由我们刚才讲到的这些 L3 缓存、L2 缓存、L1 缓存,也就是多级缓存引起的:每个核心在获取数据时,都会将数据从内存一层层往上读取,同样,后续对于数据的修改也是先写入到自己的 L1 缓存中,然后等待时机再逐层往下同步,直到最终刷回内存。
假设 core 1 修改了变量 a 的值,并写入到了 core 1 的 L1 缓存里,但是还没来得及继续往下同步,由于 core 1 有它自己的的 L1 缓存,core 4 是无法直接读取 core 1 的 L1 缓存的值的,那么此时对于 core 4 而言,变量 a 的值就不是 core 1 修改后的最新的值,core 4 读取到的值可能是一个过期的值,从而引起多线程时可见性问题的发生。
什么是主内存和工作内存
每个线程只能够直接接触到工作内存,无法直接操作主内存,而工作内存中所保存的正是主内存的共享变量的副本,主内存和工作内存之间的通信是由 JMM 控制的。
主内存和工作内存的关系
(1)所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝; (2)线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改; (3) 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。
什么是 happens-before 规则?
public class Visibility { int x = 0; public void write() { x = 1; } public void read() { int y = x; } }
如果有两个线程,分别执行 write 和 read 方法,那么由于这两个线程之间没有相互配合的机制,所以 write 和 read 方法内的代码不具备 happens-before 关系,其中的变量的可见性无法保证
规则
单线程规则
这一个 happens-before 的规则非常重要,因为如果对于同一个线程内部而言,后面语句都不能保证可以看见前面的语句的执行结果的话,那会造成非常严重的后果,程序的逻辑性就无法保证了。
锁操作规则(synchronized 和 Lock 接口等)
有线程 A 和线程 B 这两个线程。线程 A 在解锁之前的所有操作,对于线程 B 的对同一个锁的加锁之后的所有操作而言,都是可见的。这就是锁操作的 happens-before 关系的规则。
volatile 变量规则
对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作。 这就代表了如果变量被 volatile 修饰,那么每次修改之后,其他线程在读取这个变量的时候一定能读取到该变量最新的值。我们之前介绍过 volatile 关键字,知道它能保证可见性,而这正是由本条规则所规定的。
线程启动规则
左侧区域是线程 A 启动了一个子线程 B,而右侧区域是子线程 B,那么子线程 B 在执行 run 方法里面的语句的时候,它一定能看到父线程在执行 threadB.start() 前的所有操作的结果。
线程 join 规则
join 可以让线程之间等待,假设线程 A 通过调用 threadB.start() 启动了一个新线程 B,然后调用 threadB.join() ,那么线程 A 将一直等待到线程 B 的 run 方法结束(不考虑中断等特殊情况),然后 join 方法才返回。在 join 方法返回后,线程 A 中的所有后续操作都可以看到线程 B 的 run 方法中执行的所有操作的结果,也就是线程 B 的 run 方法里面的操作 happens-before 线程 A 的 join 之后的语句
中断规则
对线程 interrupt 方法的调用 happens-before 检测该线程的中断事件。 也就是说,如果一个线程被其他线程 interrupt,那么在检测中断时(比如调用 Thread.interrupted 或者 Thread.isInterrupted 方法)一定能看到此次中断的发生,不会发生检测结果不准的情况。
并发工具类的规则
线程安全的并发容器(如 HashTable)在 get 某个值时一定能看到在此之前发生的 put 等存入操作的结果。也就是说,线程安全的并发容器的存入操作 happens-before 读取操作。 信号量(Semaphore)它会释放许可证,也会获取许可证。这里的释放许可证的操作 happens-before 获取许可证的操作,也就是说,如果在获取许可证之前有释放许可证的操作,那么在获取时一定可以看到。 Future:Future 有一个 get 方法,可以用来获取任务的结果。那么,当 Future 的 get 方法得到结果的时候,一定可以看到之前任务中所有操作的结果,也就是说 Future 任务中的所有操作 happens-before Future 的 get 操作。 线程池:要想利用线程池,就需要往里面提交任务(Runnable 或者 Callable),这里面也有一个 happens-before 关系的规则,那就是提交任务的操作 happens-before 任务的执行。
单例模式的双重检查锁模式为什么必须加 volatile?
public class Singleton { private static volatile Singleton singleton; private Singleton() { } public static Singleton getInstance() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }
双重检查锁模式的写法 我们进行了两次 if (singleton == null) 检查,这就是“双重检查锁”这个名字的由来。这种写法是可以保证线程安全的,假设有两个线程同时到达 synchronized 语句块,那么实例化代码只会由其中先抢到锁的线程执行一次,而后抢到锁的线程会在第二个 if 判断中发现 singleton 不为 null,所以跳过创建实例的语句。再后面的其他线程再来调用 getInstance 方法时,只需判断第一次的 if (singleton == null) ,然后会跳过整个 if 块,直接 return 实例化后的对象。
在双重检查锁模式中为什么需要使用 volatile 关键字
在双重检查锁模式中,给 singleton 这个对象加了 volatile 关键字,那**为什么要用 volatile 呢?**主要就在于 singleton = new Singleton() ,它并非是一个原子操作,事实上,在 JVM 中上述语句至少做了以下这 3 件事:
第一步是给 singleton 分配内存空间; 然后第二步开始调用 Singleton 的构造函数等,来初始化 singleton; 最后第三步,将 singleton 对象指向分配的内存空间(执行完这步 singleton 就不是 null 了)
如果是 1-3-2,那么在第 3 步执行完以后,singleton 就不是 null 了,可是这时第 2 步并没有执行,singleton 对象未完成初始化,它的属性的值可能不是我们所预期的值。假设此时线程 2 进入 getInstance 方法,由于 singleton 已经不是 null 了,所以会通过第一重检查并直接返回,但其实这时的 singleton 并没有完成初始化,所以使用这个实例的时候会报错
多线程
并发编程三要素
1.原子性
原子性指的是一个或者多个操作,要么全部执行并且在执行的过程中不被其他操作打断,要么就全部都不执行。
2.可见性
可见性指多个线程操作一个共享变量时,其中一个线程对变量进行修改后,其他线程可以立即看到修改的结果。 实现可见性的方法: synchronized或者Lock:保证同一个时刻只有一个线程获取锁执行代码,锁释放之前把最新的值刷新到主内存,实现可见性。
3.有序性
有序性,即程序的执行顺序按照代码的先后顺序来执行。
线程理解
用户线程
UTL-用户线程
操作系统不知用户多线程存在
内核线程
KTL-内核线程
JVM用的是内核线程
线程上下文
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
空间
子主题
多线程的价值
1.发挥多核CPU的优势
多线程,可以真正发挥出多核CPU的优势来,达到充分利用CPU的目的,采用多线程的方式去同时完成几件事情而不互相干扰。
2.防止阻塞
从程序运行效率的角度来看,单核CPU不但不会发挥出多线程的优势,反而会因为在单核CPU上运行多线程导致线程上下文的切换,而降低程序整体的效率。但是单核CPU我们还是要应用多线程,就是为了防止阻塞。试想,如果单核CPU使用单线程,那么只要这个线程阻塞了,比方说远程读取某个数据吧,对端迟迟未返回又没有设置超时时间,那么你的整个程序在数据返回回来之前就停止运行了。多线程可以防止这个问题,多条线程同时运行,哪怕一条线程的代码执行读取数据阻塞,也不会影响其它任务的执行。
3. 便于建模
这是另外一个没有这么明显的优点了。假设有一个大的任务A,单线程编程,那么就要考虑很多,建立整个程序模型比较麻烦。但是如果把这个大的任务A分解成几个小任务,任务B、任务C、任务D,分别建立程序模型,并通过多线程分别运行这几个任务,那就简单很多了。
多线程的4种创建方式
继承Thread类
优点
编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。
缺点
线程类已经继承了Thread类,所以不能再继承其他父类。
实现Runnable
优点
多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
缺点
编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。
实现Callable
与Runnable区别
Callable规定(重写)的方法是call(),Runnable规定(重写)的方法是run()。 Callable的任务执行后可返回值,而Runnable的任务是不能返回值的。 Call方法可以抛出异常,run方法不可以。 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。
future
在并发编程中,我们经常用到非阻塞的模型,在之前的多线程的三种实现中,不管是继承thread类还是实现runnable接口,都无法保证获取到之前的执行结果。通过实现Callback接口,并用Future可以来接收多线程的执行结果。 Future表示一个可能还没有完成的异步任务的结果,针对这个结果可以添加Callback以便在任务执行成功或失败后作出相应的操作。
作用
Future 最主要的作用是,比如当做一定运算的时候,运算过程可能比较耗时,有时会去查数据库,或是繁重的计算,比如压缩、加密等,在这种情况下,如果我们一直在原地等待方法返回,显然是不明智的,整体程序的运行效率会大大降低。我们可以把运算的过程放到子线程去执行,再通过 Future 去控制子线程执行的计算过程,最后获取到计算结果。这样一来就可以把整个程序的运行效率提高,是一种异步的思想。
Callable和Future的关系
Callable 接口相比于 Runnable 的一大优势是可以有返回结果,那这个返回结果怎么获取呢?就可以用 Future 类的 get 方法来获取 。因此,Future 相当于一个存储器,它存储了 Callable 的 call 方法的任务结果。除此之外,我们还可以通过 Future 的 isDone 方法来判断任务是否已经执行完毕了,还可以通过 cancel 方法取消这个任务,或限时获取任务的结果等,总之 Future 的功能比较丰富。有了这样一个从宏观上的概念之后,我们就来具体看一下 Future 类的主要方法。
public interface Future<V> { boolean cancel(boolean mayInterruptIfRunning); boolean isCancelled(); boolean isDone(); V get() throws InterruptedException, ExecutionException; V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutExceptio }
(1)最常见的就是当执行 get 的时候,任务已经执行完毕了,可以立刻返回,获取到任务执行的结果。 (2)任务还没有结果,这是有可能的,比如我们往线程池中放一个任务,线程池中可能积压了很多任务,还没轮到我去执行的时候,就去 get 了,在这种情况下,相当于任务还没开始;还有一种情况是任务正在执行中,但是执行过程比较长,所以我去 get 的时候,它依然在执行的过程中。无论是任务还没开始或在进行中,我们去调用 get 的时候,都会把当前的线程阻塞,直到任务完成再把结果返回来。 (3)任务执行过程中抛出异常,一旦这样,我们再去调用 get 的时候,就会抛出 ExecutionException 异常,不管我们执行 call 方法时里面抛出的异常类型是什么,在执行 get 方法时所获得的异常都是 ExecutionException。 (4)任务被取消了,如果任务被取消,我们用 get 方法去获取结果时则会抛出 CancellationException。 (5)任务超时,我们知道 get 方法有一个重载方法,那就是带延迟参数的,调用了这个带延迟参数的 get 方法之后,如果 call 方法在规定时间内正常顺利完成了任务,那么 get 会正常返回;但是如果到达了指定时间依然没有完成任务,get 方法则会抛出 TimeoutException,代表超时了。
isDone() 方法:判断是否执行完毕 下面我们再接着看看 Future 的一些其他方法,比如说 isDone() 方法,该方法是用来判断当前这个任务是否执行完毕了。 需要注意的是,这个方法如果返回 true 则代表执行完成了;如果返回 false 则代表还没完成。但这里如果返回 true,并不代表这个任务是成功执行的,比如说任务执行到一半抛出了异常。那么在这种情况下,对于这个 isDone 方法而言,它其实也是会返回 true 的,因为对它来说,虽然有异常发生了,但是这个任务在未来也不会再被执行,它确实已经执行完毕了。所以 isDone 方法在返回 true 的时候,不代表这个任务是成功执行的,只代表它执行完毕了。
cancel 方法:取消任务的执行 下面我们再来看一下 cancel 方法,如果不想执行某个任务了,则可以使用 cancel 方法,会有以下三种情况: 第一种情况最简单,那就是当任务还没有开始执行时,一旦调用 cancel,这个任务就会被正常取消,未来也不会被执行,那么 cancel 方法返回 true。 第二种情况也比较简单。如果任务已经完成,或者之前已经被取消过了,那么执行 cancel 方法则代表取消失败,返回 false。因为任务无论是已完成还是已经被取消过了,都不能再被取消了。 第三种情况比较特殊,就是这个任务正在执行,这个时候执行 cancel 方法是不会直接取消这个任务的,而是会根据我们传入的参数做判断。cancel 方法是必须传入一个参数,该参数叫作 mayInterruptIfRunning,它是什么含义呢?如果传入的参数是 true,执行任务的线程就会收到一个中断的信号,正在执行的任务可能会有一些处理中断的逻辑,进而停止,这个比较好理解。如果传入的是 false 则就代表不中断正在运行的任务,也就是说,本次 cancel 不会有任何效果,同时 cancel 方法会返回 false。 那么如何选择传入 true 还是 false 呢? 传入 true 适用的情况是,明确知道这个任务能够处理中断。 传入 false 适用于什么情况呢? 如果我们明确知道这个线程不能处理中断,那应该传入 false。 我们不知道这个任务是否支持取消(是否能响应中断),因为在大多数情况下代码是多人协作的,对于这个任务是否支持中断,我们不一定有十足的把握,那么在这种情况下也应该传入 false。 如果这个任务一旦开始运行,我们就希望它完全的执行完毕。在这种情况下,也应该传入 false。 这就是传入 true 和 false 的不同含义和选择方法。
isCancelled() 方法:判断是否被取消 最后一个方法是 isCancelled 方法,判断是否被取消,它和 cancel 方法配合使用,比较简单。 以上就是关于 Future 的主要方法的介绍了
/** * 描述: 演示一个 Future 的使用方法 */ public class OneFuture { public static void main(String[] args) { ExecutorService service = Executors.newFixedThreadPool(10); Future<Integer> future = service.submit(new CallableTask()); try { System.out.println(future.get()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } service.shutdown(); } static class CallableTask implements Callable<Integer> { @Override public Integer call() throws Exception { Thread.sleep(3000); return new Random().nextInt(); } } }
public class GetException { public static void main(String[] args) { ExecutorService service = Executors.newFixedThreadPool(20); Future<Integer> future = service.submit(new CallableTask()); try { for (int i = 0; i < 5; i++) { System.out.println(i); Thread.sleep(500); } System.out.println(future.isDone()); future.get(); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } static class CallableTask implements Callable<Integer> { @Override public Integer call() throws Exception { throw new IllegalArgumentException("Callable抛出异常"); } } }
线程池中有一些线程来执行任务。重点在图的左侧,可以看到有一个 submit 方法,该方法往线程池中提交了一个 Task,这个 Task 实现了 Callable 接口,当我们去给线程池提交这个任务的时候,调用 submit 方法会立刻返回一个 Future 类型的对象,这个对象目前内容是空的,其中还不包含计算结果,因为此时计算还没有完成。 当计算一旦完成时,也就是当我们可以获取结果的时候,线程池便会把这个结果填入到之前返回的 Future 中去(也就是 f 对象),而不是在此时新建一个新的 Future。这时就可以利用 Future 的 get 方法来获取到任务的执行结果了。
注意点
1. 当 for 循环批量获取 Future 的结果时容易 block,get 方法调用时应使用 timeout 限制 对于 Future 而言,第一个注意点就是,当 for 循环批量获取 Future 的结果时容易 block,在调用 get 方法时,应该使用 timeout 来限制。
2. Future 的生命周期不能后退 Future 的生命周期不能后退,一旦完成了任务,它就永久停在了“已完成”的状态,不能从头再来,也不能让一个已经完成计算的 Future 再次重新执行任务。 这一点和线程、线程池的状态是一样的,线程和线程池的状态也是不能后退的。关于线程的状态和流转路径,第 03 讲已经讲过了,如图所示。
Future 产生新的线程了吗
Callable 和 Future 本身并不能产生新的线程,它们需要借助其他的比如 Thread 类或者线程池才能执行任务。例如,在把 Callable 提交到线程池后,真正执行 Callable 的其实还是线程池中的线程,而线程池中的线程是由 ThreadFactory 产生的,这里产生的新线程与 Callable、Future 都没有关系,所以 Future 并没有产生新的线程。
线程池创建
线程池就是提前创建若干个线程,如果有任务需要处理,线程池里的线程就会处理任务,处理完之后线程并不会被销毁,而是等待下一个任务。由于创建和销毁线程都是消耗系统资源的,所以当你想要频繁的创建和销毁线程的时候就可以考虑使用线程池来提升系统的性能。
Java线程的生命周期
新建、就绪、运行、阻塞、死亡
线程的通信与协作
1.线程就绪:调用start(),以及sleep()休眠超时进入线程就绪状态 2.线程阻塞:调用sleep()、wait()、join()方法进入阻塞状态 3.线程运行:调用run()、notify()、notifyAll()进入线程运行状态 4.线程死亡:run()执行完成进入线程死亡状态
1)新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread(); 2)就绪状态(Runnable):当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行; 3)运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就 绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中; 4)阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种: 1.等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态; 2.同步阻塞 — 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态; 3.其他阻塞 — 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。 5)死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
1.sleep()和yield()和join() 1)sleep()方法作用:让当前线程睡眠一段时间,期间不会释放任何持有的锁。 2)yield()方法作用:让出该线程的时间片给其它线程。线程调用了yield()方法,表示放弃当前获得的CPU时间片,回到就绪状态。最后由线程调度重新选择就绪状态的线程分配CPU资源。 3)join()方法作用:暂停当前线程,等待被调用线程指向结束之后再继续执行。
注意: 1)sleep(long)方法仅释放CPU使用权,锁仍然占用。 2)调用join()的时候,当前线程不会释放掉锁。
2.wait()和notify() 方法和notifyAll()方法 1)wait()方法的作用:让该线程处于等待状态。 2)notify()方法的作用:唤醒处于wait的线程。 3)notifyAll()方法的作用:唤醒所有处于wait状态的线程。
注意: 1)wait()方法会释放CPU执行权 和 占有的锁。 2) 线程调用wait()方法后,让该线程处于等待状态。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒。wait和notify必须配套使用,即必须使用同一把锁调用。
wait/notify/notifyAll方法的使用注意事项
为什么 wait 方法必须在 synchronized 保护的同步代码中使用?
在使用 wait 方法时,必须把 wait 方法写在 synchronized 保护的 while 代码块中,并始终判断执行条件是否满足,如果满足就往下继续执行,如果不满足就执行 wait 方法,而在执行 wait 方法之前,必须先持有对象的 monitor 锁,也就是通常所说的 synchronized 锁。 wait 方法会释放 monitor 锁,这也要求我们必须首先进入到 synchronized 内持有这把锁。
while (condition does not hold) obj.wait();
即便被虚假唤醒了,也会再次检查while里面的条件,如果不满足条件,就会继续wait,也就消除了虚假唤醒的风险。
为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?
因为 Java 中每个对象都有一把称之为 monitor 监视器的锁,由于每个对象都可以上锁,这就要求在对象头中有一个用来保存锁信息的位置。这个锁是对象级别的,而非线程级别的,wait/notify/notifyAll 也都是锁级别的操作,它们的锁属于对象,所以把它们定义在 Object 类中是最合适,因为 Object 类是所有对象的父类。 因为如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。
wait/notify 和 sleep 方法的异同?
相同点
它们都可以让线程阻塞。 它们都可以响应 interrupt 中断:在等待的过程中如果收到中断信号,都可以进行响应,并抛出 InterruptedException 异常。
不同点
wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。 在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁。 sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复。 wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。
线程调度策略
在Java多线程环境中,为保证所有线程的执行能按照一定的规则执行,JVM实现了一个线程调度器,它定义了线程调度的策略。 在 JVM 中体现为让可运行池中优先级高的线程拥有CPU 使用权。
(1)线程体中调用了yield方法让出了对cpu的占用权利 (2)线程体中调用了sleep方法使线程进入睡眠状态 (3)线程由于IO操作受到阻塞 (4)另外一个更高优先级线程出现 (5)在支持时间片的系统中,该线程的时间片用完
多线程与并发相关涉及的整体技能框架
多线程需要学习哪些技能 线程池涉及的技术 线程锁需要掌握的技术 并发工具类需要掌握的技术 并发容器需要掌握的技术
Java线程池相关
1. 为什么需要线程池 2. 线程池的处理流程 3. 线程池相关的核心参数 4. 线程池使用的注意事项
java中为了提高并发度,可以使用多线程共同执行,但是如果有大量线程短时间之内被创建和销毁,会占用大量的系统时间,影响系统效率。
线程池实现原理
4种拒绝策略
拒绝后也可以采用日志或存储下来做重推入队
https://www.cnblogs.com/warehouse/p/10720781.html
线程池原理分析
3步
1. 首先判断当前线程池中之行的任务数量是否小于 corePoolSize // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
2.如果当前之行的任务数量大于等于 corePoolSize 的时候就会走到这里 // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态才会被并且队列可以加入任务,该任务才会被加入进去
//3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。
Executors四种线程池的创建 ThreadPoolExecutor的一种线程池创建
1. newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务。 -链表队列,单线程 -缺点:无界链表,可能会有OOM情况发现
子主题
如果当前运行的线程数少于 corePoolSize,则创建一个新的线程执行任务; 当前线程池中有一个运行的线程后,将任务加入 LinkedBlockingQueue 线程执行完当前的任务后,会在循环中反复从LinkedBlockingQueue 中获取任务来执行;
public class ThreadPoolTest04 { public static void main(String[] args) { ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); for (int i = 0; i < 10; i++) { singleThreadExecutor.execute(new Runnable() { @Override public void run() { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()); } }); } } }
2. newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数。 -链表队列,固定线程 -缺点:无界链表,可能会有OOM情况发现
FixedThreadPool 的 corePoolSize 和 maximumPoolSize 都被设置为 nThreads
1.如果当前运行的线程数小于 corePoolSize, 如果再来新任务的话,就创建新的线程来执行任务; 2.当前运行的线程数等于 corePoolSize 后, 如果再来新任务的话,会将任务加入 LinkedBlockingQueue; 3.线程池中的线程执行完 手头的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行;
public class ThreadPoolTest02 { public static void main(String[] args) { //创建一个长度为5的线程池 ExecutorService executorService= Executors.newFixedThreadPool(5); for(int i=0;i<10;i++){ executorService.execute(new Runnable() { @Override public void run() { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()); } }); } } }
不推荐
FixedThreadPool 使用无界队列 LinkedBlockingQueue(队列的容量为 Intger.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响 : 当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize; 由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 FixedThreadPool的源码可以看出创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 被设置为同一个值。 由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数; 运行中的 FixedThreadPool(未执行 shutdown()或 shutdownNow())不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。
3. newCachedThreadPool创建一个可缓存线程池 -阻塞队列,最大线程,也是按需分配线程池 -缺点:最大线程问题
提交任务速度快于线程的处理速度时,就会不断创建新线程
CachedThreadPool 的corePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。
public class ThreadPoolTest01 { public static void main(String[] args) { //创建线程池,可重复利用 ExecutorService executorService= Executors.newCachedThreadPool(); //创建5个线程 for(int i=0;i<10;i++){ int num=i; executorService.execute(new Runnable() { @Override public void run() { System.out.println(Thread.currentThread().getName()+":线程"+num+"已经执行"); } }); } } }
4. newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
public class ThreadPoolTest03 { public static void main(String[] args) { ScheduledExecutorService scheduledExecutorService= Executors.newScheduledThreadPool(5); for(int i=0;i<10;i++){ scheduledExecutorService.schedule(new Runnable() { @Override public void run() { System.out.println(Thread.currentThread().getName()); } },3,TimeUnit.SECONDS); } } }
5. ThreadPoolExecutor自定义参数创建线程池
//线程名 String threadNameStr = new StringBuilder(threadName).append("%d").toString(); //**ThreadFactoryBuilder**:线程工厂类就是将一个线程的执行单元包装成为一个线程对象,比如线程的名称,线程的优先级,线程是否是守护线程等线程; // guava为了我们方便的创建出一个ThreadFactory对象,我们可以使用ThreadFactoryBuilder对象自行创建一个线程. ThreadFactory threadNameVal = new ThreadFactoryBuilder().setNameFormat(threadNameStr).build(); //线程池 return new ThreadPoolExecutor(coreSize, maxSize, timeOut, TimeUnit.SECONDS, new LinkedBlockingQueue<>(queueSize) , threadNameVal, new ThreadPoolExecutor.DiscardPolicy() { public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { logger.info(r.toString() + "执行了拒绝策略"); if (!executor.isShutdown()){ r.run(); } } });
注
Executors 各个方法的弊端: newFixedThreadPool 和 newSingleThreadExecutor: 主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至 OOM。 newCachedThreadPool 和 newScheduledThreadPool: 主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。 ThreaPoolExecutor 创建线程池方式只有一种,就是走它的构造函数,参数自己指定
线程池4种拒绝策略
newThreadPoolExecutor(5, 10, 5, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new ThreadPoolExecutor.DiscardOldestPolicy());
线程池会在两种情况下会拒绝新提交的任务。
第一种情况是当我们调用 shutdown 等方法关闭线程池后,即便此时可能线程池内部依然有没执行完的任务正在执行,但是由于线程池已经关闭,此时如果再向线程池内提交任务,就会遭到拒绝。 第二种情况是线程池没有能力继续处理新提交的任务,也就是工作已经非常饱和的时候。
拒绝策略
抛异常
第一种拒绝策略是 AbortPolicy,这种拒绝策略在拒绝任务时,会直接抛出一个类型为 RejectedExecutionException 的 RuntimeException,让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。
直接丢弃
第二种拒绝策略是 DiscardPolicy,这种拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给你任何的通知,相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失。
丢弃时间最长的
第三种拒绝策略是 DiscardOldestPolicy,如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务,这种策略与第二种不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的,这样就可以腾出空间给新提交的任务,但同理它也存在一定的数据丢失风险。
谁提交谁执行
第四种拒绝策略是 CallerRunsPolicy,相对而言它就比较完善了,当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。这样做主要有两点好处。 第一点新提交的任务不会被丢弃,这样也就不会造成业务损失。 第二点好处是,由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。
总结
为什么不应该自动创建线程池
所谓的自动创建线程池就是直接调用 Executors 的各种方法来生成前面学过的常见的线程池,例如 Executors.newCachedThreadPool()。
SingleThreadExecutor
单线程线程池
newSingleThreadExecutor 和 newFixedThreadPool 的原理是一样的,只不过把核心线程数和最大线程数都直接设置成了 1,但是任务队列仍是无界的 LinkedBlockingQueue,所以也会导致同样的问题,也就是当任务堆积时,可能会占用大量的内存并导致 OOM。
public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>())); }
FixedThreadPool
线程数量固定的线程池
通过往构造函数中传参,创建了一个核心线程数和最大线程数相等的线程池,它们的数量也就是我们传入的参数,这里的重点是使用的队列是容量没有上限的 LinkedBlockingQueue,队列中堆积的任务变多,可能发生OOM
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()); }
CachedThreadPool
缓存线程池
这里的 CachedThreadPool 和前面两种线程池不一样的地方在于任务队列使用的是 SynchronousQueue,SynchronousQueue 本身并不存储任务,而是对任务直接进行转发,这本身是没有问题的,但你会发现构造函数的第二个参数被设置成了 Integer.MAX_VALUE,这个参数的含义是最大线程数,所以由于 CachedThreadPool 并不限制线程的数量,当任务数量特别多的时候,就可能会导致创建非常多的线程,最终超过了操作系统的上限而无法创建新线程,或者导致内存不足。
public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>()); }
ScheduledThreadPool 和 SingleThreadScheduledExecutor
它采用的任务队列是 DelayedWorkQueue,这是一个延迟队列,同时也是一个无界队列,所以和 LinkedBlockingQueue 一样,如果队列中存放过多的任务,就可能导致 OOM。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,new DelayedWorkQueue()); }
缺点
FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。 CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
优点
1)重用存在的线程,减少对象创建销毁的开销。 2)可有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。 3)提供定时执行、定期执行、单线程、并发数控制等功能。
推荐
推荐使用 ThreadPoolExecutor 构造函数创建线程池,更加明确线程池的运行规则,规避资源耗尽的风险
方式一:通过ThreadPoolExecutor构造函数实现
方式二:通过 Executor 框架的工具类 Executors 来实现 我们可以创建三种类型的 ThreadPoolExecutor: FixedThreadPool SingleThreadExecutor CachedThreadPool
线程池大小确定
上下文切换: 多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。 I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。 如何判断是 CPU 密集任务还是 IO 密集任务? CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。 但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
例
@Configuration public class TreadPoolConfig { /** * 消费队列线程 * @return */ @Bean(value = "consumerQueueThreadPool") public ExecutorService buildConsumerQueueThreadPool(){ ThreadFactory namedThreadFactory = new ThreadFactoryBuilder() .setNameFormat("consumer-queue-thread-%d").build(); ExecutorService pool = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(5),namedThreadFactory,new ThreadPoolExecutor.AbortPolicy()); return pool ; } }
@Resource(name = "consumerQueueThreadPool") private ExecutorService consumerQueueThreadPool; @Override public void execute() { //消费队列 for (int i = 0; i < 5; i++) { consumerQueueThreadPool.execute(new ConsumerQueueThread()); } }
线程池隔离
描述:如果我们很多业务都依赖于同一个线程池,当其中一个业务因为各种不可控的原因消耗了所有的线程,导致线程池全部占满。 这样其他的业务也就不能正常运转了,这对系统的打击是巨大的。
Executor框架
子主题
主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象。 把创建完成的实现 Runnable/Callable接口的 对象直接交给 ExecutorService 执行: ExecutorService.execute(Runnable command))或者也可以把 Runnable 对象或Callable 对象提交给 ExecutorService 执行(ExecutorService.submit(Runnable task)或 ExecutorService.submit(Callable <T> task))。 如果执行 ExecutorService.submit(…),ExecutorService 将返回一个实现Future接口的对象(我们刚刚也提到过了执行 execute()方法和 submit()方法的区别,submit()会返回一个 FutureTask 对象)。由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行。 最后,主线程可以执行 FutureTask.get()方法来等待任务执行完成。主线程也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。
ThreadPoolExecutor类
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列 ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可 RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务 )
线程池参数关系
最重要3个参数
corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。 maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
其他常见参数
keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁; unit : keepAliveTime 参数的时间单位。 threadFactory :executor 创建新线程的时候会用到。 handler :饱和策略。关于饱和策略下面单独介绍一下。
饱和策略
ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。 ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任务丢弃任何一个任务请求的话,你可以选择这个策略。 ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。 ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。
状态
RUNNING 自然是运行状态,指可以接受任务执行队列里的任务 SHUTDOWN 指调用了 shutdown() 方法,不再接受新任务了,但是队列里的任务得执行完毕。 STOP 指调用了 shutdownNow() 方法,不再接受新任务,同时抛弃阻塞队列里的所有任务并中断所有正在执行任务。 TIDYING 所有任务都执行完毕,在调用 shutdown()/shutdownNow() 中都会尝试更新为这个状态。 TERMINATED 终止状态,当执行 terminated() 后会更新为这个状态。
4大常用的线程锁
多线程的缘由 多线程并发面临的问题 Java线程锁总结
Synchronized ReentrantLock Semaphore AtomicInteger
Synchronized
Hotspot 的实现
子主题
内存知识
JMM规范
1、如果想要把一个变量从主内存复制到工作内存,就需要按照顺序执行read和load操作,如果把变量从工作内存同步到主内存中,就要按照顺序执行store和write操作。但Java内存模型只要求上述操作必须按照顺序执行,而没有保证必须是连续执行。 2、程序中如果有同步操作才会有lock和unlock操作,一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,执行多次后,必须执行相对应次数但unlock操作,变量才会被解锁。lock和unlock必须成对出现。 3、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或者assign操作初始化变量但值。
内存模型
内存模型
线程间通信
子主题
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。 unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放之后的变量才可以被其他线程锁定。 read(读取):作用于主内存的变量,读取主内存变量的值。 load(载入):作用于主内存的变量,把read操作从主内存中得到的变量值放入到线程本地内存的变量副本中。 use(使用):作用于工作内存的变量,把工作内存中的一个变量传递给执行引擎。 assign(赋值):作用域工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量。 store(存储):作用域工作内存的变量,把工作内存中的一个变量值传输到主内存中,以便随后的write操作。 write(写入):作用域工作内存的变量,把stroe操作从工作内存中一个变量的值传送到主内存的变量中去。
1 )线程A把本地内存A中更新过的共享变量,刷新到主内存当中去。 2 )线程B到主内存中重新读取更新后的共享变量。
JVM内存分布与JVM对象访问机制
Java 对象引用 OOP 位于栈中,Java 对象实例(包含对象头和实例数据,其中实例数据为对象实例的字段数据)位于堆中,Java 对象所属 Java 类的类对象 instanceKlass(包含对象方法和类变量等信息)位于方法区中。 (1)Java 对象引用位于栈中,包含指向堆中的 Java 对象实例的指针,从而可以访问 Java 对象实例。 (2)Java 对象实例由对象头和实例数据两部分组成,其中对象头中包含元数据指针,通过元数据指针访问方法区的 Java 类元数据对象 instanceKlass。
java对象
对象组成
Header-对象头
知识点: 不同位下JVM对象头大小不一样 1.在64位JVM下,存放Class指针的空间大小是8字节,MarkWord是8字节,对象头为16字节。 2.64位JVM开启指针压缩的情况下,存放Class指针的空间大小是4字节,MarkWord是8字节,对象头为12字节。
Mark Word-对象自身运行时的数据-
1. HashCode、GC分代年龄 2. 锁状态标志、 线程持有的锁(monitor)、 偏向线程ID、偏向时间戳 3. GC标记
用于存储对象的哈希码和GC相关信息,如对象是否可达、是否被标记为垃圾等。Mark Word在不同的锁状态下存储的内容不同,包括无锁状态、偏向锁状态等。
klass指针-类型指针
指向此对象的类元数据的指针,也就是通过这个指针来知道这个对象是哪个类的实例,但不是所有的虚拟机实现都是通过这个来查找类的元数据的。
指向对象的类元信息对象,包括对象的类型、继承关系、成员方法、静态变量等。
实例数据
实例数据就是对象真正存储的数据区,各种类型的字段内容。
对齐填充
这部分内容没什么别的意义,就是起着占位符的作用,主要是因为HotSpot虚拟机的内存管理要求对象的大小必须是8字节的整数倍,而对象头正好是8个字节的整数倍,但是实例数据不一定,所以需要对齐填充补全。
synchronized锁原理
synchronized的作用
在Java中,synchronized关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized代码段不被多个线程同时执行。 synchronized既可以加在一段代码上,也可以加在方法上。
偏向锁 轻量级 重量级
偏向锁
我贴一个名字,空杯归零,归我了 多数时间中,只有一个线程在访问变量,所以贴个条,表示我在访问变量 在mark word设置标识
是否提高效率
不一定 在明确知道多个线程竞争的时候,系统会把资源大量消耗在撤消上
轻量级锁
又来一个线程,与第一个竞争
自旋锁
又来一个人,与第一个人竞争,进行CAS比较获锁,叫自旋锁 线程数量过多的时候用自旋不合适
重量级锁
等待队列,待执行队列
synchronized和ReentrantLock的区别
synchronized是和if、else、for、while一样的关键字,ReentrantLock是类,这是二者的本质区别。既然ReentrantLock是类,那么它就提供了比synchronized更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量,ReentrantLock比synchronized的扩展性体现在几点上: (1)ReentrantLock可以对获取锁的等待时间进行设置,这样就避免了死锁 (2)ReentrantLock可以获取各种锁的信息 (3)ReentrantLock可以灵活地实现多路通知 另外,二者的锁机制其实也是不一样的。ReentrantLock底层调用的是Unsafe的park方法加锁,synchronized操作的应该是对象头中mark word,这点我不能确定。
ReentrantReadWriteLock
ReentrantReadWriteLock简介 ReentrantReadWriteLock特性 ReentrantReadWriteLock的主要成员和结构图 ReentrantReadWriteLock的核心实现 ReentrantReadWriteLock的锁获取与释放
ReadWriteLock
首先明确一下,不是说ReentrantLock不好,只是ReentrantLock某些时候有局限。如果使用ReentrantLock,可能本身是为了防止线程A在写数据、线程B在读数据造成的数据不一致,但这样,如果线程C在读数据、线程D也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。 因为这个,才诞生了读写锁ReadWriteLock。ReadWriteLock是一个读写锁接口,ReentrantReadWriteLock是ReadWriteLock接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。
特点
读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。
Semaphore(ˈseməfɔːr)
信号量的一个最主要的作用就是,来控制那些需要限制并发访问量的资源。具体来讲,信号量会维护“许可证”的计数,而线程去访问共享资源前,必须先拿到许可证。线程可以从信号量中去“获取”一个许可证,一旦线程获取之后,信号量持有的许可证就转移过去了,所以信号量手中剩余的许可证要减一。
同理,线程也可以“释放”一个许可证,如果线程释放了许可证,这个许可证相当于被归还给信号量了,于是信号量中的许可证的可用数量加一。当信号量拥有的许可证数量减到 0 时,如果下个线程还想要获得许可证,那么这个线程就必须等待,直到之前得到许可证的线程释放,它才能获取。由于线程在没有获取到许可证之前不能进一步去访问被保护的共享资源,所以这就控制了资源的并发访问量,这就是整体思路。
场景
慢服务 服务是中间这个方块儿,左侧是请求,右侧是我们所依赖的那个慢服务。出于种种原因(比如计算量大、依赖的下游服务多等),右边的慢服务速度很慢,并且它可以承受的请求数量也很有限,一旦有太多的请求同时到达它这边,可能会导致它这个服务不可用,会压垮它。所以我们必须要保护它,不能让太多的线程同时去访问。那怎么才能做到这件事情呢?
public class SemaphoreDemo1 { public static void main(String[] args) { ExecutorService service = Executors.newFixedThreadPool(50); for (int i = 0; i < 1000; i++) { service.submit(new Task()); } service.shutdown(); } static class Task implements Runnable { @Override public void run() { System.out.println(Thread.currentThread().getName() + "调用了慢服务"); try { //模拟慢服务 Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } } } }
慢服务会被50个线程访问导致服务崩溃 从线程 1 一直到线程 50 都去调用这个慢服务,当然实际调用顺序每次都会不一样,但是这 50 个线程都会去几乎同时调用这个慢服务,在这种情况下,就会导致我们的慢服务崩溃。
所以,必须严格限制能够同时到达该服务的请求数。比如,我们想限制同时不超过 3 个请求来访问该服务,该怎么实现呢?并且这里有一点值得注意,我们的前提条件是,线程池中确实有 50 个线程,线程数肯定超过了 3 个,那么怎么进一步控制这么多的线程不同时访问慢服务呢?我们可以通过信号量来解决这个问题。
方框代表一个许可证为 3 的信号量,每一个绿色的长条代表一个许可证(permit)。现在我们拥有 3 个许可证,并且信号量的特点是非常“慷慨”,只要它持有许可证,别人想请求的话它都会分发的。假设此时 Thread 1 来请求了,在这种情况下,信号量就会把一个许可证给到这边的第一个线程 Thread 1
Thread 1 拿到许可证之后就拥有了访问慢服务的资格,它紧接着就会去访问我们的慢服务,同时,我们的信号量手中持有的许可证也减为了 2。假设这个慢服务速度很慢,可能长时间内不返回,所以在没返回之前,Thread 1 也会不释放许可证,在此期间第二个线程又来请求了
由于信号量手中持有两个许可证,还是可以满足 Thread 2 的需求的,所以就把第二个许可证给了第二个线程。这样一来,第二个线程也拿到了我们的许可证,可以访问右边的慢服务了
如果信号量中的许可证已经没有了,因为原有的 3 个都分给这 3 个线程了。在这种情况下,信号量就可以进一步发挥作用了,此时假设第 4 个线程再来请求找我们信号量拿许可证,由于此时线程 1、线程 2、线程 3 都正在访问“慢服务”,还没归还许可证,而信号量自身也没有更多的许可证了,所以在这个时候就会发生这样的一种情况:
总结
以上的过程,展示了如何利用信号量,去控制在同一时刻最多只有 3 个线程执行某任务的目的,那主要就是通过控制许可证的发放和归还的方式实现的。
用法
首先初始化一个信号量,并且传入许可证的数量,这是它的带公平参数的构造函数:public Semaphore(int permits, boolean fair),传入两个参数,第一个参数是许可证的数量,另一个参数是是否公平。如果第二个参数传入 true,则代表它是公平的策略,会把之前已经等待的线程放入到队列中,而当有新的许可证到来时,它会把这个许可证按照顺序发放给之前正在等待的线程;如果这个构造函数第二个参数传入 false,则代表非公平策略,也就有可能插队,就是说后进行请求的线程有可能先得到许可证。 第二个流程是在建立完这个构造函数,初始化信号量之后,我们就可以利用 acquire() 方法。在调用慢服务之前,让线程来调用 acquire 方法或者 acquireUninterruptibly方法,这两个方法的作用是要获取许可证,这同时意味着只有这个方法能顺利执行下去的话,它才能进一步访问这个代码后面的调用慢服务的方法。如果此时信号量已经没有剩余的许可证了,那么线程就会等在 acquire 方法的这一行代码中,所以它也不会进一步执行下面调用慢服务的方法。我们正是用这种方法,保护了我们的慢服务。 acquire() 和 acquireUninterruptibly() 的区别是:是否能响应中断。acquire() 是可以支持中断的,也就是说,它在获取信号量的期间,假设这个线程被中断了,那么它就会跳出 acquire() 方法,不再继续尝试获取了。而 acquireUninterruptibly() 方法是不会被中断的。 第三步就是在任务执行完毕之后,调用 release() 来释放许可证,比如说我们在执行完慢服务这行代码之后,再去执行 release() 方法,这样一来,许可证就会还给我们的信号量了。
其他主要方法介绍 除了这几个主要方法以外,还有一些其他的方法,我再来介绍一下。 (1)public boolean tryAcquire() tryAcquire 和之前介绍锁的 trylock 思维是一致的,是尝试获取许可证,相当于看看现在有没有空闲的许可证,如果有就获取,如果现在获取不到也没关系,不必陷入阻塞,可以去做别的事。 (2)public boolean tryAcquire(long timeout, TimeUnit unit) 同样有一个重载的方法,它里面传入了超时时间。比如传入了 3 秒钟,则意味着最多等待 3 秒钟,如果等待期间获取到了许可证,则往下继续执行;如果超时时间到,依然获取不到许可证,它就认为获取失败,且返回 false。 (3)availablePermits() 这个方法用来查询可用许可证的数量,返回一个整型的结果。
public class SemaphoreDemo2 { static Semaphore semaphore = new Semaphore(3); public static void main(String[] args) { ExecutorService service = Executors.newFixedThreadPool(50); for (int i = 0; i < 1000; i++) { service.submit(new Task()); } service.shutdown(); } static class Task implements Runnable { @Override public void run() { try { semaphore.acquire(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "拿到了许可证,花费2秒执行慢服务"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("慢服务执行完毕," + Thread.currentThread().getName() + "释放了许可证"); semaphore.release(); } } }
注意点
信号量还有几个注意点: 获取和释放的许可证数量尽量保持一致,否则比如每次都获取 2 个但只释放 1 个甚至不释放,那么信号量中的许可证就慢慢被消耗完了,最后导致里面没有许可证了,那其他的线程就再也没办法访问了; 在初始化的时候可以设置公平性,如果设置为 true 则会让它更公平,但如果设置为 false 则会让总的吞吐量更高。 信号量是支持跨线程、跨线程池的,而且并不是哪个线程获得的许可证,就必须由这个线程去释放。事实上,对于获取和释放许可证的线程是没有要求的,比如线程 A 获取了然后由线程 B 释放,这完全是可以的,只要逻辑合理即可。
AtomicInteger
死锁问题
死锁是一种状态,当两个(或多个)线程(或进程)相互持有对方所需要的资源,却又都不主动释放自己手中所持有的资源,导致大家都获取不到自己想要的资源,所有相关的线程(或进程)都无法继续往下执行,在未改变这种状态之前都不能向前推进,我们就把这种状态称为死锁状态
死锁的影响
数据库
数据库系统不会放任这种情况发生,当数据库检测到这一组事务发生了死锁时,根据策略的不同,可能会选择放弃某一个事务,被放弃的事务就会释放掉它所持有的锁,从而使其他的事务继续顺利进行。此时程序可以重新执行被强行终止的事务,而这个事务现在就可以顺利执行了,因为所有跟它竞争资源的事务都已经在刚才执行完毕,并且释放资源了。
JVM
在 JVM 中,对于死锁的处理能力就不如数据库那么强大了。如果在 JVM 中发生了死锁,JVM 并不会自动进行处理,所以一旦死锁发生,就会陷入无穷的等待。
/** * 描述: 必定死锁的情况 */ public class MustDeadLock implements Runnable { public int flag; static Object o1 = new Object(); static Object o2 = new Object(); public void run() { System.out.println("线程"+Thread.currentThread().getName() + "的flag为" + flag); if (flag == 1) { synchronized (o1) { try { Thread.sleep(500); } catch (Exception e) { e.printStackTrace(); } synchronized (o2) { System.out.println("线程1获得了两把锁"); } } } if (flag == 2) { synchronized (o2) { try { Thread.sleep(500); } catch (Exception e) { e.printStackTrace(); } synchronized (o1) { System.out.println("线程2获得了两把锁"); } } } } public static void main(String[] argv) { MustDeadLock r1 = new MustDeadLock(); MustDeadLock r2 = new MustDeadLock(); r1.flag = 1; r2.flag = 2; Thread t1 = new Thread(r1, "t1"); Thread t2 = new Thread(r2, "t2"); t1.start(); t2.start(); } }
死锁4个必要条件
第 1 个叫互斥条件
它的意思是每个资源每次只能被一个线程(或进程,下同)使用,为什么资源不能同时被多个线程或进程使用呢?这是因为如果每个人都可以拿到想要的资源,那就不需要等待,所以是不可能发生死锁的。
第 2 个是请求与保持条件
它是指当一个线程因请求资源而阻塞时,则需对已获得的资源保持不放。如果在请求资源时阻塞了,并且会自动释放手中资源(例如锁)的话,那别人自然就能拿到我刚才释放的资源,也就不会形成死锁。
第 3 个是不剥夺条件
它是指线程已获得的资源,在未使用完之前,不会被强行剥夺。比如我们在上一课时中介绍的数据库的例子,它就有可能去强行剥夺某一个事务所持有的资源,这样就不会发生死锁了。所以要想发生死锁,必须满足不剥夺条件,也就是说当现在的线程获得了某一个资源后,别人就不能来剥夺这个资源,这才有可能形成死锁。
第 4 个是循环等待条件
只有若干线程之间形成一种头尾相接的循环等待资源关系时,才有可能形成死锁,比如在两个线程之间,这种“循环等待”就意味着它们互相持有对方所需的资源、互相等待;而在三个或更多线程中,则需要形成环路,例如依次请求下一个线程已持有的资源等。
定位死锁
jstack
jstack -l 进程号
"t1": waiting to lock monitor 0x00006000038d0000 (object 0x000000061feac618, a java.lang.Object), which is held by "t2" "t2": waiting to lock monitor 0x00006000038d4000 (object 0x000000061feac608, a java.lang.Object), which is held by "t1" Java stack information for the threads listed above: =================================================== "t1": at MustDeadLock.run(Unknown Source) - waiting to lock <0x000000061feac618> (a java.lang.Object) - locked <0x000000061feac608> (a java.lang.Object) at java.lang.Thread.run(java.base@17.0.10/Thread.java:842) "t2": at MustDeadLock.run(Unknown Source) - waiting to lock <0x000000061feac608> (a java.lang.Object) - locked <0x000000061feac618> (a java.lang.Object) at java.lang.Thread.run(java.base@17.0.10/Thread.java:842) Found 1 deadlock.
ThreadMXBean
ThreadMXBean 也可以帮我们找到并定位死锁,如果我们在业务代码中加入这样的检测,那我们就可以在发生死锁的时候及时地定位,同时进行报警等其他处理,也就增强了我们程序的健壮性。
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); long[] deadlockedThreads = threadMXBean.findDeadlockedThreads(); if (deadlockedThreads != null && deadlockedThreads.length > 0) { for (int i = 0; i < deadlockedThreads.length; i++) { ThreadInfo threadInfo = threadMXBean.getThreadInfo(deadlockedThreads[i]); System.out.println("线程id为"+threadInfo.getThreadId()+",线程名为" + threadInfo.getThreadName()+"的线程已经发生死锁,需要的锁正被线程"+threadInfo.getLockOwnerName()+"持有。"); } }
解决死锁策略
避免策略
优化代码逻辑,从根本上消除发生死锁的可能性。通常而言,发生死锁的一个主要原因是顺序相反的去获取不同的锁
//发生了死锁 public class TransferMoney implements Runnable { int flag; static Account a = new Account(500); static Account b = new Account(500); static class Account { public Account(int balance) { this.balance = balance; } int balance; } @Override public void run() { if (flag == 1) { transferMoney(a, b, 200); } if (flag == 0) { transferMoney(b, a, 200); } } public static void transferMoney(Account from, Account to, int amount) { //先获取两把锁,然后开始转账 synchronized (to) { synchronized (from) { if (from.balance - amount < 0) { System.out.println("余额不足,转账失败。"); return; } from.balance -= amount; to.balance += amount; System.out.println("成功转账" + amount + "元"); } } } public static void main(String[] args) throws InterruptedException { TransferMoney r1 = new TransferMoney(); TransferMoney r2 = new TransferMoney(); r1.flag = 1; r2.flag = 0; Thread t1 = new Thread(r1); Thread t2 = new Thread(r2); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("a的余额" + a.balance); System.out.println("b的余额" + b.balance); } }
/* *如果我们在两个 synchronized 之间加上一个 Thread.sleep(500),来模拟银行网络迟延等情 *况,那么 transferMoney 方法就变为: */ public static void transferMoney(Account from, Account to, int amount) { //先获取两把锁,然后开始转账 synchronized (to) { try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (from) { if (from.balance - amount < 0) { System.out.println("余额不足,转账失败。"); return; } from.balance -= amount; to.balance += amount; System.out.println("成功转账" + amount + "元"); } } } /* 可以看到 transferMoney 的变化就在于,在两个 synchronized 之间,也就是获取到第一把锁后、获取到第二把锁前,我们加了睡眠 500 毫秒的语句。此时再运行程序,会有很大的概率发生死锁,从而导致控制台中不打印任何语句,而且程序也不会停止。 */
//实际上不在乎获取锁的顺序 /*我们可以发现,其实转账时,并不在乎两把锁的相对获取顺序。转账的时候,我们无论先获取到转出账户锁对象,还是先获取到转入账户锁对象,只要最终能拿到两把锁,就能进行安全的操作。所以我们来调整一下获取锁的顺序,使得先获取的账户和该账户是“转入”或“转出”无关,而是使用 HashCode 的值来决定顺序,从而保证线程安全。 */ public static void transferMoney(Account from, Account to, int amount) { int fromHash = System.identityHashCode(from); int toHash = System.identityHashCode(to); if (fromHash < toHash) { synchronized (from) { synchronized (to) { if (from.balance - amount < 0) { System.out.println("余额不足,转账失败。"); return; } from.balance -= amount; to.balance += amount; System.out.println("成功转账" + amount + "元"); } } } else if (fromHash > toHash) { synchronized (to) { synchronized (from) { if (from.balance - amount < 0) { System.out.println("余额不足,转账失败。"); return; } from.balance -= amount; to.balance += amount; System.out.println("成功转账" + amount + "元"); } } } }
//有主键就更安全、方便 看一下用主键决定锁获取顺序的方式,它会更加的安全方便。刚才我们使用了 HashCode 作为排序的标准,因为 HashCode 比较通用,每个对象都有,不过这依然有极小的概率会发生 HashCode 相同的情况。在实际生产中,需要排序的往往是一个实体类,而一个实体类一般都会有一个主键 ID,主键 ID 具有唯一、不重复的特点,所以如果我们这个类包含主键属性的话就方便多了,我们也没必要去计算 HashCode,直接使用它的主键 ID 来进行排序,由主键 ID 大小来决定获取锁的顺序,就可以确保避免死锁。
检测与恢复策略
避免死锁是通过逻辑让死锁不发生,而这里的检测与恢复策略,是先允许系统发生死锁,然后再解除。
例如系统可以在每次调用锁的时候,都记录下来调用信息,形成一个“锁的调用链路图”,然后隔一段时间就用死锁检测算法来检测一下,搜索这个图中是否存在环路,一旦发生死锁,就可以用死锁恢复机制,比如剥夺某一个资源,来解开死锁,进行恢复。所以它的思路和之前的死锁避免策略是有很大不同的。
解开死锁
方法1——线程终止
第一种解开死锁的方法是线程(或进程,下同)终止,在这里,系统会逐个去终止已经陷入死锁的线程,线程被终止,同时释放资源,这样死锁就会被解开。
方法2——资源抢占
第二个解开死锁的方法就是资源抢占。其实,我们不需要把整个的线程终止,而是只需要把它已经获得的资源进行剥夺,比如让线程回退几步、 释放资源,这样一来就不用终止掉整个线程了,这样造成的后果会比刚才终止整个线程的后果更小一些,成本更低。
鸵鸟策略
如果我们的系统发生死锁的概率不高,并且一旦发生其后果不是特别严重的话,我们就可以选择先忽略它。直到死锁发生的时候,我们再人工修复,比如重启服务,这并不是不可以的
其他资料
锁顺序
总是以固定的顺序获取锁。
例
所有线程都先锁定文件资源,再锁定数据库资源,死锁就不会发生。
锁超时
线程在尝试获取锁时不会无限等待。Java的ReentrantLock就提供了这样的功能
使用并发工具类
Java并发API提供了一些高级工具,比如java.util.concurrent包中的类,可以帮助咱们更好地管理锁和避免死锁。
例
Semaphore可以用来控制对资源的并发访问数,而CountDownLatch和CyclicBarrier可以用于线程间的同步。
检测和解决死锁
JVM工具检测死锁
jConsole和jVisualVM
编程技巧解决死锁
锁的顺序
防范措施
代码简单,避免一个线程同时持有多个锁,如果需要,使用超时尝试获取锁
最佳实践总结
保持锁的简单性
尽量避免多个锁的嵌套,这样可以减少死锁的可能性。
锁顺序一致性
总是以相同的顺序获取锁,这样可以防止循环等待的发生。
使用定时锁
利用tryLock带超时的特性,避免线程长时间阻塞。
避免不必要的锁
分析代码,确保只在必要时加锁。
使用高级并发工具
ReentrantLock、Semaphore
代码审查和测试
定期进行代码审查,查找潜在的死锁风险,同时进行彻底的多线程测试。
容器
什么是同步容器 什么是并发容器 常见的7大并发容器介绍:concurrenthashmap copyonwritearraylist等 ConcurrentHashMap的底层实现机制
AQS框架
AQS的重要性
AQS 在 ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch、ThreadPoolExcutor 的 Worker 中都有运用(JDK 1.8),AQS 是这些类的底层原理。
为什么需要AQS
原因是,上面刚讲的那些协作类,它们有很多工作是类似的,所以如果能把实现类似工作的代码给提取出来,变成一个新的底层工具类(或称为框架)的话,就可以直接使用这个工具类来构建上层代码了,而这个工具类其实就是 AQS。 有了 AQS 之后,对于 ReentrantLock 和 Semaphore 等线程协作工具类而言,它们就不需要关心这么多的线程调度细节,只需要实现它们各自的设计逻辑即可。
AQS的作用
AQS 是一个用于构建锁、同步器等线程协作工具类的框架,有了 AQS 以后,很多用于线程协作的工具类就都可以很方便的被写出来,有了 AQS 之后,可以让更上层的开发极大的减少工作量,避免重复造轮子,同时也避免了上层因处理不当而导致的线程安全问题,因为 AQS 把这些事情都做好了。总之,有了 AQS 之后,我们构建线程协作工具类就容易多了。
AQS内部原理解析
AQS 最核心的三大部分
state 状态
/** * The synchronization state. */ private volatile int state;
根据具体实现类的作用不同而表示不同的含义
在信号量里面,state 表示的是剩余许可证的数量。信号量的 state 相当于是一个内部计数器。
在 CountDownLatch 工具类里面,state 表示的是需要“倒数”的数量
一开始我们假设把它设置为 5,当每次调用 CountDown 方法时,state 就会减 1,一直减到 0 的时候就代表这个门闩被放开。
在 ReentrantLock 中它表示的是锁的占有情况。最开始是 0,表示没有任何线程占有锁;如果 state 变成 1,则就代表这个锁已经被某一个线程所持有了。
FIFO队列
在队列中,分别用 head 和 tail 来表示头节点和尾节点,两者在初始化的时候都指向了一个空节点。头节点可以理解为“当前持有锁的线程”,而在头节点之后的线程就被阻塞了,它们会等待被唤醒,唤醒也是由 AQS 负责操作的。
这个队列最主要的作用是存储等待的线程。假设很多线程都想要同时抢锁,那么大部分的线程是抢不到的,那怎么去处理这些抢不到锁的线程呢?就得需要有一个队列来存放、管理它们。所以 AQS 的一大功能就是充当线程的“排队管理器”。
当多个线程去竞争同一把锁的时候,就需要用排队机制把那些没能拿到锁的线程串在一起;而当前面的线程释放锁之后,这个管理器就会挑选一个合适的线程来尝试抢刚刚释放的那把锁。所以 AQS 就一直在维护这个队列,并把等待的线程都放到队列里面。
期望协作工具类
获取/释放方法
描述:是协作工具类的逻辑的具体体现,需要每一个协作工具类自己去实现,所以在不同的工具类中,它们的实现和含义各不相同。
获取方法
获取操作通常会依赖 state 变量的值,根据 state 值不同,协作工具类也会有不同的逻辑,并且在获取的时候也经常会阻塞。
ReentrantLock 中的 lock 方法就是其中一个“获取方法”,执行时,如果发现 state 不等于 0 且当前线程不是持有锁的线程,那么就代表这个锁已经被其他线程所持有了。这个时候,当然就获取不到锁,于是就让该线程进入阻塞状态。
Semaphore 中的 acquire 方法就是其中一个“获取方法”,作用是获取许可证,此时能不能获取到这个许可证也取决于 state 的值。如果 state 值是正数,那么代表还有剩余的许可证,数量足够的话,就可以成功获取;但如果 state 是 0,则代表已经没有更多的空余许可证了,此时这个线程就获取不到许可证,会进入阻塞状态,所以这里同样也是和 state 的值相关的。
CountDownLatch 获取方法就是 await 方法(包含重载方法),作用是“等待,直到倒数结束”。执行 await 的时候会判断 state 的值,如果 state 不等于 0,线程就陷入阻塞状态,直到其他线程执行倒数方法把 state 减为 0,此时就代表现在这个门闩放开了,所以之前阻塞的线程就会被唤醒。
释放方法
CountDownLatch 获取方法就是 await 方法(包含重载方法),作用是“等待,直到倒数结束”。执行 await 的时候会判断 state 的值,如果 state 不等于 0,线程就陷入阻塞状态,直到其他线程执行倒数方法把 state 减为 0,此时就代表现在这个门闩放开了,所以之前阻塞的线程就会被唤醒。
在 Semaphore 信号量里面,释放就是 release 方法(包含重载方法),release() 方法的作用是去释放一个许可证,会让 state 加 1;
在 CountDownLatch 里面,释放就是 countDown 方法,作用是倒数一个数,让 state 减 1。
应用原理
AQS用法
第一步,新建一个自己的线程协作工具类,在内部写一个 Sync 类,该 Sync 类继承 AbstractQueuedSynchronizer,即 AQS; 第二步,想好设计的线程协作工具类的协作逻辑,在 Sync 类里,根据是否是独占,来重写对应的方法。如果是独占,则重写 tryAcquire 和 tryRelease 等方法;如果是非独占,则重写 tryAcquireShared 和 tryReleaseShared 等方法; 第三步,在自己的线程协作工具类中,实现获取/释放的相关方法,并在里面调用 AQS 对应的方法,如果是独占则调用 acquire 或 release 等方法,非独占则调用 acquireShared 或 releaseShared 或 acquireSharedInterruptibly 等方法。
AQS并发重要性非常高
AQS的介绍
AQS(AbstractQueuedSynchronizer)就是一个抽象的队列同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它。 AQS的主要作用是为Java中的并发同步组件提供统一的底层支持,比如大家熟知的: ReentrantLock Semaphore CountDownLatch CyclicBarrier 等并发类均是基于AQS来实现的。
AQS的数据模型
双向链表
3个核心成员变量
共享资源:volatile int state(代表共享状态) 队头节点:head头节点 队尾节点:tail尾节点 head、tail、state三个变量都是volatile的,通过volatile来保证共享变量的可见性。
AQS中state状态的变更是基于CAS实现的
3.CLH队列(FIFO队列)
CLH队列通过内置的FIFO队列(Node来实现),来完成线程等待排队 (多线程争用资源被阻塞时会进入此队列)。
AQS的资源共享方式
1.独占锁Exclusive 独占模式下时,其他线程试图获取该锁将无法取得成功,只有一个线程能执行,如ReentrantLock采用独占模式。 ReentrantLock还可以分为公平锁和非公平锁: 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的 2.共享锁shared 多个线程获取某个锁可能会获得成功,多个线程可同时执行,如:Semaphore、CountDownLatch。
AQS的锁获取与释放原理
每个Node其实就是一个线程封装,当线程在竞争锁失败之后,会封装成Node加入到AQS队列中;获取锁的线程释放锁之后,会从队列中唤醒一个阻塞的Node(也就是线程)
1.线程获取锁流程: 线程A获取锁,state将0置为1,线程A占用 在A没有释放锁期间,线程B也来获取锁,线程B获取state为1,表示线程被占用,线程B创建Node节点放入队尾(tail),并且阻塞线程B 同理线程C获取state为1,表示线程被占用,线程C创建Node节点,放入队尾,且阻塞线程 2.线程释放锁流程: 线程A执行完,将state从1置为0 唤醒下一个Node B线程节点,然后再删除线程A节点 线程B占用,获取state状态位,执行完后唤醒下一个节点 Node C,再删除线程B节点 更加详细的锁获取和释放过程,建议通过查看源码的方式学习AQS独占模式和共享模式下的获取锁过程。
锁详解
乐观锁/悲观锁
乐观锁
就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号、数据比对等机制。 乐观锁适用于多读的应用类型,乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。 简单来说,CAS算法有3个三个操作数: 需要读写的内存值 V。 进行比较的值 A。 要写入的新值 B。 当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则返回V。这是一种乐观锁的思路,它相信在它修改之前,没有其它线程去修改它;而Synchronized是一种悲观锁,它认为在它修改之前,一定会有其它线程去修改它,悲观锁效率很低。
CAS的问题
1)CAS容易造成ABA问题。一个线程a将数值改成了b,接着又改成了a,此时CAS认为是没有变化,其实是已经变化过了,而这个问题的解决方案可以使用版本号标识,每操作一次version加1。在java5中,已经提供了AtomicStampedReference来解决问题。 2) 不能保证代码块的原子性 CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。 3)CAS造成CPU利用率增加。之前说过了CAS里面是一个循环判断的过程,如果线程一直没有获取到状态,cpu资源会一直被占用。
悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
synchronized关键字的实现就是典型的悲观锁。
MySQL行锁、表锁、悲观锁、乐观锁的特点与应用
synchronized关键字的实现就是典型的悲观锁。
场景
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
公平锁/非公平锁
公平锁
就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己。
优点
等待锁的线程不会饿死。
缺点
整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁
上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。
优点
可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
应用
java jdk并发包中的ReentrantLock可以指定构造函数的boolean类型来创建公平锁和非公平锁(默认),比如:公平锁可以使用new ReentrantLock(true)实现。
独享锁/共享锁
独享锁
是指该锁一次只能被一个线程所持有。
共享锁
是指该锁可被多个线程所持有。
比较
对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。 读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
AQS
抽象队列同步器(AbstractQueuedSynchronizer,简称AQS)是用来构建锁或者其他同步组件的基础框架,它使用一个整型的volatile变量(命名为state)来维护同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
concurrent包的实现结构如上图所示,AQS、非阻塞数据结构和原子变量类等基础类都是基于volatile变量的读/写和CAS实现,而像Lock、同步器、阻塞队列、Executor和并发容器等高层类又是基于基础类实现。
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。
分段锁/自旋锁
分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。 当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
自旋锁
当只有一个线程等待锁时,该线程通过循环的方式,来试探当前锁是否被释放可用。
并发容器
描述:
java提供了很多丰富的容器,主要可以分为四大类别List、Map、Set、Queue,但是这些容器的实现类有各种各样的缺点,如我们常见的ArrayList、HashMap是线程不安全的,如Hashtable、Vector是线程安全,但是效率很差,这些线程安全的容器是通过synchronized关键字来实现的,这样做就削弱了程序的并发性,所以并发容器就诞生了。
定义:
并发容器是专门针对多线程并发设计的,使用了锁分段技术,只对操作位置进行同步操作,但是其它其它线程可以对没有操作的位置继续进行访问。
容器
Map
ConcurrentHashMap -并发HashMap
介绍
ConcurrentHashMap是java.util.concurrent(JUC)包下的成员,它支持线程安全、支持多线程操作时并发读写操作,在jdk1.7中,它采用的是Segment分段锁,但是在1.8中,使用的是CAS+synchronized,底层使用的是数组+链表+红黑树
技术版本
1.7
分段、数组、链表
jdk1.7中的ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成,Segment是一种可重入锁(ReentrantLock),每个Segment都包含一个HashEntry[]数组,每个HashEntry是一个链表结构,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁。ConcurrentHashMap使用锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据分配一把锁,当一个线程占用锁访问其中一个段数据的时候,其它段的数据也能被其它线程访问。
1.8
数组 + 链表 + 红黑树 (CAS+synchronized) --安全:使用volatile和CAS
jdk1.8抛弃了Segment分段锁,采用的是CAS+synchronized,它的改进是为了解决查询链表效率太低的问题,将1.7中的HashEntry改为node,取消的ReentrantLock改为了synchronized,采用的数据结构为数组+链表+红黑树,保证了查询效率。 // 链表转红黑树的阈值 > 8 时 // 红黑树转链表的阈值 <= 6 时
优势
锁粒度
首先锁的粒度并没有变粗,甚至变得更细了。原来每扩容一次,ConcurrentHashMap 的并发度就扩大一倍。 原来的分段锁,锁的是一个段,大小是cap/conlevel 的桶的数量,现在只需要锁一个桶
扩容
其他线程可以通过检测数组中的节点决定是否对这条链表(红黑树)进行扩容,减小了扩容的粒度,提高了扩容的效率。不是每次都要整体扩容,只需要对当前链表或者红黑树扩容,减小了扩容的粒度
synchronized
JVM原生支持,后续版本升级不需要改代码
ConcurrentSkipListMap -基于跳表的并发Map
介绍
SkipList即跳表,跳表是一种空间换时间的数据结构,通过冗余数据,将链表一层一层索引,达到类似二分查找的效果
子主题
List
CopyOnWriteArrayList -并发版ArrayList
介绍:
CopyOnWriteArrayList是juc包下提供的一个并发安全的ArrayList,它的主要特点是写操作时需要复制集合,会创建一个新的底层数组,读操作时只需要获得数组下标即可,它的工作方式采用的是用空间换时间,适用于读多写少的场景中。
添加元素时先将原数组复制,然后再将添加的元素放置到新数组的末尾,然后将数组的引用指向新数组。
源码分析
底层数据结构是数组
private transient volatile Object[] array;
新增
它仅在写/修改/删除操作时存在线程安全,所以在这些操作中加入了ReentrantLock来保证线程安全,以写操作为例
public boolean add(E e) { //获取锁 final ReentrantLock lock = this.lock; //加锁 lock.lock(); try { //获得当前集合中保存数据的数组 Object[] elements = getArray(); //获取该数组的长度 int len = elements.length; //拷贝当前的数组,并让其长度+1 Object[] newElements = Arrays.copyOf(elements, len + 1); //将加入的元素放在新数组的最后一位 newElements[len] = e; //将数组的引用指向新数组 setArray(newElements); return true; } finally { //释放锁 lock.unlock(); } }
场景
由于读操作不加锁,写(增、删、改)操作加锁,因此适用于读多写少的场景。
局限
由于读的时候不会加锁(读的效率高,就和普通ArrayList一样),读取的当前副本,因此可能读取到脏数据。如果介意,建议不用。
Set
CopyOnWriteArraySet
描述:基于CopyOnWriteArrayList实现(内含一个CopyOnWriteArrayList成员变量),也就是说底层是一个数组,意味着每次add都要遍历整个集合才能知道是否存在,不存在时需要插入(加锁)。
场景
在CopyOnWriteArrayList适用场景下加一个,集合别太大(全部遍历伤不起)
ConcurrentSkipListSet -基于跳表的并发Set
类似HashSet和HashMap的关系,ConcurrentSkipListSet里面就是一个ConcurrentSkipListMap,就不细说了。
Queue(kjuː)
ConcurrentLinkedQueue -并发队列(基于链表)
介绍
在并发编程中,有两种方法可以实现线程安全的队列,一种使用的是阻塞算法,该算法使用同一个锁(入队出队使用同一把锁)或中两把锁(入队和出队使用不同的锁)实现,另一种是使用非阻塞算法 该算法是使用循环CAS的方法来实现,我们这里主要使用非阻塞算法的方法来实现ConcurrentLinkedQueue。
源码分析
数据结构
ConcurrentLinkedQueue有head结点和tail结点组成,每个结点有由节点元素(item)和下一个结点的引用(next)组成,结点之间就是通过这个next关联起来,组成一个链表结构的队列
volatile E item; volatile Node<E> next;
入队分析
子主题
整个入队就做两件事情:①定位尾结点②使用CAS算法将入队结点设置为尾结点的next结点,不成功则重试
ArrayBlockingQueue -阻塞队列(基于数组)
介绍
基于数组实现的可阻塞队列,构造时必须制定数组大小,往里面放东西时如果数组满了便会阻塞直到有位置(也支持直接返回和超时等待),通过一个锁ReentrantLock保证线程安全。
LinkedBlockingQueue -阻塞队列(基于链表)
介绍
基于链表实现的阻塞队列,相比与不阻塞的ConcurrentLinkedQueue,它多了一个容量限制,如果不设置默认为int最大值。
ConcurrentLinkedDeque -并发队列(基于双向链表)
介绍
基于双向链表实现的并发队列,可以分别对头尾进行操作,因此除了先进先出(FIFO),也可以先进后出(FILO),当然先进后出的话应该叫它栈了。
LinkedBlockingDeque 阻塞队列(基于双向链表)
介绍
类似LinkedBlockingQueue,但提供了双向链表特有的操作。
PriorityBlockingQueue -线程安全的优先队列
介绍
构造时可以传入一个比较器,可以看做放进去的元素会被排序,然后读取的时候按顺序消费。某些低优先级的元素可能长期无法被消费,因为不断有更高优先级的元素进来。
SynchronousQueue -读写成对的队列
介绍
一个虚假的队列,因为它实际上没有真正用于存储元素的空间,每个插入操作都必须有对应的取出操作,没取出时无法继续放入。
LinkedTransferQueue -基于链表的数据交换队列
介绍
实现了接口TransferQueue,通过transfer方法放入元素时,如果发现有线程在阻塞在取元素,会直接把这个元素给等待线程。如果没有人等着消费,那么会把这个元素放到队列尾部,并且此方法阻塞直到有人读取这个元素。和SynchronousQueue有点像,但比它更强大。
DelayQueue -延时队列
介绍
可以使放入队列的元素在指定的延时后才被消费者取出,元素需要实现Delayed接口。
同步集合与并发集合区别
同步集合与并发集合都为多线程和并发提供了合适的线程安全的集合,不过并发集合的可扩展性更高。同步集合比并发集合会慢得多,主要原因是锁,同步集合会对整个May或List加锁,而并发集合例如ConcurrentHashMap, 把整个Map 划分成几个片段,只对相关的几个片段上锁,同时允许多线程访问其他未上锁的片段(JDK1.8版本底层加入了红黑树)。
同步集合
Vector Stack HashTable Collections.synchronized方法生成
并发集合
ConcurrentHashMap CopyOnWriteArrayList CopyOnWriteArraySet等
并发工具类
CountDownLatch -线程等待 [lætʃ]
最开始 CountDownLatch 设置的初始值为 3,然后 T0 线程上来就调用 await 方法,它的作用是让这个线程开始等待,等待后面的 T1、T2、T3,它们每一次调用 countDown 方法,3 这个数值就会减 1,也就是从 3 减到 2,从 2 减到 1,从 1 减到 0,一旦减到 0 之后,这个 T0 就相当于达到了自己触发继续运行的条件,于是它就恢复运行了。
1)构造函数:public CountDownLatch(int count) { }; 它的构造函数是传入一个参数,该参数 count 是需要倒数的数值。 (2)await():调用 await() 方法的线程开始等待,直到倒数结束,也就是 count 值为 0 的时候才会继续执行。 (3)await(long timeout, TimeUnit unit):await() 有一个重载的方法,里面会传入超时参数,这个方法的作用和 await() 类似,但是这里可以设置超时时间,如果超时就不再等待了。 (4)countDown():把数值倒数 1,也就是将 count 值减 1,直到减为 0 时,之前等待的线程会被唤起。
一个线程(或者多个), 等待另外N个线程完成某个事情之后才能执行。
场景
在一些应用场合中,需要等待某个条件达到要求后才能做后面的事情;同时当线程都完成后也会触发事件,以便进行后面的操作, 这个时候就可以使用CountDownLatch。
用法
用法一
一个线程等待其他多个线程都执行完毕,再继续自己的工作
代码
public class RunDemo1 { public static void main(String[] args) throws InterruptedException { CountDownLatch latch = new CountDownLatch(5); ExecutorService service = Executors.newFixedThreadPool(5); for (int i = 0; i < 5; i++) { final int no = i + 1; Runnable runnable = new Runnable() { @Override public void run() { try { Thread.sleep((long) (Math.random() * 10000)); System.out.println(no + "号运动员完成了比赛。"); } catch (InterruptedException e) { e.printStackTrace(); } finally { latch.countDown(); } } }; service.submit(runnable); } System.out.println("等待5个运动员都跑完....."); latch.await(); System.out.println("所有人都跑完了,比赛结束。"); } }
用法二
多个线程等待某一个线程的信号,同时开始执行
代码
public class RunDemo2 { public static void main(String[] args) throws InterruptedException { System.out.println("运动员有5秒的准备时间"); CountDownLatch countDownLatch = new CountDownLatch(1); ExecutorService service = Executors.newFixedThreadPool(5); for (int i = 0; i < 5; i++) { final int no = i + 1; Runnable runnable = new Runnable() { @Override public void run() { System.out.println(no + "号运动员准备完毕,等待裁判员的发令枪"); try { countDownLatch.await(); System.out.println(no + "号运动员开始跑步了"); } catch (InterruptedException e) { e.printStackTrace(); } } }; service.submit(runnable); } Thread.sleep(5000); System.out.println("5秒准备时间已过,发令枪响,比赛开始!"); countDownLatch.countDown(); } }
注意点
刚才讲了两种用法,其实这两种用法并不是孤立的,甚至可以把这两种用法结合起来,比如利用两个 CountDownLatch,第一个初始值为多个,第二个初始值为 1,这样就可以应对更复杂的业务场景了; CountDownLatch 是不能够重用的,比如已经完成了倒数,那可不可以在下一次继续去重新倒数呢?这是做不到的,如果你有这个需求的话,可以考虑使用 CyclicBarrier 或者创建一个新的 CountDownLatch 实例。
总结
CountDownLatch 类在创建实例的时候,需要在构造函数中传入倒数次数,然后由需要等待的线程去调用 await 方法开始等待,而每一次其他线程调用了 countDown 方法之后,计数便会减 1,直到减为 0 时,之前等待的线程便会继续运行。
CyclicBarrier -所有线程到达屏障,所有线程继续运行 [ˈsaɪklɪk,ˈsɪklɪk][ˈbæriər]
CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。
N个线程相互等待,任何一个线程完成之前,所有的线程都必须等待。 可以用于多线程计算数据,最后合并计算结果的应用场景。
作用
CyclicBarrier 和 CountDownLatch 确实有一定的相似性,它们都能阻塞一个或者一组线程,直到某种预定的条件达到之后,这些之前在等待的线程才会统一出发,继续向下执行。正因为它们有这个相似点,你可能会认为它们的作用是完全一样的,其实并不是。 CyclicBarrier 可以构造出一个集结点,当某一个线程执行 await() 的时候,它就会到这个集结点开始等待,等待这个栅栏被撤销。直到预定数量的线程都到了这个集结点之后,这个栅栏就会被撤销,之前等待的线程就在此刻统一出发,继续去执行剩下的任务。
代码
/** * 假设我们班级春游去公园里玩,并且会租借三人自行车,每个人都可以骑,但由于这辆自行 * 车是三人的,所以要凑齐三个人才能骑一辆,而且从公园大门走到自行车驿站需要一段时间 */ public class CyclicBarrierDemo { public static void main(String[] args) { CyclicBarrier cyclicBarrier = new CyclicBarrier(3); for (int i = 0; i < 6; i++) { new Thread(new Task(i + 1, cyclicBarrier)).start(); } } static class Task implements Runnable { private int id; private CyclicBarrier cyclicBarrier; public Task(int id, CyclicBarrier cyclicBarrier) { this.id = id; this.cyclicBarrier = cyclicBarrier; } @Override public void run() { System.out.println("同学" + id + "现在从大门出发,前往自行车驿站"); try { Thread.sleep((long) (Math.random() * 10000)); System.out.println("同学" + id + "到了自行车驿站,开始等待其他人到达"); cyclicBarrier.await(); System.out.println("同学" + id + "开始骑车"); } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } } }
可以看到 6 个同学纷纷从大门出发走到自行车驿站,因为每个人的速度不一样,所以会有 3 个同学先到自行车驿站,不过在这 3 个先到的同学里面,前面 2 个到的都必须等待第 3 个人到齐之后,才可以开始骑车。后面的同学也一样,由于第一辆车已经被骑走了,第二辆车依然也要等待 3 个人凑齐才能统一发车。
执行动作 barrierAction public CyclicBarrier(int parties, Runnable barrierAction):当 parties 线程到达集结点时,继续往下执行前,会执行这一次这个动作。 CyclicBarrier cyclicBarrier = new CyclicBarrier(3, new Runnable() { @Override public void run() { System.out.println("凑齐3人了,出发!"); } });
CyclicBarrier 和 CountDownLatch 的异同
相同点:都能阻塞一个或一组线程,直到某个预设的条件达成发生,再统一出发。 但是它们也有很多不同点,具体如下。 作用对象不同:CyclicBarrier 要等固定数量的线程都到达了栅栏位置才能继续执行,而 CountDownLatch 只需等待数字倒数到 0,也就是说 CountDownLatch 作用于事件,但 CyclicBarrier 作用于线程;CountDownLatch 是在调用了 countDown 方法之后把数字倒数减 1,而 CyclicBarrier 是在某线程开始等待后把计数减 1。 可重用性不同:CountDownLatch 在倒数到 0 并且触发门闩打开后,就不能再次使用了,除非新建一个新的实例;而 CyclicBarrier 可以重复使用,在刚才的代码中也可以看出,每 3 个同学到了之后都能出发,并不需要重新新建实例。CyclicBarrier 还可以随时调用 reset 方法进行重置,如果重置时有线程已经调用了 await 方法并开始等待,那么这些线程则会抛出 BrokenBarrierException 异常。 执行动作不同:CyclicBarrier 有执行动作 barrierAction,而 CountDownLatch 没这个功能。
Semaphore -控制并发数 [ˈseməfɔːr]
Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。 Semaphore也是一个线程同步的辅助类,可以维护当前访问自身的线程个数,并提供了同步机制。使用Semaphore可以控制同时访问资源的线程个数,例如,实现一个文件允许的并发访问数。
Semaphore就是一个信号量,它的作用是限制某段代码块的并发数。Semaphore有一个构造函数,可以传入一个int型整数n,表示某段代码最多只有n个线程可以访问,如果超出了n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。由此可以看出如果Semaphore构造函数中传入的int型整数n=1,相当于变成了一个synchronized了。
场景
Semaphore可以用于做流量控制,特别公用资源有限的应用场景,比如数据库连接。假如有一个需求,要读取几万个文件的数据,因为都是IO密集型任务,我们可以启动几十个线程并发的读取,但是如果读到内存后,还需要存储到数据库中,而数据库的连接数只有10个,这时我们必须控制只有十个线程同时获取数据库连接保存数据,否则会报错无法获取数据库连接
在代码中,虽然有30个线程在执行,但是只允许10个并发的执行。Semaphore的构造方法Semaphore(int permits) 接受一个整型的数字,表示可用的许可证数量。Semaphore(10)表示允许10个线程获取许可证,也就是最大并发数是10。Semaphore的用法也很简单,首先线程使用Semaphore的acquire()获取一个许可证,使用完之后调用release()归还许可证。还可以用tryAcquire()方法尝试获取许可证。
Exchanger -线程间协作
Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据, 如果第一个线程先执行exchange方法,它会一直等待第二个线程也执行exchange,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。
场景
两个线程需要以线程安全的方式进行数据传递 两个线程之间需要交替处理执行结果的情况
1、Exchanger可以用于遗传算法,遗传算法里需要选出两个人作为交配对象,这时候会交换两人的数据,并使用交叉规则得出2个交配结果。 2、Exchanger也可以用于校对工作。比如我们需要将纸制银流通过人工的方式录入成电子银行流水,为了避免错误,采用AB岗两人进行录入,录入到Excel之后,系统需要加载这两个Excel,并对这两个Excel数据进行校对,看看是否录入的一致。
区别
CyclicBarrier和CountDownLatch的区别
1) CountDownLatch简单的说就是一个线程等待,直到他所等待的其他线程都执行完成并且调用countDown()方法发出通知后,当前线程才可以继续执行。 2) cyclicBarrier是所有线程都进行等待,直到所有线程都准备好进入await()方法之后,所有线程同时开始执行! 3) CountDownLatch的计数器只能使用一次。而CyclicBarrier的计数器可以使用reset() 方法重置。所以CyclicBarrier能处理更为复杂的业务场景,比如如果计算发生错误,可以重置计数器,并让线程们重新执行一次。 4) CyclicBarrier还提供其他有用的方法,比如getNumberWaiting方法可以获得CyclicBarrier阻塞的线程数量。isBroken方法用来知道阻塞的线程是否被中断。如果被中断返回true,否则返回false。
volatile关键词
对于可见性,Java提供了volatile关键字来保证可见性。 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。 从实践角度而言,volatile的一个重要作用就是和CAS结合,保证了原子性,详细的可以参见java.util.concurrent.atomic包下的类,比如AtomicInteger。
内存屏障
store写 load读
不会重排序
JVM中会有这4个屏障,在操作时前后加入屏障,防止重排序。 加入volatile可实现屏障
底层volatile用lock实现 保证有序性保障
CPU缓存
一级缓存(Level 1 Cache)简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存。一般来说,一级缓存可以分为一级数据缓存(Data Cache,D-Cache)和一级指令缓存(Instruction Cache,I-Cache)
L2 Cache(二级缓存)是CPU的第二层高速缓存,分内部和外部两种芯片。内部的芯片二级缓存运行速度与主频相同,而外部的二级缓存则只有主频的一半。L2高速缓存容量也会影响CPU的性能,原则是越大越好。
三级缓存是为读取二级缓存后未命中的数据设计的—种缓存,在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用,这进一步提高了CPU的效率。
JMM内存模型
JMM(Java Memory Model), 是Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。JMM定义了一个线程与主存之间的抽象关系,它就像我们的数据结构中的逻辑结构一样,只是概念性的东西,并不是真实存在的,但是能够让我们更好的理解多线程的底层原理。
8大原子操作
read读取: 从主内存中读取数据 load载入: 将主内存读取到的数据写入工作内存 use使用: 从工作内存读取出数据来计算 assign赋值: 将CPU计算出的值重新赋值到工作内存中 store存储: 将工作内存中更改后的值写入到主存 write写入: 将store回去的变量赋值给主存中的变量 lock锁定: 将主内存变量加锁,标识为线程独占状态 unlock解锁: 将主内存变量解锁,解锁后其他线程才能再次锁定该变量
JMM缓存不一致问题
总线加锁
一个线程在修改数据时,会加一把lock锁到总线上。此时,其他线程就不能再去读取数据了,等到线程2将数据修改完写回到主存,然后unlock释放锁,然后其他线程才能够读取。
MESI缓存一致性协议
CPU通过总线监听机制感知数据变化,从而将自己缓存里的数据失效。
当几个缓存共享特定数据并且处理器修改共享数据的值时,更改必须传播到所有其他具有数据副本的缓存中。这种变化的传播可以防止系统违反高速缓存一致性。可以通过总线侦听来完成数据更改的通知。所有侦听器都会监视总线上的每笔交易。如果修改共享缓存块的事务出现在总线上,则所有侦听器都会检查其缓存是否具有共享块的相同副本。如果缓存具有共享块的副本,则相应的窥探器将执行操作以确保缓存一致性。该动作可以是刷新或无效缓存块。它还依赖于缓存一致性协议来改变缓存块状态。 注:这是硬件实现。
volatile可见性底层实现原理(第一大特性)
保证可见性原理验证
底层的实现,主要是通过汇编lock前缀指令,它会锁定内存区域的缓存(缓存行锁定),并写回到主内存中。
lock指令的解释: 会将当前处理器缓存行的数据立即写回到系统内存 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效 就是通过lock指令,让initFlag立即写回内存,且让其他线程中的副本失效。
相比于此前在总线上加的重量级锁,lock指令只是在会写主内存时加了锁,就是从store操作开始才加锁,而此前的总线上加锁是从read就开始了。一旦写回,立即unlock释放锁。由于CPU的读写是非常快的,这个过程是非常非常之短的。所以volatile是轻量级的锁,性能高。 注:volatile保证可见性与有序性,但是不保证证原子性,保证原则性需要借助synchronized这样的锁机制
问题解决
1. 同步加锁解决volatile原子性问题
第一种补救措施很简单,就是简单粗暴的的加锁,这样可以保证给num加1这个方法是同步的,这样每个线程就会井然有序的运行,而保证了最终的num数和预期值一致。
2. CAS解决volatile原子性问题
针对num++这类复合类的操作,可以使用JUC并发包中的原子操作类,原子操作类是通过循环CAS的方式来保证其原子性的。 AtomicInteger这是个基于CAS的无锁技术,它的主要原理就是通过比较预期值和实际值,当其没有异常的以后,就进行增值操作。incrementAndGet这个方法实际上每次对num进行+1的过程都进行了比较,存在一个retry的过程。它在多线程处理中可以防止这种多次递增而引发的线程不安全的问题
volatile保证有序性(第二大特性)
volatile保证有序性,就是禁止编译器在编译阶段对指令的重排序问题。
内存屏障
就像一套栅栏分割前后的代码,阻止栅栏前后的没有数据依赖性的代码进行指令重排序,保证程序在一定程度上的有序性。 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证数据的可见性。
volatile 和 synchronized 的关系
相似性:volatile 可以看作是一个轻量版的 synchronized,比如一个共享变量如果自始至终只被各个线程赋值和读取,而没有其他操作的话,那么就可以用 volatile 来代替 synchronized 或者代替原子变量,足以保证线程安全。实际上,对 volatile 字段的每次读取或写入都类似于“半同步”——读取 volatile 与获取 synchronized 锁有相同的内存语义,而写入 volatile 与释放 synchronized 锁具有相同的语义。 不可代替:但是在更多的情况下,volatile 是不能代替 synchronized 的,volatile 并没有提供原子性和互斥性。 性能方面:volatile 属性的读写操作都是无锁的,正是因为无锁,所以不需要花费时间在获取锁和释放锁上,所以说它是高性能的,比 synchronized 性能更好。
volatile不能保证原子性,原因是变量没有上锁
ThreadLocal
ThreadLocal是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不通的变量值完成操作的场景。
场景
场景1,ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。 场景2,ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。
解决问题
ThreadLocal 并不是用来解决共享资源问题的。虽然 ThreadLocal 确实可以用于解决多线程情况下的线程安全问题,但其资源并不是共享的,而是每个线程独享的。所以这道题其实是有一定陷阱成分在内的。 ThreadLocal 解决线程安全问题的时候,相比于使用“锁”而言,换了一个思路,把资源变成了各线程独享的资源,非常巧妙地避免了同步操作。具体而言,它可以在 initialValue 中 new 出自己线程独享的资源,而多个线程之间,它们所访问的对象本身是不共享的,自然就不存在任何并发问题。这是 ThreadLocal 解决并发问题的最主要思路。
区别
ThreadLocal 是通过让每个线程独享自己的副本,避免了资源的竞争。 synchronized 主要用于临界资源的分配,在同一时刻限制最多只有一个线程能访问该资源。
当 ThreadLocal 用于让多个类能更方便地拿到我们希望给每个线程独立保存这个信息的场景下时(比如每个线程都会对应一个用户信息,也就是 user 对象),在这种场景下,ThreadLocal 侧重的是避免传参,所以此时 ThreadLocal 和 synchronized 是两个不同维度的工具。
内存泄漏
因为通常情况下,如果一个对象不再有用,那么我们的垃圾回收器 GC,就应该把这部分内存给清理掉。这样的话,就可以让这部分内存后续重新分配到其他的地方去使用;否则,如果对象没有用,但一直不能被回收,这样的垃圾对象如果积累的越来越多,则会导致我们可用的内存越来越少,最后发生内存不够用的 OOM 错误。
Key 的泄漏
static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }
这个 Entry 是 extends WeakReference。弱引用的特点是,如果这个对象只被弱引用关联,而没有任何强引用关联,那么这个对象就可以被回收,所以弱引用不会阻止 GC。因此,这个弱引用的机制就避免了 ThreadLocal 的内存泄露问题。 这就是为什么 Entry 的 key 要使用弱引用的原因。
Value 的泄漏
Thread Ref → Current Thread → ThreadLocalMap → Entry → Value → 可能泄漏的value实例。 这条链路是随着线程的存在而一直存在的,如果线程执行耗时任务而不停止,那么当垃圾回收进行可达性分析的时候,这个 Value 就是可达的,所以不会被回收。但是与此同时可能我们已经完成了业务逻辑处理,不再需要这个 Value 了,此时也就发生了内存泄漏问题。
如何避免内存泄露
public void remove() { ThreadLocalMap m = getMap(Thread.currentThread()); if (m != null) m.remove(this); }
先获取到 ThreadLocalMap 这个引用的,并且调用了它的 remove 方法。这里的 remove 方法可以把 key 所对应的 value 给清理掉,这样一来,value 就可以被 GC 回收了。
源码
ThreadLocalMap 解决 hash 冲突的方式是不一样的,它采用的是线性探测法。如果发生冲突,并不会用链表的形式往下链,而是会继续寻找下一个空的格子。这是 ThreadLocalMap 和 HashMap 在处理冲突时不一样的点。
public T get() { //获取到当前线程 Thread t = Thread.currentThread(); //获取到当前线程内的 ThreadLocalMap 对象,每个线程内都有一个 ThreadLocalMap 对象 ThreadLocalMap map = getMap(t); if (map != null) { //获取 ThreadLocalMap 中的 Entry 对象并拿到 Value ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } //如果线程内之前没创建过 ThreadLocalMap,就创建 return setInitialValue(); }
ThreadLocalMap getMap(Thread t) { return t.threadLocals; }
public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); }
static class ThreadLocalMap { static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } } private Entry[] table; //... }
原子
Atomic
Adder/Accumulator
在高并发下 LongAdder 比 AtomicLong 效率更高,因为对于 AtomicLong 而言,它只适合用于低并发场景,否则在高并发的场景下,由于 CAS 的冲突概率大,会导致经常自旋,影响整体效率。而 LongAdder 引入了分段锁的概念,当竞争不激烈的时候,所有线程都是通过 CAS 对同一个 Base 变量进行修改,但是当竞争激烈的时候,LongAdder 会把不同线程对应到不同的 Cell 上进行修改,降低了冲突的概率,从而提高了并发性。 Accumulator 和 Adder 非常相似,实际上 Accumulator 就是一个更通用版本的 Adder,比如 LongAccumulator 是 LongAdder 的功能增强版,因为 LongAdder 的 API 只有对数值的加减,而 LongAccumulator 提供了自定义的函数操作。
LongAccumulator适用场景
第一点需要满足的条件,就是需要大量的计算,并且当需要并行计算的时候,我们可以考虑使用 LongAccumulator。 当计算量不大,或者串行计算就可以满足需求的时候,可以使用 for 循环;如果计算量大,需要提高计算的效率时,我们则可以利用线程池,再加上 LongAccumulator 来配合的话,就可以达到并行计算的效果,效率非常高。 第二点需要满足的要求,就是计算的执行顺序并不关键,也就是说它不要求各个计算之间的执行顺序,也就是说线程 1 可能在线程 5 之后执行,也可能在线程 5 之前执行,但是执行的先后并不影响最终的结果。
一些非常典型的满足这个条件的计算,就是类似于加法或者乘法,因为它们是有交换律的。同样,求最大值和最小值对于顺序也是没有要求的,因为最终只会得出所有数字中的最大值或者最小值,无论先提交哪个或后提交哪个,都不会影响到最终的结果。
线程协作
Condition、object.wait() 和 notify() 的关系?
Condition接口
作用: 等待状态:线程 1 需要等待某些条件满足后,才能继续运行,执行 Condition 的 await 方法,一旦执行了该方法,这个线程就会进入 WAITING 状态。 唤醒状态:有另外一个线程,我们把它称作线程 2,它去达成对应的条件,直到这个条件达成之后,那么,线程 2 调用 Condition 的 signal 方法 [或 signalAll 方法],代表“这个条件已经达成了,之前等待这个条件的线程现在可以苏醒了”
public class ConditionDemo { private ReentrantLock lock = new ReentrantLock(); //条件 private Condition condition = lock.newCondition(); void method1() throws InterruptedException { lock.lock(); try{ System.out.println(Thread.currentThread().getName()+":条件不满足,开始await"); condition.await(); System.out.println(Thread.currentThread().getName()+":条件满足了,开始执行后续的任务"); }finally { lock.unlock(); } } void method2() throws InterruptedException { lock.lock(); try{ System.out.println(Thread.currentThread().getName()+":需要5秒钟的准备时间"); Thread.sleep(5000); System.out.println(Thread.currentThread().getName()+":准备工作完成,唤醒其他的线程"); condition.signal(); }finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { ConditionDemo conditionDemo = new ConditionDemo(); new Thread(new Runnable() { @Override public void run() { try { conditionDemo.method2(); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); conditionDemo.method1(); } }
method1,它代表主线程将要执行的内容,首先获取到锁,打印出“条件不满足,开始 await”,然后调用 condition.await() 方法,直到条件满足之后,则代表这个语句可以继续向下执行了,于是打印出“条件满足了,开始执行后续的任务”,最后会在 finally 中解锁。 method2,它同样也需要先获得锁,然后打印出“需要 5 秒钟的准备时间”,接着用 sleep 来模拟准备时间;在时间到了之后,则打印出“准备工作完成”,最后调用 condition.signal() 方法,把之前已经等待的线程唤醒。 main 方法,它的主要作用是执行上面这两个方法,它先去实例化我们这个类,然后再用子线程去调用这个类的 method2 方法,接着用主线程去调用 method1 方法。
注意点
线程 2 解锁后,线程 1 才能获得锁并继续执行 线程 2 对应刚才代码中的子线程,而线程 1 对应主线程。这里需要额外注意,并不是说子线程调用了 signal 之后,主线程就可以立刻被唤醒去执行下面的代码了,而是说在调用了 signal 之后,还需要等待子线程完全退出这个锁,即执行 unlock 之后,这个主线程才有可能去获取到这把锁,并且当获取锁成功之后才能继续执行后面的任务。刚被唤醒的时候主线程还没有拿到锁,是没有办法继续往下执行的。 signalAll() 和 signal() 区别 signalAll() 会唤醒所有正在等待的线程,而 signal() 只会唤醒一个线程。
用 Condition 和 wait/notify 实现简易版阻塞队列
用 Condition 实现简易版阻塞队列
public class MyBlockingQueueForCondition { private Queue queue; private int max = 16; private ReentrantLock lock = new ReentrantLock(); private Condition notEmpty = lock.newCondition(); private Condition notFull = lock.newCondition(); public MyBlockingQueueForCondition(int size) { this.max = size; queue = new LinkedList(); } public void put(Object o) throws InterruptedException { lock.lock(); try { while (queue.size() == max) { notFull.await(); } queue.add(o); notEmpty.signalAll(); } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lock(); try { while (queue.size() == 0) { notEmpty.await(); } Object item = queue.remove(); notFull.signalAll(); return item; } finally { lock.unlock(); } } }
首先定义了一个队列变量 queue,其最大容量是 16;然后定义了一个 ReentrantLock 类型的 Lock 锁,并在 Lock 锁的基础上创建了两个 Condition,一个是 notEmpty,另一个是 notFull,分别代表队列没有空和没有满的条件;最后,声明了 put 和 take 这两个核心方法。
用 wait/notify 实现简易版阻塞队列
class MyBlockingQueueForWaitNotify { private int maxSize; private LinkedList<Object> storage; public MyBlockingQueueForWaitNotify (int size) { this.maxSize = size; storage = new LinkedList<>(); } public synchronized void put() throws InterruptedException { while (storage.size() == maxSize) { this.wait(); } storage.add(new Object()); this.notifyAll(); } public synchronized void take() throws InterruptedException { while (storage.size() == 0) { this.wait(); } System.out.println(storage.remove()); this.notifyAll(); } }
最主要的部分仍是 put 与 take 方法。我们先来看 put 方法,该方法被 synchronized 保护,while 检查 List 是否已满,如果不满就往里面放入数据,并通过 notifyAll() 唤醒其他线程。同样,take 方法也被 synchronized 修饰,while 检查 List 是否为空,如果不为空则获取数据并唤醒其他线程。
上面两个的关系
实际上,如果说 Lock 是用来代替 synchronized 的,那么 Condition 就是用来代替相对应的 Object 的 wait/notify/notifyAll,所以在用法和性质上几乎都一样。 Condition 把 Object 的 wait/notify/notifyAll 转化为了一种相应的对象,其实现的效果基本一样,但是把更复杂的用法,变成了更直观可控的对象方法,是一种升级。 await 方法会自动释放持有的 Lock 锁,和 Object 的 wait 一样,不需要自己手动释放锁。 另外,调用 await 的时候必须持有锁,否则会抛出异常,这一点和 Object 的 wait 一样。
CAS
什么是CAS
英文全称是 Compare-And-Swap,中文叫做“比较并交换”,它是一种思想、一种算法。
在多线程的情况下,各个代码的执行顺序是不能确定的,所以为了保证并发安全,我们可以使用互斥锁。而 CAS 的特点是避免使用互斥锁,当多个线程同时使用 CAS 更新同一个变量时,只有其中一个线程能够操作成功,而其他线程都会更新失败。不过和同步互斥锁不同的是,更新失败的线程并不会被阻塞,而是被告知这次由于竞争而导致的操作失败,但还可以再次尝试。
例子
子主题
子主题
/** * 描述: 模拟CAS操作,等价代码 */ public class SimulatedCAS { private int value; public synchronized int compareAndSwap(int expectedValue, int newValue) { int oldValue = value; if (oldValue == expectedValue) { value = newValue; } return oldValue; } }
在 compareAndSwap 方法里都做了哪些事情。需要先拿到变量的当前值,所以代码里用就会用 int oldValue = value 把变量的当前值拿到。然后就是 compare,也就是“比较”,所以此时会用 if (oldValue == expectedValue) 把当前值和期望值进行比较,如果它们是相等的话,那就意味着现在的值正好就是我们所期望的值,满足条件,说明此时可以进行 swap,也就是交换,所以就把 value 的值修改成 newValue,最后再返回 oldValue,完成了整个 CAS 过程。
案例演示:两个线程竞争 CAS,其中一个落败
public class DebugCAS implements Runnable { private volatile int value; public synchronized int compareAndSwap(int expectedValue, int newValue) { int oldValue = value; if (oldValue == expectedValue) { value = newValue; System.out.println("线程"+Thread.currentThread().getName()+"执行成功"); } return oldValue; } public static void main(String[] args) throws InterruptedException { DebugCAS r = new DebugCAS(); r.value = 100; Thread t1 = new Thread(r,"Thread 1"); Thread t2 = new Thread(r,"Thread 2"); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(r.value); } @Override public void run() { compareAndSwap(100, 150); } }
CAS 和乐观锁的关系,什么时候会用到 CAS?
并发容器
ConcurrentHashMap
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } //以下部分省略 ... }
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) { return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v); }
U 是 Unsafe 类型的,Unsafe 类包含 compareAndSwapInt、compareAndSwapLong、compareAndSwapObject 等和 CAS 密切相关的 native 层的方法,其底层正是利用 CPU 对 CAS 指令的支持实现的。 不仅被用在了 ConcurrentHashMap 的 putVal 方法中,还被用在了 merge、compute、computeIfAbsent、transfer 等重要的方法中,所以 ConcurrentHashMap 对于 CAS 的应用是比较广泛的。
ConcurrentLinkedQueue
public boolean offer(E e) { checkNotNull(e); final Node<E> newNode = new Node<E>(e); for (Node<E> t = tail, p = t;;) { Node<E> q = p.next; if (q == null) { if (p.casNext(null, newNode)) { if (p != t) casTail(t, newNode); return true; } } else if (p == q) p = (t != (t = tail)) ? t : head; else p = (p != t && t != (t = tail)) ? t : q; } }
在 offer 方法中,有一个 for 循环,这是一个死循环,在第 8 行有一个与 CAS 相关的方法,是 casNext 方法,用于更新节点。那么如果执行 p 的 casNext 方法失败的话,casNext 会返回 false,那么显然代码会继续在 for 循环中进行下一次的尝试。所以在这里也可以很明显的看出 ConcurrentLinkedQueue 的 offer 方法使用到了 CAS。
数据库
可以利用 version 字段在数据库中实现乐观锁和 CAS 操作,而在获取和修改数据时都不需要加悲观锁。
UPDATE student SET name = ‘小王’, version = 2 WHERE id = 10 AND version = 1
原子类
AtomicInteger
public final int getAndAdd(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta); }
public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }
看到有一个 compareAndSwapInt 方法,这里会传入多个参数,分别是 var1、var2、 var5、var5 + var4,其实它们代表 object、offset、expectedValue 和 newValue。 第一个参数 object 就是将要修改的对象,传入的是 this,也就是 atomicInteger 这个对象本身; 第二个参数是 offset,也就是偏移量,借助它就可以获取到 value 的数值; 第三个参数 expectedValue,代表“期望值”,传入的是刚才获取到的 var5; 而最后一个参数 newValue 是希望修改为的新值 ,等于之前取到的数值 var5 再加上 var4,而 var4 就是我们之前所传入的 delta,delta 就是我们希望原子类所改变的数值,比如可以传入 +1,也可以传入 -1。 所以 compareAndSwapInt 方法的作用就是,判断如果现在原子类里 value 的值和之前获取到的 var5 相等的话,那么就把计算出来的 var5 + var4 给更新上去,所以说这行代码就实现了 CAS 的过程。 一旦 CAS 操作成功,就会退出这个 while 循环,但是也有可能操作失败。如果操作失败就意味着在获取到 var5 之后,并且在 CAS 操作之前,value 的数值已经发生变化了,证明有其他线程修改过这个变量。
CAS缺点
ABA 问题
我们想确切知道从上一次看到这个值以来到现在,这个值是否发生过变化。例如,这个值假设从 A 变成了 B,再由 B 变回了 A,此时,我们不仅认为它发生了变化,并且会认为它变化了两次。
假设第一个线程拿到的初始值是 100,然后进行计算,在计算的过程中,有第二个线程把初始值改为了 200,然后紧接着又有第三个线程把 200 改回了 100。等到第一个线程计算完毕去执行 CAS 的时候,它会比较当前的值是不是等于最开始拿到的初始值 100,此时会发现确实是等于 100,所以线程一就认为在此期间值没有被修改过,就理所当然的把这个 100 改成刚刚计算出来的新值,但实际上,在此过程中已经有其他线程把这个值修改过了,这样就会发生 ABA 问题。
版本号解决
在 atomic 包中提供了 AtomicStampedReference 这个类,它是专门用来解决 ABA 问题的,解决思路正是利用版本号,AtomicStampedReference 会维护一种类似 <Object,int> 的数据结构,其中的 int 就是用于计数的,也就是版本号,它可以对这个对象和 int 版本号同时进行原子更新,从而也就解决了 ABA 问题。因为我们去判断它是否被修改过,不再是以值是否发生变化为标准,而是以版本号是否变化为标准,即使值一样,它们的版本号也是不同的。
自旋时间过长
由于单次 CAS 不一定能执行成功,所以 CAS 往往是配合着循环来实现的,有的时候甚至是死循环,不停地进行重试,直到线程竞争不激烈的时候,才能修改成功。
范围不能灵活控制
不能灵活控制线程安全的范围。
执行 CAS 的时候,是针对某一个,而不是多个共享变量的,这个变量可能是 Integer 类型,也有可能是 Long 类型、对象类型等等,但是我们不能针对多个共享变量同时进行 CAS 操作,因为这多个变量之间是独立的,简单的把原子操作组合到一起,并不具备原子性。因此如果我们想对多个对象同时进行 CAS 操作并想保证线程安全的话,是比较困难的。
解决方案: 那就是利用一个新的类,来整合刚才这一组共享变量,这个新的类中的多个成员变量就是刚才的那多个共享变量,然后再利用 atomic 包中的 AtomicReference 来把这个新对象整体进行 CAS 操作,这样就可以保证线程安全。
final关键字和“不变性”
关键字 final 修饰变量的作用是很明确的,那就是意味着这个变量一旦被赋值就不能被修改了,也就是说只能被赋值一次,直到天涯海角也不会“变心”。
赋值时机
成员变量,类中的非 static 修饰的属性; 静态变量,类中的被 static 修饰的属性; 局部变量,方法中的变量。
public class FinalFieldAssignment1 { private final int finalVar = 0; }
class FinalFieldAssignment2 { private final int finalVar; public FinalFieldAssignment2() { finalVar = 0; } }
class FinalFieldAssignment3 { private final int finalVar; { finalVar = 0; } }
空白 final
如果我们声明了 final 变量之后,并没有立刻在等号右侧对它赋值,这种情况就被称为“空白 final”。
/** * 描述: 空白final提供了灵活性 */ public class BlankFinal { //空白final private final int a; //不传参则把a赋值为默认值0 public BlankFinal() { this.a = 0; } //传参则把a赋值为传入的参数 public BlankFinal(int a) { this.a = a; } }
特殊用法:final 修饰参数
关键字 final 还可以用于修饰方法中的参数。在方法的参数列表中是可以把参数声明为 final 的,这意味着我们没有办法在方法内部对这个参数进行修改。
/** * 描述: final参数 */ public class FinalPara { public void withFinal(final int a) { System.out.println(a);//可以读取final参数的值 // a = 9; //编译错误,不允许修改final参数的值 } }
final修饰方法
目前我们使用 final 去修饰方法的唯一原因,就是想把这个方法锁定,意味着任何继承类都不能修改这个方法的含义,也就是说,被 final 修饰的方法不可以被重写,不能被 override。
特例:final 的 private方法
类中的所有 private 方法都是隐式的指定为自动被 final 修饰的,我们额外的给它加上 final 关键字并不能起到任何效果。 由于我们这个方法是 private 类型的,所以对于子类而言,根本就获取不到父类的这个方法,就更别说重写了。在上面这个代码例子中,其实子类并没有真正意义上的去重写父类的 privateEat 方法,子类和父类的这两个 privateEat 方法彼此之间是独立的,只是方法名碰巧一样而已。
final 修饰类
final 修饰类的含义很明确,就是这个类“不可被继承”
如果必须使用 final 方法或类,请说明原因
因为未来代码的维护者,他可能不是很理解为什么我们在这里使用了 final,因为使用后,对他来说是有影响的,比如用 final 修饰方法,那他就不能去重写了,或者说我们用 final 修饰了类,那他就不能去继承了。
总结
它用在变量、方法或者类上时,其含义是截然不同的,所以我们就逐个对这 3 种情况进行了讲解:修饰变量意味着一旦被赋值就不能被修改;修饰方法意味着不能被重写;修饰类意味着不能被继承。
String是不可变的
在 Java 中,字符串是一个常量,我们一旦创建了一个 String 对象,就无法改变它的值,它的内容也就不可能发生变化(不考虑反射这种特殊行为)
String 不可变的好处
字符串常量池
第一个好处是可以使用字符串常量池。在 Java 中有字符串常量池的概念,比如两个字符串变量的内容一样,那么就会指向同一个对象,而不需创建第二个同样内容的新对象
用作 HashMap 的 key
第二个好处就是它可以很方便地用作 HashMap (或者 HashSet) 的 key。通常建议把不可变对象作为 HashMap的 key,比如 String 就很合适作为 HashMap 的 key。
缓存 HashCode
第三个好处就是缓存 HashCode。
这是一个成员变量,保存的是 String 对象的 HashCode。因为 String 是不可变的,所以对象一旦被创建之后,HashCode 的值也就不可能变化了,我们就可以把 HashCode 缓存起来。这样的话,以后每次想要用到 HashCode 的时候,不需要重新计算,直接返回缓存过的 hash 的值就可以了,因为它不会变,这样可以提高效率,所以这就使得字符串非常适合用作 HashMap 的 key。
线程安全
第四个好处就是线程安全,因为具备不变性的对象一定是线程安全的,我们不需要对其采取任何额外的措施,就可以天然保证线程安全。
NIO
epoll的本质
1. 从网卡接收数据说起
计算机结构图
第一步,从硬件的角度看计算机怎样接收网络数据
在 ① 阶段,网卡收到网线传来的数据; 经过 ② 阶段的硬件电路的传输; 最终 ③ 阶段将数据写入到内存中的某个地址上。
子主题
我们只需知道:网卡会把接收到的数据写入内存。 通过硬件传输,网卡接收的数据存放到内存中,操作系统就可以去读取它们。
2. 如何知道接收了数据
第二步,要从CPU的角度来看数据接收。 先了解一下概念--中断
计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时,它应立即去保存数据,保存数据的程序具有较高的优先级(电容可以保存少许电量,供 CPU 运行很短的一小段时间)。
一般而言,由硬件产生的信号需要 CPU 立马做出回应,不然数据可能就丢失了,所以它的优先级很高。CPU 理应中断掉正在执行的程序,去做出响应;当 CPU 完成对硬件的响应后,再重新执行用户程序。中断的过程如下图,它和函数调用差不多,只不过函数调用是事先定好位置,而中断的位置由“信号”决定。
数据来了,网卡硬件产生中断信号,让CPU做出回应
如何知道接收了数据: 当网卡把数据写入到内存后,网卡向 CPU 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。
3. 进程阻塞为什么不占用CPU资源
第三步,要从操作系统进程调度的角度来看数据接收。阻塞是进程调度的关键一环,指的是进程在等待某事件(如接收到网络数据)发生之前的等待状态,recv、select 和 epoll 都是阻塞方法
代码: //创建socket int s = socket(AF_INET, SOCK_STREAM, 0); //绑定 bind(s, ...) //监听 listen(s, ...) //接受客户端连接 int c = accept(s, ...) //接收客户端数据 recv(c, ...); //将数据打印出来 printf(...)
调用 recv 接收数据。recv 是个阻塞方法,当程序运行到 recv 时,它会一直等待,直到接收到数据才往下执行。
阻塞的原理
工作队列
操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得 CPU 使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到 recv 时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。
工作队列中有 A、B 和 C 三个进程。 工作队列:正在CPU中轮询执行的进程。
计算机中运行着 A、B 与 C 三个进程,其中进程 A 执行着上述基础网络程序,一开始,这 3 个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。
等待队列
当进程 A 执行到创建 socket 的语句时,操作系统会创建一个由文件系统管理的 socket 对象(如下图)。这个 socket 对象包含了发送缓冲区、接收缓冲区与等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该 socket 事件的进程。
进程A执行创建的socket 等待队列:它指向所有需求等待该socket事件的进程
创建socket
当程序执行到 recv 时,操作系统会将进程 A 从工作队列移动到该 socket 的等待队列中(如下图)。由于工作队列只剩下了进程 B 和 C,依据进程调度,CPU 会轮流执行这两个进程的程序,不会执行进程 A 的程序。所以进程 A 被阻塞,不会往下执行代码,也不会占用 CPU 资源。
执行到recv时,将进程A的引用放入socket的等待队列中。 当数据接收时,该进程会被唤醒。
socket的等待队列
注:操作系统添加等待队列只是添加了对这个“等待中”进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。上图为了方便说明,直接将进程挂到等待队列之下。
唤醒进程
当 socket 接收到数据后,操作系统将该 socket 等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。同时由于 socket 的接收缓冲区已经有了数据,recv 可以返回接收到的数据。
4. 内核接收网络数据全过程
这一步,贯穿网卡、中断、进程调度的知识,叙述阻塞 recv 下,内核接收数据的全过程。
内核接收数据全过程
进程在 recv 阻塞期间,计算机收到了对端传送的数据(步骤①), 数据经由网卡传送到内存(步骤②), 然后网卡通过中断信号通知 CPU 有数据到达,CPU 执行中断程序(步骤③)。 此处的中断程序主要有两项功能,先将网络数据写入到对应 socket 的接收缓冲区里面(步骤④),再唤醒进程 A(步骤⑤),重新将进程 A 放入工作队列中。
唤醒进程
问题
其一,操作系统如何知道网络数据对应于哪个 socket? 其二,如何同时监视多个 socket 的数据?
第一个问题:因为一个 socket 对应着一个端口号,而网络数据包中包含了 ip 和端口的信息,内核可以通过端口号找到对应的 socket。当然,为了提高处理速度,操作系统会维护端口号到 socket 的索引结构,以快速读取。 第二个问题是多路复用的重中之重,也正是本文后半部分的重点。
5. 同时监视多个socket的简单方法
服务端需要管理多个客户端连接,而 recv 只能监视单个 socket,这种矛盾下,人们开始寻找监视多个 socket 的方法。epoll 的要义就是高效地监视多个 socket。
理解select
不太高效的select
int s = socket(AF_INET, SOCK_STREAM, 0); bind(s, ...); listen(s, ...); int fds[] = 存放需要监听的socket; while(1){ int n = select(..., fds, ...) for(int i=0; i < fds.count; i++){ if(FD_ISSET(fds[i], ...)){ //fds[i]的数据处理 } }}
fds[]存放监听socket。 进程A分别加入这三个socket的等待队列中
当任何一个 socket 收到数据后,中断程序将唤起进程 sock2 接收到了数据,中断程序唤起进程 A
将进程 A 从所有等待队列中移除,再加入到工作队列里面。 (所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面)
当进程 A 被唤醒后,它知道至少有一个 socket 接收了数据。程序只需遍历一遍 socket 列表,就可以得到就绪的 socket。
先准备一个数组 fds,让 fds 存放着所有需要监视的 socket。然后调用 select,如果 fds 中的所有 socket 都没有数据,select 会阻塞,直到有一个 socket 接收到数据,select 返回,唤醒进程。用户可以遍历 fds,通过 FD_ISSET 判断具体哪个 socket 收到数据,然后做出处理
select缺点
1.每次调用 select 都需要将进程加入到所有监视 socket 的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个 fds 列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定 select 的最大监视数量,默认只能监视 1024 个 socket。
2.进程被唤醒后,程序并不知道哪些 socket 收到数据,还需要遍历一次。
补充说明
本节只解释了 select 的一种情形。当程序调用 select 时,内核会先遍历一遍 socket,如果有一个以上的 socket 接收缓冲区有数据,那么 select 直接返回,不会阻塞。这也是为什么 select 的返回值有可能大于 1 的原因之一。如果没有 socket 有数据,进程才会阻塞。
6. epoll的设计思路
措施一:功能分离
相比select, epoll拆分了功能
select 低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。
int s = socket(AF_INET, SOCK_STREAM, 0); bind(s, ...) listen(s, ...) int epfd = epoll_create(...); epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中 while(1){ int n = epoll_wait(...) for(接收到数据的socket){ //处理 } }
先用 epoll_create 创建一个 epoll 对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到 epfd 中,最后调用 epoll_wait 等待数据:
措施二:就绪列表
select 低效的另一个原因在于程序不知道哪些 socket 收到数据,只能一个个遍历
就绪列表示意图 rdlist是就续列表,引用收到数据的socket
如果内核维护一个“就绪列表”,引用收到数据的 socket,就能避免遍历。如下图所示,计算机共有三个 socket,收到数据的 sock2 和 sock3 被就绪列表 rdlist 所引用。当进程被唤醒后,只要获取 rdlist 的内容,就能够知道哪些 socket 收到数据。
7. epoll的原理与工作流程
创建epoll对象
内核创建eventpoll对象 1. eventpoll也就是epfd对象,也是文件系统中的一员,将需要监视的 socket 添加到 epfd 中。 2. 就续列表 可作为epfd的成员
当某个进程调用 epoll_create 方法时,内核会创建一个 eventpoll 对象(也就是程序中 epfd 所代表的对象)。eventpoll 对象也是文件系统中的一员,和 socket 一样,它也会有等待队列。 创建一个代表该 epoll 的 eventpoll 对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为 eventpoll 的成员。
维护监视列表
添加所要监听的socket socket收到数据后,中断程序会操作eventpoll
创建 epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 socket。以添加 socket 为例,如图,如果通过 epoll_ctl 添加 sock1、sock2 和 sock3 的监视,内核会将 eventpoll 添加到这三个 socket 的等待队列中。 当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程
接收数据
就续列表添加引用
当 socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 socket 引用。如图展示的是 sock2 和 sock3 收到数据后,中断程序让 rdlist 引用这两个 socket。 eventpoll 对象相当于 socket 和进程之间的中介,socket 的数据接收并不直接影响进程,而是通过改变 eventpoll 的就绪列表来改变进程状态。 当程序执行到 epoll_wait 时,如果 rdlist 已经引用了 socket,那么 epoll_wait 直接返回,如果 rdlist 为空,阻塞进程。
阻塞和唤醒进程
epoll_wait阻塞进程
假设计算机中正在运行进程 A 和进程 B,在某时刻进程 A 运行到了 epoll_wait 语句。如下图所示,内核会将进程 A 放入 eventpoll 的等待队列中,阻塞进程
epoll 唤醒进程
当 socket 接收到数据,中断程序一方面修改 rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态(如下图)。也因为 rdlist 的存在,进程 A 可以知道哪些 socket 发生了变化。
8. epoll 的实现细节
eventpoll的数据结构
双向链表就是这样一种数据结构,epoll 使用双向链表来实现就绪队列(rdlist)
eventpoll 包含了 lock、mtx、wq(等待队列)与 rdlist 等成员,其中 rdlist 和 rbr 是我们所关心的
注:因为操作系统要兼顾多种功能,以及由更多需要保存的数据,rdlist 并非直接引用 socket,而是通过 epitem 间接引用,红黑树的节点也是 epitem 对象。同样,文件系统也并非直接引用着 socket。为方便理解,本文中省略了一些间接结构。
就续列表的数据结构
程序可能随时调用 epoll_ctl 添加监视 socket,也可能随时删除。当删除时,若该 socket 已经存放在就绪列表中,它也应该被移除。所以就绪列表应是一种能够快速插入和删除的数据结构。
索引结构
既然 epoll 将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的 socket,至少要方便地添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好,epoll 使用了红黑树作为索引结构(对应上图的 rbr)
总结:
开启多个线程,每个线程启动一个epoll文件描述符(同socket差不多),epoll注册多个socket(内核程序会epoll引用注册到socket的等待队列中),epoll中的就绪列表rdlist存放多个正在接收数据的socket引用,线程注册到epoll的等待列表中,执行到epoll_wait时,多个线程并发运行。
对上层应用,select是轮询,epoll是发生时触发。epoll是系统做的轮询
图文并茂讲解epoll原理
epoll基础简介
相关函数
epoll_create函数
epoll_create函数用于创建epoll文件描述符,该文件描述符用于后续的epoll操作 内核空间: 1. 创建struct eventpoll对象 2. 初始化等待队列wq 3. 初始化就绪队列rdlist(监听到epoll事件,将事件放到到就绪队列中) 4. 初始化红黑树rbr(保存epoll事件) #include <sys/epoll.h> int epoll_create(int size); 参数: size:目前内核还没有实际使用,只要大于0就行 返回值: 返回epoll文件描述符
epoll_ctl函数
epoll_ctl函数用于增加,删除,修改epoll事件,epoll事件会存储于内核epoll结构体红黑树中。 #include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 参数: epfd:epoll文件描述符 op:操作码 EPOLL_CTL_ADD:插入事件 EPOLL_CTL_DEL:删除事件 EPOLL_CTL_MOD:修改事件 fd:事件绑定的套接字文件描述符 events:事件结构体 返回值: 成功:返回0 失败:返回-1
struct epoll_event结构体
#include <sys/epoll.h> struct epoll_event{ uint32_t events; //epoll事件,参考事件列表 epoll_data_t data; } ; typedef union epoll_data { void *ptr; int fd; //套接字文件描述符 uint32_t u32; uint64_t u64; } epoll_data_t;
epoll事件列表
头文件:<sys/epoll.h> enum EPOLL_EVENTS { EPOLLIN = 0x001, //读事件 EPOLLPRI = 0x002, EPOLLOUT = 0x004, //写事件 EPOLLRDNORM = 0x040, EPOLLRDBAND = 0x080, EPOLLWRNORM = 0x100, EPOLLWRBAND = 0x200, EPOLLMSG = 0x400, EPOLLERR = 0x008, //出错事件 EPOLLHUP = 0x010, //出错事件 EPOLLRDHUP = 0x2000, EPOLLEXCLUSIVE = 1u << 28, EPOLLWAKEUP = 1u << 29, EPOLLONESHOT = 1u << 30, EPOLLET = 1u << 31 //边缘触发 };
epoll_wait函数
epoll_wait用于监听套接字事件,可以通过设置超时时间timeout来控制监听的行为为阻塞模式还是超时模式 #include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); 参数: epfd:epoll文件描述符 events:epoll事件数组 maxevents:epoll事件数组长度 timeout:超时时间 小于0:一直等待 等于0:立即返回 大于0:等待超时时间返回,单位毫秒 返回值: 小于0:出错 等于0:超时 大于0:返回就绪事件个数
epoll软件架构
子主题
LT模式和ET模式
特点
epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
LT模式:水平触发
只要这个文件描述符还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作 socket读触发:socket接收缓冲区有数据,会一直触发epoll_wait EPOLLIN事件,直到数据被用户读取完。 socket写触发:socket可写,会一直触发epoll_wait EPOLLOUT事件。
ET模式:边缘触发
只有数据到来才触发,不管缓存区中是否还有数据,缓冲区剩余未读尽的数据不会导致epoll_wait返回; socket读触发:socket数据从无到有,会触发epoll_wait EPOLLIN事件,只会触发一次EPOLLIN事件,用户检测到事件后,需一次性把socket接收缓冲区数据全部读取完,读取完的标志为recv返回-1,errno为EAGAIN。 socket写触发:socket可写,会触发一次epoll_wait EPOLLOUT事件。
阻塞和非阻塞
epoll阻塞:epoll自身是阻塞的,我们可以通过epoll_wait超时参数设置epoll阻塞行为。 epoll监听套接字阻塞:epoll监听套接字阻塞是指插入epoll监听事件的套接字设置为阻塞模式。 epoll监听套接字设置成阻塞还是非阻塞? 这个问题可以肯定的回答是非阻塞,因为epoll是为高并发设计的,任何的其他阻塞行为,都会影响epoll高效运行。
epoll为什么高效?
红黑树
红黑树提高epoll事件增删查改效率。
回调通知机制
当epoll监听套接字有数据读或者写时,会通过注册到socket的回调函数通知epoll,epoll检测到事件后,将事件存储在就绪队列(rdllist)。
就绪队列
epoll_wait返回成功后,会将所有就绪事件存储在事件数组,用户不需要进行无效的轮询,从而提高了效率。
几个结论
socket 内核对象 ≈ fd 文件描述符 ≈ TCP 连接;
服务器要接收客户端的数据,要建立 socket 内核结构,主要包含两个重要的数据结构,(进程)等待队列,和(数据)接收队列,socket 在进程中作为一个文件,可以用文件描述符 fd 来表示,为了方便理解,本文中, socket 内核对象 ≈ fd 文件描述符 ≈ TCP 连接;
阻塞IO
描述:服务器端采用多线程,当 accept 一个请求后,开启线程进行 recv,可以完成并发处理,但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换会带来很大的开销,10000 个线程真正发生读写事件的线程数不会超过 20%,每次 accept 都开一个线程也是一种资源浪费。
1. 建立连接,调用socket内核对象 2. 进入等待队列 3. 数据到达网卡 4. 拷贝数据到内存中 5. 网卡向CPU发出中断 6. CPU向内核发出软中断 7. 将内存数据根据IP和端口拷贝到对应的socket缓存区 8. 服务器接收的数据到达socket缓冲区,即数据接收队列 9. 进程A被回调函数唤醒后,将内核缓冲区数据拷贝到用户空间
子主题
三个问题
1. 进程在 recv 的时候大概率会被阻塞掉,导致一次进程切换; 2. 当 TCP 连接上的数据到达服务端的网卡、并从网卡复制到内核空间 socket 的数据等待队列时,进程会被唤醒,又是一次进程切换;并且,在用户进程继续执行完 recvfrom() 函数系统调用,将内核空间的数据拷贝到了用户缓冲区后,用户进程才会真正拿到所需的数据进行处理; 3. 一个进程同时只能等待一条连接,如果有很多并发,则需要很多进程; 总结:一次数据到达会进行两次进程切换,一次数据读取有两处阻塞,单进程对单连接。
非阻塞IO
描述:服务器端当 accept 一个请求后,加入 fds 集合,每次轮询一遍 fds 集合 recv(非阻塞)数据,没有数据则立即返回错误,每次轮询所有 fd(包括没有发生读写事件的fd)会很浪费 cpu。
如果没有数据从网卡到达内核 socket 的等待队列时,系统调用会直接返回,而不是阻塞的等待。 用户进程会重复发送请求检查数据是否到达内核空间,如果没有到,则立即返回,不会阻塞。
IO多路复用
服务器端采用单线程通过 select/epoll 等系统调用获取 fd 列表,遍历有事件的 fd 进行 accept/recv/send,使其能支持更多的并发连接请求。
select
描述:客户端操作服务器时就会产生这三种文件描述符(简称fd):writefds(写)、readfds(读)、和 exceptfds(异常)。select 会阻塞住监视 3 类文件描述符,等有数据、可读、可写、出异常或超时就会返回;返回后通过遍历 fdset 整个数组来找到就绪的描述符 fd,然后进行对应的 IO 操作。
没有数据到达网卡
elect进程有数据到达时,会通过回调函数唤醒进程进行数据的读取
缺点
1. 轮询:由于是采用轮询方式全盘扫描,会随着文件描述符 FD 数量增多而性能下降。 2. fd_set从用户空间拷贝到内核空间:每次调用 select(),都需要把 fd 集合从用户态拷贝到内核态,并进行遍历(消息传递都是从内核到用户空间)。 3. 打开的FD数量有限:单个进程打开的 FD 是有限制(通过FD_SETSIZE设置)的,默认是 1024 个,可修改宏定义,但是效率仍然慢。 4. select只返回就续文件个数:具体哪个文件可读还需要遍历;(epoll 优化为只返回就绪的文件描述符,无需做无效的遍历)
poll
描述:管理多个描述符也是进行轮询,根据描述符的状态进行处理,但 poll 无最大文件描述符数量的限制,因其基于链表存储。
epoll
例
有100万用户同时与一个进程保持着TCP连接,而每一时刻只有几十个或几百个TCP连接是活跃的(接收TCP包),也就是说在每一时刻进程只需要处理这100万连接中的一小部分连接
如何高效的处理
以前的处理方式
1. 用户态内存到内核态内存复制:在某一时刻,进程收集有事件的连接时,把100万连接套接字传给操作系统(用户态内存到内核态内存的大量复制),而由操作系统内核寻找这些连接上有没有未处理的事件,是巨大的资源浪费,像select,poll就是这样做的。 2. 处理连接数:1024个
epoll改造原处理方式
分3部分改造
1. 调用epoll_create建立一个epoll对象(在epoll文件系统中给这个句柄分配资源); 执行epoll_create()时,创建了红黑树和就绪链表;
//epoll_create创建eventpoll结构体 struct eventpoll { ... /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件, 也就是这个epoll监控的事件*/ struct rb_root rbr; /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/ struct list_head rdllist; ... };
2. 调用epoll_ctl向epoll对象中添加这100万个连接的套接字;
epoll_ctl在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。
//在epoll中对于每一个事件都会建立一个epitem结构体,如下所示: struct epitem { ... //红黑树节点 struct rb_node rbn; //双向链表节点 struct list_head rdllink; //事件句柄等信息 struct epoll_filefd ffd; //指向其所属的eventepoll对象 struct eventpoll *ep; //期待的事件类型 struct epoll_event event; ... }; // 这里包含每一个事件对应着的信息。
子主题
3. 调用epoll_wait收集发生事件的连接。
只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户
回调通知机制
epoll事件与网卡驱动程序建立回调关系,ep_poll_callback回调方法,会把事件放到rdlist双向链表中
总结
一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。
epoll_create
创建了红黑树和就绪链表;
epoll_ctl
执行epoll_ctl()时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;
epoll_wait
执行epoll_wait()时立刻返回准备就绪链表里的数据即可
epoll反应堆模型
epoll_create(); // 创建监听红黑树 epoll_ctl(); // 向树上添加监听fd epoll_wait(); // 监听 有客户端连接上来--->lfd调用acceptconn()--->将cfd挂载到红黑树上监听其读事件---> epoll_wait()返回cfd--->cfd回调recvdata()--->将cfd摘下来监听写事件---> epoll_wait()返回cfd--->cfd回调senddata()--->将cfd摘下来监听读事件--->...--->
NIO模型下的几种处理IO的不同机制
IO:(同步阻塞IO) 当线程调用read()或write()时,该线程将被阻塞,直到有一些数据要读取,或者数据被完全写入,在此期间,该线程无法执行任何其他操作。
NIO:(多路复用的同步非阻塞IO)--不断的去内核询问是否有数据到来 允许线程请求从通道读取数据,并且只获取当前可用的内容,如果当前没有数据可用,线程可以继续使用其他内容,而不是在数据可供读取之前保持阻塞状态。 是一种基于通道(Channel)和缓冲区(Buffer)的I/O方式,以块的方式处理数据,采用多路复用的IO模型。 核心组件包括Channel、Buffer和Selector。 允许从Channel读取数据到Buffer,或从Buffer写入数据到Channel。 通道可以是双向的,与IO中的单向流形成对比。
AIO:(异步非阻塞IO) 是 NIO 的升级版本,提供了异步非堵塞的 IO 操作方式,因此人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
select:(同步IO) 不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。 原理是通过在多个通道上进行等待操作,并在其中一个通道可以进行操作时执行相应的代码块。 是一个阻塞语句,会等待至少一个通道可用。 每次调用select时,都需要将文件描述符集合从用户态拷贝到内核态,开销较大。
poll:(同步IO) 原理与select类似,但描述fd集合的方式不同,使用pollfd结构而不是fd_set结构。 是链式的,没有最大连接数的限制。 具有水平触发的特点,即已就绪的fd如果未被处理,下次poll时仍会通知。
epoll:(同步IO) 通过socket等待队列、eventpoll等待队列、就绪队列和红黑树等机制实现。 避免了select和poll中每次调用都需要从用户态拷贝文件描述符集合到内核态的开销。 支持大量文件描述符,且效率更高。
非阻塞IO: 虽然进程大部分时间都不会被block,但它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。 异步非阻塞IO: 它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
select、poll、epoll在本质上都被认为是同步I/O
需要用户进程主动发起I/O操作,并在I/O操作完成之前,用户进程需要等待或轮询以检查操作的状态。
具体来说: 阻塞特性:无论是select、poll还是epoll,它们都会阻塞用户进程的执行,直到至少有一个文件描述符就绪或者超时。即使epoll通过其内部机制减少了无效的轮询,它仍然需要用户进程在调用epoll_wait时等待,直到有事件发生。 主动轮询:尽管epoll通过事件驱动的方式提高了效率,但它仍然需要用户进程主动调用epoll_wait来检查是否有事件发生。这意味着用户进程不能完全从I/O操作中解脱出来,去执行其他任务,直到它确定至少有一个事件已经就绪。 数据拷贝:在I/O操作完成后,无论是使用select、poll还是epoll,数据都需要从内核空间拷贝到用户空间。这个拷贝过程是由用户进程来触发的,并且会阻塞用户进程的执行,直到拷贝完成。 因此,尽管epoll通过其事件驱动机制减少了无效的轮询,提高了性能,但它仍然需要用户进程主动等待和检查I/O操作的状态,所以它在本质上仍然是同步I/O。 与异步I/O相比,异步I/O允许用户进程发起I/O操作后,立即返回并继续执行其他任务,而不需要等待或轮询I/O操作的状态。当I/O操作完成时,系统会通过某种机制(如回调函数)通知用户进程。这样,用户进程可以更加高效地利用CPU资源,提高程序的并发性和响应性。
select,poll,epoll是如何轮询的检查事件的
首先,select机制是通过将已连接的Socket放到一个文件描述符集合中,然后调用select函数将文件描述符集合拷贝到内核态,让内核来检查是否有网络事件产生。内核会遍历文件描述符集合,检查是否有可读、可写或异常事件发生,并将就绪的文件描述符标记为可读或可写状态。然后,select函数将修改后的文件描述符集合拷贝回用户态,用户进程需要再次遍历文件描述符集合,找到并处理那些就绪的文件描述符。因此,select在轮询检查事件时需要进行两次遍历和两次数据拷贝,效率相对较低。 接着,poll机制与select类似,也使用文件描述符集合来检查事件。但是,poll使用动态数组和链表的形式来组织文件描述符,突破了select机制中文件描述符个数的限制。在轮询时,poll同样需要将文件描述符集合从用户态拷贝到内核态,并由内核来遍历和检查事件。内核会将就绪的文件描述符标记为可读或可写状态,并返回给用户进程。用户进程同样需要遍历文件描述符集合,找到并处理就绪的文件描述符。因此,poll在轮询检查事件时的效率与select相近,也存在遍历和拷贝的开销。 最后,epoll机制是专门为处理大量并发连接而设计的,它采用了基于事件驱动的方式。在初始化时,epoll会创建一个epoll实例,并使用epoll_ctl函数注册需要监视的文件描述符及其对应的事件。在轮询时,用户进程调用epoll_wait函数,该函数会阻塞当前线程,直到至少有一个文件描述符就绪或超时。与select和poll不同,epoll机制在内核中维护了一个事件表,用于存储已注册的文件描述符及其事件。当某个文件描述符上的事件发生时,内核会将其添加到就绪链表中。因此,epoll_wait函数实际上是在内核的就绪链表中查找和返回就绪的文件描述符,无需再次遍历整个文件描述符集合。这种机制使得epoll在轮询检查事件时具有更高的效率,能够处理大量的并发连接。 总结来说,select、poll和epoll在轮询检查事件时的主要区别在于数据拷贝和遍历的次数以及内核中事件处理的方式。select和poll需要进行多次遍历和拷贝,效率相对较低;而epoll通过维护内核中的事件表,减少了不必要的遍历和拷贝开销,提高了处理大量并发连接的效率。
select、poll和epoll在轮询检查事件时,如何使用的线程来检查,是用了一个线程,还是用了多个线程来检查
在轮询检查事件时,select、poll和epoll本身并不直接涉及线程的使用。它们主要是系统调用,用于监视文件描述符的状态变化,如可读、可写或异常。这些系统调用可以在单个线程中使用,也可以在多线程环境中使用,具体取决于应用程序的设计和需求。
java8特性
简介
java.util.Stream 表示能应用在一组元素上一次执行的操作序列。Stream 操作分为中间操作或者最终操作两种,最终操作返回一特定类型的计算结果,而中间操作返回Stream本身,这样就可以将多个操作依次串起来。Stream 的创建需要指定一个数据源,比如 java.util.Collection的子类,List或者Set, Map不支持。Stream的操作可以串行stream()执行或者并行parallelStream()执行。
1. forEach 循环
@Test public void forEach(){ // 你不鸟我,我也不鸟你 List<String> list = Arrays.asList("you", "don't", "bird", "me", ",", "I", "don't", "bird", "you"); // 方式一:JDK1.8之前的循环方式 for (String item: list) { System.out.println(item); } // 方式二:使用Stream的forEach方法 // void forEach(Consumer<? super T> action) list.stream().forEach(item -> System.out.println(item)); // 方式三:方式二的简化方式 // 由于方法引用也属于函数式接口,所以方式二Lambda表达式也可以使用方法引用来代替 // 此种方式就是方式一、方式二的简写形式 list.stream().forEach(System.out::println); }
2. filter 过滤
反射
子主题
数组结构
HashMap
描述
HashMap实例有两个影响其性能的参数: 初始容量和负载因子。 默认的负载因子(0.75)在时间和空间成本之间提供了很好的权衡。
从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。
常量定义
默认初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
为了避免使用魔法数字,使得常量定义本身就具有自我解释的含义。 强调这个数必须是 2 的幂。
hash值的算法
为什么这么设计hash
右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。 而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。
架构
UML图
用例视图
用例图
描述
从用户的角度描述了系统的功能,并指出各个功能的执行者,强调用户的使用者,系统为执行者完成哪些功能。
需求阶段
主要在需求分析阶段,通过反映用户和软件系统之间的交互,描述软件的功能需求,图中小人形象的元素被称为角色,角色可以是人,也可以是其他的系统。系统可能比较复杂,可能只包含其中一小部分功能,这些功能被一个矩形框框起来,这个框被称为用例的边界,框架的椭圆表示一个一个的功能,功能之间可以调用依赖,也可以进行功能扩展,通常需要对用例图配以文字说明,形成需求文档。
回答了两个问题:
1. 是谁用软件 2. 软件的功能
设计视图
类图
需求分析
在需求分析阶段,也可以将关键的领域模型对象图,用类图画出来,这个阶段关注的是领域对象的识别及其关系,所以通常用简化的类图来描述,只画出类的名字及其关系
详细设计
设计出来后开发工程师只需要按照类图实现代码就可以了,只要类的方法逻辑不是太复杂,不同的工程师实现出来的代码几乎是一样的,从而保证软件的规范统一。 实践中通常不需要把一个软件所有的类都画出来,把核心的,有代表性的,有一定技术难度的类画出来,通常用简化的类图来描述
描述
用户根据用例图抽象成类,描述类的内部结构和类与类之间的关系,是一种静态结构图。 在UML类图中,常见的有以下几种关系: 泛化(Generalization), 实现(Realization),关联(Association),聚合(Aggregation),组合(Composition),依赖(Dependency)。 各种关系的强弱顺序: 泛化 = 实现 > 组合 > 聚合 > 关联 > 依赖
几种关系
泛化
是一种继承(extends)关系,表示一般与特殊的关系,它指定了子类如何继承父类的所有特征和行为。例如:老虎是动物的一种,即有老虎的特性也有动物的共性。
实现
是一种类与接口(interface)的关系,表示类是接口所有特征和行为的实现。
关联
是一种拥有的关系
如老师与学生
是一种拥有的关系,它使一个类知道另一个类的属性和方法;如:老师与学生,丈夫与妻子关联可以是双向的,也可以是单向的。双向的关联可以有两个箭头或者没有箭头,单向的关联有一个箭头。
代码体现:成员变量
聚合
是关联关系的一种
部分可以离开整体而单独存在
如车和轮胎
是整体与部分的关系,且部分可以离开整体而单独存在。 如车和轮胎是整体和部分的关系,轮胎离开车仍然可以存在。
代码体现:成员变量
组合
组合关系是关联关系的一种
部分不能离开整体而单独存在
如公司与部门
是整体与部分的关系,但部分不能离开整体而单独存在。如公司和部门是整体和部分的关系,没有公司就不存在部门。
代码体现:成员变量
箭头及指向:带实心菱形的实线,菱形指向整体
依赖
是一种使用的关系
是一种使用的关系,即一个类的实现需要另一个类的协助,所以要尽量不使用双向的互相依赖.
代码表现:局部变量、方法的参数或者对静态方法的调用
各种类图关系
对象图
描述的是参与交互的各个对象在交互过程中某一时刻的状态。对象图可以被看作是类图在某一时刻的实例。
进程视图
序列图-时序图
需求阶段
如果在需求阶段,就提出要和现有的某些子系统整合,可以通过时序图描述新系统和原来的子系统的调用关系
详细设计
类的时序图,指导最终的代码开发,如果某个类方法内部有比较复杂的逻辑
描述参与者之间的动态调用关系,每个参与者有一条垂直向下的生命线,用虚线表示,而参与者之间的消息,也从上到下表示调用的前后顺序关系,每个生命线都有一个激活条,只有在参与者活动的时候才是激活的。 序列图通常用于表述对象之间的交互,这个对象可以是类对象,也可以是更大粒度的参与者,比如组件、服务器、子系统,总之描述不同参与者之间的交互的,都可以使用序列图。也就是说在软件设计的不同阶段,都可以画序列图。
交互图的一种,描述了对象之间消息发送的先后顺序,强调时间顺序。 序列图的主要用途是把用例表达的需求,转化为进一步、更加正式层次的精细表达。用例常常被细化为一个或者更多的序列图。同时序列图更有效地描述如何分配各个类的职责以及各类具有相应职责的原因。 消息用从一个对象的生命线到另一个对象生命线的箭头表示。箭头以时间顺序在图中从上到下排列。
涉及的元素
生命线
生命线名称可带下划线。当使用下划线时,意味着序列图中的生命线代表一个类的特定实例。
同步消息
同步等待消息
异步消息
异步发送消息,不需等待
注释
约束
组合
组合片段用来解决交互执行的条件及方式。它允许在序列图中直接表示逻辑组件,用于通过指定条件或子进程的应用区域,为任何生命线的任何部分定义特殊条件和子进程。常用的组合片段有:抉择、选项、循环、并行。
协作图
整体分析
交互图的一种,描述了收发消息的对象的组织关系,强调对象之间的合作关系。时序图按照时间顺序布图,而写作图按照空间结构布图
状态图
需求阶段
单个对象生命周期的状态变更,业务系统中很多重要的领域对象,都有比较复杂的状态变迁,比如账号,有创建状态、激活状态、冻结状态、欠费状态等等各种状态,因此用户、订单、商品、红包这些常见的领域模型都有多种状态。这些状态的变迁描述,可以在用例图中用文字描述,随着角色的各种操作而改变。但是这种描述方式,状态散乱在各处,容易弄错,UML状态图可以很好的解决这一问题。 一张状态图描述一个对象生命周期的各种状态以及期变迁的关系。
是一种由状态、变迁、事件和活动组成的状态机,用来描述类的对象所有可能的状态以及时间发生时状态的转移条件。
活动图 Activity Diagrams 【关键的业务流程可以用活动图】
是状态图的一种特殊情况 主要描述过程逻辑,业务流程UML中没有流程图,很多时候人们用活动图代替流程图,活动图和早期流程图的图形元素也很接近,实心圆代表流程开始,空心圆代表流程结束,圆角矩形表示活动,菱形表示分支判断
如果某个类方法内部有比较复杂的逻辑,可以画方法的活动图进行描述
是状态图的一种特殊情况,这些状态大都处于活动状态。本质是一种流程图,它描述了活动到活动的控制流。 交互图强调的是对象到对象的控制流,而活动图则强调的是从活动到活动的控制流。 活动图是一种表述过程基理、业务过程以及工作流的技术。 它可以用来对业务过程、工作流建模,也可以对用例实现甚至是程序实现来建模。
带泳道的活动图
可以根据活动的范围,将活动根据领域、系统、角色等划分到不同的泳道中,使流程边界更加清晰
泳道表明每个活动是由哪些人或哪些部门负责完成。
带对象流的活动图
用活动图描述某个对象时,可以把涉及到的对象放置在活动图中,并用一个依赖将其连接到进行创建、修改和撤销的动作状态或者活动状态上,对象的这种使用方法就构成了对象流。对象流用带有箭头的虚线表示。
实现视图
构件图
概要设计
因为组件的粒度比较粗,通常用以描述设计软件的模块,及其之间的关系,需要在设计早期阶段就画出来,因此组件图一般用在概要设计阶段
组件是比类更大粒度的设计元素,一个组件中通常包含多个类,组件图有时候和包的用途比较接近,但是组件图既可以描述逻辑上的组件,也可以描述物理上的组件,比如一个JAR,一个DLL等,因此组件图更灵活一点,实践中用组件图而不是包图进行模块设计,更常见一些。组件图描述组件之间的静态关系,主要是依赖关系。如果想要描述组件之间的动态调用关系,可以使用组件序列图,以组件作为参与者,描述组件之间的消息调用关系。
构件图是用来表示系统中构件与构件之间,类或接口与构件之间的关系图。其中,构建图之间的关系表现为依赖关系,定义的类或接口与类之间的关系表现为依赖关系或实现关系。
通过组件图以及组件时序图设计软件主要模块及其关系,设计软件主要模块及其关系,还可以通过组件活动图描述组件之间的流程逻辑
拓扑视图
部署图
概要设计
描述软件系统的最终部署情况,需要部署多少台服务器,关键组件都部署在哪些服务器上。 部署图是软件系统最终物理呈现的蓝图,根据部署图所有相关者,客户、老板、工程师都能够清晰地了解最终运行的系统,物理上是什么样子,和现有系统服务器的关系,和第三方服务器的关系。根据部署图还可以估算出服务器和第三方软件的采购成本。因此部署图是整个软件设计模型中比较宏观的一张图,在设计早期就需要画的一张模型图,根据部署图各方可以讨论是否对这个方案认可,只有对部署图达成共识,才能够继续后面的细节设计,主要用在概要设计阶段
描述了系统运行时进行处理的结点以及在结点上活动的构件的配置。强调了物理设备以及之间的连接关系。 描述一个具体应用的主要部署结构,通过对各种硬件,在硬件中的软件以及各种连接协议的显示,可以很好的描述系统是如何部署的;平衡系统运行时的计算资源分布;可以通过连接描述组织的硬件网络结构或者是嵌入式系统等具有多种硬件和软件相关的系统运行模型。
UML与软件工程
图的差异比较
1.序列图(时序图)VS协作图
序列图和协作图都是交互图。二者在语义上等价,可以相互转化。但是侧重点不同:序列图侧重时间顺序,协作图侧重对象间的关系。 共同点:时序图与协作图均显示了对象间的交互。 不同点:时序图强调交互的时间次序。 协作图强调交互的空间结构。
2.状态图VS活动图
状态图和活动图都是行为图。状态图侧重从行为的结果来描述,活动图侧重从行为的动作来描述。状态图描述了一个具体对象的可能状态以及他们之间的转换。在实际的项目中,活动图并不是必须的,需要满足以下条件:1、出现并行过程&行为;2、描述算法;3、跨越多个用例的活动图。
3.活动图VS交互图
二者都涉及到对象和他们之间传递的关系。区别在于交互图观察的是传送消息的对象,而活动图观察的是对象之间传递的消息。看似语义相同,但是他们是从不同的角度来观察整个系统的。
软件建模与软件设计
如何做软件设计
解决方法就是软件建模,软件设计的输出,就是软件的抽象模型,这些模型之上配上文字说明,就形成了软件的设计文档,模型是对客观存在的抽象。
软件开发中有两个存在:
1. 领域问题
比如我们要开发一个电子商务网站,客观的领域问题就是如何做生意,卖家如何管理商品,管理订单,服务用户,买家如何挑选商品,如何下订单,如何支付等等
2. 软件系统
软件由哪些类组成,这些类如何组织成一个一个的组件,这些类和组件之间的依赖关系是如何的,运行期如何调用,需要部署多少台服务器,服务器之间如何通信
我们要对领域问题和软件系统进行分析、设计、抽象,另一方面我们根据抽象出来的模型,开发、实现出最终的软件系统,这就是软件开发的主要过程。
大型架构的演进之路
第一部分是大型互联网系统的特点
高并发和大流量
大规模的并发用户访问会对系统的处理能力造成巨大的冲击,系统必须要有足够强的处理能力才能够满足。同时有这么多用户来访问,产生了巨大的访问流量,对系统的抗压能力形成了考验。
高可用
互联网没有下班时间,所以一直要保持高可用,7×24 小时永不间断。为了保证系统的高可用,必须要进行特别的系统架构设计。
海量的数据存储
互联网需要满足大量的用户使用,所以这些用户会产生很多的数据,需要对这些数据进行重组和管理。除了用户提交的数据,互联网还会采集很多其它的数据,包括一些用户行为的数据、第三方的数据以及网络爬虫获取的数据,通过大数据技术对这些数据做进一步分析,对用户进行更精准的营销和服务,以发现新的业务增长点。
用户分布广泛,网络情况复杂
互联网是为全球用户提供服务的,用户分布范围广,各地的网络情况千差万别,为了使所有用户能够得到统一的良好的体验,需要对系统架构进行相关的设计。
安全环境恶劣
因为互联网是开放的,所以互联网站很容易就会受到攻击。
需求变化快,发布频繁
应市场,满足用户需求,发布频率是非常高的。比如 Office 这样的产品发布版本是以年为单位的,而大型网站的产品发布一般是以周为单位的,每个星期都会发布新的版本来更新产品特性。
第二部分是系统处理能力提升的两种途径
描述:因为互联网主要面对的技术挑战就是用户量不断上升产生的并发访问压力以及数据存储压力,所以系统需要更强的处理能力才能解决这些问题。那么如何解决这些问题?主要有两种途径。
垂直伸缩
垂直伸缩就是提升单台服务器的处理能力,比如说用更快频率、更多核的 CPU,用更大的内存,用更快的网卡,用更多的磁盘组成一台服务器,使单台服务器的处理能力得到提升,通过这种手段提升系统的处理能力。
局限
当垂直伸缩达到一定程度以后,继续增加计算需要花费更多的钱。
垂直伸缩是有物理极限的。单单一台机器的处理能力是有极限的,即使是大型机,也有自己的物理极限,它不可能无限地伸缩下去。
操作系统的设计或者应用程序的设计制约着垂直伸缩。因为垂直伸缩就意味着程序在单一服务器上运行,那么这个程序应用以及操作系统要相应具备管理这么庞大的计算资源的能力。
水平伸缩
使用更多的服务器,将这些服务器构成一个分布式集群。这个集群统一对外提供服务,来提高系统整体的处理能力。
优点
采用水平伸缩,只要架构合理,能够将服务器添加到集群中,你的系统是可以始终正常运行的。它没有极限,成本也不会在某个临界点突然增加。甚至,逐渐增加服务器,获得更强的计算处理能力,还比以前的服务器更便宜,因为硬件的价格总是在不断下降的
第三部分是大型互联网系统架构演化过程
了解概念
分布式
系统中的多个模块在不同服务器上部署,即可称为分布式系统,如Tomcat和数据库分别部署在不同的服务器上,或两个相同功能的Tomcat分别部署在不同服务器上
高可用
系统中部分节点失效时,其他节点能够接替它继续提供服务,则可认为系统具有高可用性
集群
一个特定领域的软件部署在多台服务器上并作为一个整体提供一类服务,这个整体称为集群。 如Zookeeper中的Master和Slave分别部署在多台服务器上,共同组成一个整体提供集中配置服务。 在常见的集群中,客户端往往能够连接任意一个节点获得服务,并且当集群中一个节点掉线时,其他节点往往能够自动的接替它继续提供服务,这时候说明集群具有高可用性
负载均衡
请求发送到系统时,通过某些方式把请求均匀分发到多个节点上,使系统中每个节点能够均匀的处理请求负载,则可认为系统是负载均衡的
正向代理和反向代理
正向代理
系统内部要访问外部网络时,统一通过一个代理服务器把请求转发出去,在外部网络看来就是代理服务器发起的访问,此时代理服务器实现的是正向代理
反向代理
当外部请求进入系统时,反向代理服务器把该请求转发到系统中的某台服务器上,对外部请求来说,与之交互的只有代理服务器,此时代理服务器实现的是反向代理。
总结
正向代理是代理服务器代替系统内部来访问外部网络的过程, 反向代理是外部请求访问系统时通过代理服务器转发到内部服务器的过程。
单机架构
应用数量与用户数都较少,可以把Tomcat和数据库部署在同一台服务器上。
新的技术挑战
随着用户数的增长,Tomcat和数据库之间的竞争资源,单机性能不足以支撑业务,架构演进势在必行
第1次演进:Tomcat与数据库分开部署
将 Tomcat 和数据库分别独占服务器资源,显著提高两者各自性能
新的技术挑战
随着用户数的增长,并发读写数据库成为瓶颈
第2次演进:引入本地缓存和分布式缓存
在Tomcat服务器上增加本地缓存,并在外部增加分布式缓存,缓存热门商品信息或热门商品的html页面等。通过缓存能把绝大多数请求在读写数据库前拦截掉,大大降低数据库压力。其中涉及的技术包括:使用memcached作为本地缓存,使用Redis作为分布式缓存,还会涉及缓存一致性、缓存穿透/击穿、缓存雪崩、热点数据集中失效等问题。
引用缓存
新的技术挑战
缓存抗住了大部分的访问请求,随着用户数的增长,并发压力主要落在单机的Tomcat上,响应逐渐变慢
第3次演进:引入反向代理实现负载均衡
在多台服务器上分别部署Tomcat,使用反向代理软件(Nginx)把请求均匀分发到每个Tomcat中。涉及的技术包括:Nginx、HAProxy,两者都是工作在网络第七层的反向代理软件,主要支持http协议,还会涉及session共享、文件上传下载的问题。
引用反向代理
新的技术挑战
反向代理使应用服务器可支持的并发量大大增加,但并发量的增长也意味着更多请求穿透到数据库,单机的数据库最终成为瓶颈
第4次演进:数据库读写分离
把数据库划分为读库和写库,读库可以有多个,通过同步机制把写库的数据同步到读库。 对于需要查询最新写入数据场景,可通过在缓存中多写一份,通过缓存获得最新数据。 涉及的技术包括:Mycat,它是数据库中间件,可通过它来组织数据库的分离读写和分库分表,客户端通过它来访问下层数据库,还会涉及数据同步,数据一致性的问题。
数据库读写分离
新的技术挑战
业务逐渐变多,不同业务之间的访问量差距较大,不同业务直接竞争数据库,相互影响性能。
第5次演进:数据库按业务分库
数据库按业务分库,把不同业务的数据保存到不同的数据库中,使业务之间的资源竞争降低,对于访问量大的业务,可以部署更多的服务器来支撑。
按业务分库
新的技术挑战
随着用户数的增长,单机的写库会逐渐会达到性能瓶颈
第6次演进:把大表拆分为小表
针对支付记录,可按照小时创建表,每个小时表继续拆分为小表,使用用户ID或记录编号来路由数据
大表拆分小表
新的技术挑战
数据库和Tomcat都能够水平扩展,可支撑的并发大幅提高。然而随着用户数的增长,最终单机的Nginx会成为瓶颈
第7次演进:使用LVS或F5来使多个Nginx负载均衡
由于瓶颈在Nginx,因此无法通过两层的Nginx来实现多个Nginx的负载均衡。
图中的LVS和F5是工作在网络第四层的负载均衡解决方案,其中LVS是软件,运行在操作系统内核态,可对TCP请求或更高层级的网络协议进行转发,因此支持的协议更丰富,并且性能也远高于Nginx,可假设单机的LVS可支持几十万个并发的请求转发
新的技术挑战
由于LVS也是单机的,随着并发数增长到几十万时,LVS服务器最终会达到瓶颈。 此时用户数达到千万甚至上亿级别,用户分布在不同的地区,与服务器机房距离不同,导致了访问的延迟会明显不同
第8次演进:通过DNS轮询实现机房间的负载均衡
在DNS服务器中可配置一个域名对应多个IP地址,每个IP地址对应到不同的机房里的虚拟IP。
新的技术挑战
随着数据的丰富程度和业务的发展,检索、分析等需求越来越丰富,单单依靠数据库无法解决如此丰富的需求
第9次演进:引入NoSQL数据库和搜索引擎等技术
当数据库中的数据多到一定规模时,数据库就不适用于复杂的查询了,往往只能满足普通查询的场景。 对于统计报表场景,在数据量大时不一定能跑出结果,而且在跑复杂查询时会导致其他查询变慢 对于全文检索、可变数据结构等场景,数据库天生不适用。因此需要针对特定的场景,引入合适的解决方案。 如对于海量文件存储,可通过分布式文件系统HDFS解决,对于key value类型的数据,可通过HBase和Redis等方案解决,对于全文检索场景,可通过搜索引擎如ElasticSearch解决,对于多维分析场景,可通过Kylin或Druid等方案解决。
子主题
新的技术挑战
引入更多组件解决了丰富的需求,业务维度能够极大扩充,随之而来的是一个应用中包含了太多的业务代码,业务的升级迭代变得困难
第10次演进:大应用拆分为小应用
按照业务板块来划分应用代码,使单个应用的职责更清晰,相互之间可以做到独立升级迭代。 这时候应用之间可能会涉及到一些公共配置,可以通过分布式配置中心Zookeeper来解决。
新的技术挑战
不同应用之间存在共用的模块,由应用单独管理会导致相同代码存在多份,导致公共功能升级时全部应用代码都要跟着升级
第11次演进:复用的功能抽离成微服务
如用户管理、订单、支付、鉴权等功能在多个应用中都存在,那么可以把这些功能的代码单独抽取出来形成一个单独的服务来管理,这样的服务就是所谓的微服务 应用和服务之间通过HTTP、TCP或RPC请求等多种方式来访问公共服务,每个单独的服务都可以由单独的团队来管理。 此外,可以通过Dubbo、SpringCloud等框架实现服务治理、限流、熔断、降级等功能,提高服务的稳定性和可用性。
架构设计
图
微服务架构图
子主题
广告数据分析平台架构
子主题
大中台
子主题
高可用Web架构设计
系统架构流程图
子主题
java系统架构图
子主题
云架构图
子主题
电商平台
子主题
架构原理与技术认知
架构师思维就是站在全局的角度来思考问题,这个全局包括时间和空间的全局。时间角度要做到全技术周期(全商业周期/全产品周期)中对技术的驾驭。空间角度要做到技术与组织(公司/部门)的战略对起。举个例子:作为架构师,你要能做到站在业务发展的阶段,需要通过技术、成本、资源等考量来设计系统,解决现阶段的主要矛盾,也要对未来的演进发展有预估。
三个技术认知
对架构设计的认知
关于架构设计的问题,一定要立足于点、连接成线、扩散成面 架构设计的重要性在于它能够解决软件系统的复杂度问题,提高系统的可维护性、可扩展性、可靠性和安全性,以满足不断变化的业务需求。
例子
交易系统重构
为什么要重构
从4个层面回答
从订单系统层面来看 由于交易流程中的订单系统相对来说业务稳定,不存在很多的迭代需求,如果耦合到整个交易系统中,在其他功能发布上线的时候会影响订单系统,比如订单中心的稳定性。基于这样的考虑,需要拆分出一个独立的子系统。
从促销系统层面来看 由于促销系统是交易流程中的非核心系统,出于保障交易流程稳定性的考虑,将促销系统单独拆分出来,在发生异常的时候能让促销系统具有可降级的能力。
从报价系统层面来看 报价是业务交易流程中最为复杂和灵活的系统,出于专业化和快速迭代的考虑,拆分出一个独立的报价系统,目的就是为了快速响应需求的变化。
从复杂度评估层面来看 系统拆分虽然会导致系统交互更加复杂,但在规范了 API 的格式定义和调用方式后,系统的复杂度可以维持在可控的范围内。
订单系统
实际问题
业务不断发展,团队人员变大,成了多个业务团队,订单增加,每个业务团队都会提出自己的功能需求。
矛盾
人员多,业务复杂,维护不便、增加测试成本
多人协作进行复杂业务,导致速度缓慢,但业务需求又快带迭代。
研发效率不能匹配业务发展的速度,并且单靠加人不能解决问题
解决
系统边界划分
让研发人员有能力提速,来快速响应需求变化,这就要求架构师对业务领域和团队人员有足够的了解。
拆分成几个服务
购物车 订单 履约 返利 作件
案例分析-总结4点
为什么做架构拆分?
通常最直接目的就是做系统之间解耦、子系统之间解耦,或模块之间的解耦。
为什么要做系统解耦?
系统解耦后,使得原本错综复杂的调用逻辑能有序地分布到各个独立的系统中,从而使得拆封后的各个系统职责更单一,功能更为内聚。
为什么要做职责单一?
因为职责单一的系统功能逻辑的迭代速度会更快,会提高研发团队响应业务需求的速度,也就是提高了团队的开发效率。
为什么要关注开发效率?
研发迭代效率的提升是任何一家公司在业务发展期间都最为关注的问题,所以从某种程度上看,架构拆分是系统提效最直接的手段。
对分析问题的认知
技术人员在做系统设计时需要与公司或部门的战略定位对齐,才能让你的技术有价值。因为对于系统技术架构升级的问题,业务方、管理者和技术人员的关注点是不同的。
关注点
业务方关注点在系统能力
业务方的诉求是在技术升级后,系统有能力迭代功能来满足市场的要求,所以关注点在系统能力。
管理方关注点在人效管理
管理者的诉求是在技术升级后,系统研发团队的开发效能得到提升,所以关注点在人效管理。
技术方关注点在系统设计原则
作为技术人员的你,需要找到自己做系统设计的立足点,来满足不同人对技术的诉求,而这个立足点通常就是系统设计原则
对能力边界的认知
中高级研发
功能性需求
采用合理的技术实现系统功能性需求
非功能性需求
无要求
架构师
功能性需求
采用合理的技术设计系统功能性架构
非功能性需求
在系统高可用、高性能、高扩展,容灾性等技术导向方面做出有价值的贡献
总结
首先要提高你对系统架构设计的认知能力,一个好的架构师的架构设计不是仅仅停留在技术解决方案上。 其次要提高你分析系统问题的认知能力,做架构设计要具备根据现阶段的主要矛盾来分析问题的能力。 最后你要扩大自己能够驾驭系统的边界,因为只有这样才能遇到之前没经历过的问题层次,注意我这里说的是问题层次,而不是问题数量。
架构设计方案
案例
在电商中,当用户发表一条商品评论,后台的逻辑是点评系统会调用一系列的远程 API 接口,如调用风控系统、广告系统、消息系统……几个甚至十几个系统的接口。
问题
随着业务快速发展,通过 RPC 同步调用的问题逐渐暴露出来。由于过多地依赖其他系统,导致评论发布的接口性能很低,可用性也容易受到其他系统影响。而且每当点评系统需求上线时,其他系统都需要跟着进行联调测试,导致需求迭代速度缓慢。
分析
从几个方面来回答
复杂来源
功能性复杂度
产品业务发展快速,系统越来越多,协作效率越来越低。根源在架构上各业务子系统强耦合。引用消息队列解耦各系统。这是系统业务领域带来的本质上的复杂度,也是功能性的复杂度,解决的是系统效率的问题。 要解决业务发展带来的系统耦合、开发效率缓慢问题。
非功能性复杂度
不在高性能
架构设计的目标应该满足未来业务增长,我们把未来业务增长的预估峰值设定为目前峰值的 4 倍,这样最终的性能要求分别是:TPS 为 176,QPS 是 1840。这样的读写指标还达不到系统压测的性能基线,所以可以确定的是点评系统的复杂度并不在高性能问题上。
保证高可用
点评系统的消息队列挂掉,将导致用户评论发送失败,当然在用户体验层面,解决方式可以在页面端提示用户重新操作,但如果问题影响到了点评消息的读取,导致评论没有走风控策略,就会造成严重的影响。所以高可用性是点评系统的设计复杂度之一,包括点评写入、点评存储,以及点评消息的读取,都需要保证高可用性。
解决方案
点评系统消息管道的架构设计解决方案
1. 采用开源的 MQ 消息管道。目前 MQ 消息管道有很多开源解决方案,比如 Kafka、RocketMQ、RabbitMQ 等。在实际项目中,你可以根据不同的应用场景选择合适的成熟开源消息队列方案,这是很多公司常用的做法。 2. 采用开源的 Redis 实现消息队列。方案 1 虽然应用了开源 MQ 实现点评消息的通信,但是因为引入一个消息中间件就会带来运维成本,所以方案 2 可以基于轻量级的 Redis 实现,以降低系统的维护成本和实现复杂度。 3. 采用内存队列 + MySQL 来实现。方案 2 中虽然应用了较为轻量级的 Redis 来实现,但是还需要引入一个缓存系统,同样也会带来运维成本,所以方案 3 是直接基于 MySQL 实现,即基于内存队列的方式,异步持久化到数据库,然后通过定时任务读取 MySQL 中的消息并处理。 一般情况,你至少要设计两到三套备选方案,考虑通过不同的技术方式来解决问题。方案设计不用过于详细,而是要确定技术的可行性和优缺点。
评估标准
评估架构解决方案的重要手段。做系统架构,需要站在更高的层面考虑系统的全局性关注点,比如性能、可用性、IT 成本、投入资源、实现复杂度、安全性、后续扩展性等。这在不同场景的不同阶段会起到决定性作用。
点评系统功能性复杂度
点评系统的功能性复杂度问题,本质上是随着业务发展带来的系统开发效率问题。解决这个问题要试着站得更高一些,以部门负责人的视角,考虑现有研发团队的能力素质、IT 成本、资源投入周期等因素是否匹配上面三种架构解决方案。
点评系统非功能性复杂度
解决高可用,参考三个设计原则
第一个是系统无单点原则。首先要保证系统各节点在部署的时候至少是冗余的,没有单点。很显然三种设计方案都支持无单点部署方式,都可以做到高可用。
第二个是可水平扩展原则。对于水平扩展,MQ 和 Redis 都具有先天的优势,但内存队列 + MySQL 的方式则需要做分库分表的开发改造,并且还要根据业务提前考虑未来的容量预估。
第三个是可降级原则。降级处理是当系统出现故障的时候,为了系统的可用性,选择有损的或者兜底的方式提供服务。
限流,即抛弃超出预估流量外的用户。 降级,即抛弃部分不重要的功能,让系统提供有损服务,如商品详情页不展示宝贝收藏的数量,以确保核心功能不受影响。 熔断,即抛弃对故障系统的调用。一般情况下熔断会伴随着降级处理,比如展示兜底数据。
技术实现
在确定了具体的架构解决方案之后,需要进一步说明技术上的落地实现方式和深层原理,如果你最终选择基于 Redis 来实现消息队列,那么可以有几种实现方式?各自的优缺点有哪些?对于这些问题,要做到心里有数。比如,基于 Redis List 的 LPUSH 和 RPOP 的实现方式、基于 Redis 的订阅或发布模式,或者基于 Redis 的有序集合(Sorted Set)的实现方式
分布式技术原理与设计
中间件常用组件的原理和设计
分布式缓存原理与设计
高性能可用设计
架构设计本质
问题
什么是系统设计,系统设计的核心是什么? 如何训练系统设计的思维模式? 有什么方法来帮助我们理解复杂的系统? 如何进行系统分析? 架构设计的本质是什么? 如何进行架构设计? 如何进行业务领域建模? 模型如何推导出架构设计? 架构设计需要遵循哪些规范?
关键词
系统思维,系统分析,系统设计,架构元素,架构视图,架构模型,业务模型,概念模型,系统模型,分析模型,设计模型,用例驱动,领域驱动,物件,功能,物件结构,功能交互,利益,架构工具,决策选择,架构师,架构图
全文概要
软件从业人员的成长路线大体是在管理线和技术线上形成突破,当然也有结合起来相得益彰的。而技术上的追求,架构师则是一个重要的门槛,对于刚入行的程序员可能会认为架构师就是画架构图的,诚然架构师很重要的一个职责是绘制架构图,但这只是其中一个很小的环节而已。 实际上架构也只是系统设计里面的一个重要环节,除了架构还包含了商业诉求,业务建模,系统分析,系统设计等重要领域。本文尝试从更高视角重新审视架构设计的工作,把架构设计的上升到系统设计的立体空间去探索,最终勾勒出系统设计的全域知识体系。
思维分析
1. 系统总览
人类社会活动中的不管大大小小的,简单抑或复杂的事物,总要先出现在我们的脑海里,然后再投射到现实的物理空间来。我们总是在孜孜不倦地追求美好的事物,但现实存在的问题就是,首先我们的脑袋也理解不了太过复杂的东西,其次脑海里的想象有时候也很难真实无损的映射成现实的系统,再者由于总是资源有限的,我们并没有花不完的预算。
归结起来设计一个系统,或者朴素的说,做一件事情,我们需要解决的问题
2. 系统演化
系统设计是根据系统分析的结果,运用系统科学的思想和方法,设计出能最大限度满足所要求的目标系统的过程。
业务描述
假设现在我们想登上火星,言下之意是需要借助一套设备要把人类送到火星上,大胆一点,发挥仅存那点为数不多的物理知识储备,要设计出一套系统,能够把人类送到火星。这个时候老板就是愿意出资去火星豪华 7 日游的金主,那么需要一个负责人来实现这趟旅程,我们姑且把这个负责人就称为登火旅行系统架构师(叫总设计师也行,不需要在意这种细节)。那么这个系统架构师的工作,就是把登陆火星的一系列需求和目标转化成为足于支撑登陆火星庞大工程的系统架构。
回答系统提到的问题
性质,保证下单流程高可用,高并发 受众,主体是企业,参加系统建设的企业 利益,提升用户体验,提升企业技术水平 目标,用户下单流程高效,稳定,体验好 需求,负载均衡,高并发,安全,购物车,确认订单,下单、作件,返利等 抽象,输出业务架构/概念架构/系统架构 设计,技术选型组合,如springboot,dubbo,mysql,rabbitmq,sharding-jdbc,线程池异步等 方向,不断验证测试
初次业务流程画图
火箭,机舱,地球,火星,来回,基础功能(安全,舒适,成本)
火星旅程的四个阶段:登机,航行,下机游玩,返程
概念抽象
概念建模
需要对核心业务的充分理解,同时在基础性和通用性方面的功能也需要同时考虑,这个阶段需要大量的业务专家和各个领域的科学家通力协作,保证对系统的理解没有偏差。经过一系列的概念抽象和组合,最终输出登陆火星工程的架构图
子主题
系统落地
对各个模块进行分析,细化,设计和实施
3. 架构思维
架构目标
软件架构之道最核心的问题是解决复杂性的问题,并且在解决问题的过程中找到最佳的平衡点,既要简单又能满足发展。描述系统设计的本质,就是实现纵向上的时间,横向上的空间进行考虑,规划出决策路径,最终拿到目标结果。 架构师眼里第一件事不是多流行的技术,多高性能的框架,或者多完善的业务模型,而应该聚焦在利益之上。 架构师就是负责把老板画的饼给实现了,在相当长的一段时间内保证产物有足够的利益回报
架构过程
所谓条条大路通罗马,有的是一路平川而有的则是崎岖不平,那么架构过程就是不断归类合并同类项,力求最合适的决策选择来实现我们所要达成的愿望。在面对复杂业务的场景下,我们需要做出如下的思考: 确定系统实体对象和预期功能 抽象系统实体之间的关系,功能与实体的关系 划定系统的边界和外部环境的关系 预测系统带来的效果
在架构过程中,很重要的一项任务就是识别系统的实体关系和功能关系,进而对系统效果进行预测,也就是在完成一系列的分析建模工作后推导出来的系统架构需要在预测上达到我们要的效果,那么系统预测通常有如下四种方式: 经验 实验 建模 推理
系统思维
系统思维首先是高效地理解分析现存的系统,对系统重构做好理论指导基础。
系统思维目标: 理解系统是什么 预测系统的走向 为决策提供知识支持
系统分析
系统分析,旨在研究特定系统结构中各部分的相互作用,系统的对外接口与界面,以及该系统整体的行为、功能和局限,从而为系统未来的变迁与有关决策提供参考和依据。系统分析的经常目标之一,在于改善决策过程及系统性能,以期达到系统的整体最优。
系统分解一些方法
体系归纳 层级分解 逻辑关系 自顶向下 自底向上 由外向内 由内而外
1.实体分析
分析
系统是什么? 构成系统的元素有哪些? 系统元素之间的结构是什么? 系统的边界在哪里? 系统的使用场景是什么?
实体是系统的一项基础属性,是系统的物理体现或信息体现。对功能的执行起工具性作用,而描述实体通常可以使用以下工具来表达: 文字描述 符号描述 插图 插画 示意图 三维图 透视图
实体之间的关系就是结构,分析结构时需要对实体进行分解,实体可以建模为对象及其之间的结构,进一步可以分解为小的实体,又可以聚合起来称为系统本身,对实体之间的各种结构分析则可以得出系统架构,即是把功能元素组合成物理块时所用的编排方式。
分析实体
对实体的载体进行抽象聚类,形成对象,体现出边界 用适当的层次来分解架构的实体
分析关系
即是实体的结构,是对象之间存在稳定关系,有助于功能交互的执行系统实体有如下关系: 空间拓扑关系 连接性关系 地址关系 顺序关系 成员关系 所有权关系 人际关系
2.功能分析
了解了系统的物理基础,对组成系统的实体进行分解,分析,进而对实体的关系描述为结构,结构抽象是得出架构的基础步骤,而系统物理基础存在的理由是为了实现我们的诉求,也即是系统的功能。
系统设计
TOGAF:框架开放组体系结构框架(The Open Group Architecture Framework,缩写:TOGAF)是一个企业架构框架,它提供了一种设计,规划,实施和管理企业信息技术架构的方法。TOGAF 是一种高层设计方法。它通常被建模为四个级别:业务,应用程序,数据,和技术。
在 TOGAF 中,任何一种企业能力的建设都需要对如下四种领域进行设计,包括针对这一可持续性架构实践建设: 业务架构:突出了架构治理、架构流程、架构组织结构、架构信息需求以及架构产品等方面; 数据架构:定义了组织中架构连续体和架构资源库的结构; 应用架构:描述了用于支持此可持续架构实践的功能和服务; 技术架构:描述了架构实践中用于支持各架构应用和企业连续体的基础设施需求和部署方式。
如何做好架构设计
作为架构师要有的特点
技术好
至少代码容易读,容易扩展,重用性好,这不仅需要学习面向对象和设计模式,还要通过大量的编码实践,不单单是停在纸上谈兵的阶段
懂得业务
不了解业务,就不能设计出贴合业务的架构,而行业的相关知识也不是短时间能积累起来的。
良好的沟通能力
架构师需要沟通确认需求,需要让团队理解架构设计。
有架构思维
懂得用抽象、分治、复用、 迭代等思维降低软件复杂性
架构思维
描述:降低软件复杂性,有几种有效的方式:抽象、分治、复用和迭代,架构思维就是这几个的集合
1.抽象思维
架构是为了满足业务需求而存在,需要通常是一些文字性的描述、原型、UI设计图
先进行抽象,把需求变成计算机能识别的模型
例:抽象出各个用户、订单、内容等模型,划清各个角色的责任以及对象交互的方式,隐藏很多无关紧要的细节。
2.分治思维
对复杂的系统分而治之,分解为小的、简单的部分。
例:针对高并发场景,可以通过设计将流量分到不同的服务器,避免单台服务器过载。
3.复用思维
复用是提升开发效率的最简单有效的方法,通过对相同内容的抽象,让其能复用于不同的场景。
4.迭代思维
好的架构都是演进过来,很少有架构是一步到位,我们需要保证不影响业务正常进度的基础上,逐步迭代成最终合理的架构
什么是架构设计
描述:用最小的人力成本满足需求开发和需求变更,用最小的运行成本来保障软件的运行。
1.使用微服务架构,把复杂系统拆分成一系列小的服务,再拆成功能模块,让人员更好地分工协作
2.前后端分离,让程序员专注某个知识领域,降低开发难度
3.分层设计,隔离业务逻辑,减少需求变更带来的影响
为什么需要架构设计
存在原因
需求让技术变复杂。
小网站与大网站不是一个等级
人员让技术复杂
成员水平不等、擅长技术也不一样,有效协作是考验
技术本身复杂
使用的语言、框架、组件、数据库、大数据等技术,都有学习成本
软件稳定运行也复杂
软件上线后,充满了不确定性。
降低复杂度
降低开发成本
复杂系统拆分成多个相对简单的服务,使得普通程序员都可以完成,降低了人力成本。
帮助组织人员高效协作
通过抽象和拆分,让开发人员可以独立完成功能模块。
组织好各种技术
选择合适的编程语言、协议、框架、组件等,最高效地实现需求目标。
保障服务稳定运行
利用成熟的架构方案,例如负载均衡、限流、降级、熔断等,保障服务的高可用。
如何做好架构设计
分析需求
对产品的需求进行抽象,分析用例,了解各种用户角色和其使用的场景
选择相似的成熟架构设计方案
例如微服务架构、前后端分离,还要根据团队选择合适的开发语言和框架。
自顶向下层层细化
好的实践是自顶向下的,不过早陷入技术细节中,从整体到局部规划,设计好部署架构、分层和分模块、API设计、数据库设计等。
验证和优化架构设计方案
完整的架构设计方案,需要有多次的评审,充分收集各方面的反馈,反复修改后确定,另外,还要考虑架构预期能满足多长时间的业务增长,比如半年还是一年还是三年。
总体架构设计
架构本质
架构的本质就是对系统进行有序化地重构以致符合当前业务的发展,并可以快速扩展。
https://www.toutiao.com/a6675916632612667916/
如何实现无序到有序
基本的手段就是分和合,先把系统打散,然后重新组合。
分:合理定位
分的过程是把系统拆分为各个子系统/模块/组件,拆的时候,首先要解决每个组件的定位问题,然后才能划分彼此的边界,实现合理的拆分。 拆分的结果使开发人员能够做到业务聚焦、技能聚焦,实现开发敏捷
合:有机整合
合就是根据最终要求,把各个分离的组件有机整合在一起,相对来说,第一步的拆分更难。 合的结果是系统变得柔性,可以因需而变,实现业务敏捷。
单体架构
适用场景
适合小项目,用户数,业务处理还比较简单,一两台服务器能支撑起正常的业务处理。
优点
痛点和破局
开发效率低、代码维护难、部署不灵活、稳定性不高、扩展性不够
破局见分布式架构拆分
面向服务架构SOA
它是粗粒度、松耦合服务架构,服务之间通过简单、精确定义接口进行通讯,不涉及底层编程接口和通讯模型。
服务架构本质
不仅仅在采用什么样的技术框架实现和塑造,更重要的是在于通过不停地在共创中反问、反思、反省等方式进行对业务的本质的不断追溯、抽象、综合归纳演绎,我们的每一个架构师都是服务化架构的接生婆,我们的使命是建立真正反映业务本质并驱动业务不断向前的架构。
服务架构的构成
子主题
特点、缺点
水平分层架构
微服务架构
介绍
通过将功能分解到各个离散的服务中以实现对解决方案的解耦。它的主要作用是将功能分解到离散的各个服务当中,从而降低系统的耦合性,并提供更加灵活的服务支持。
发展
传统开发模式
优点: ①开发简单,集中式管理 ②基本不会重复开发 ③功能都在本地,没有分布式的管理和调用消耗 缺点: 1、效率低:开发都在同一个项目改代码,相互等待,冲突不断 2、维护难:代码功功能耦合在一起,新人不知道何从下手 3、不灵活:构建时间长,任何小修改都要重构整个项目,耗时 4、稳定性差:一个微小的问题,都可能导致整个应用挂掉 5、扩展性不够:无法满足高并发下的业务需求
微服务
目的: 有效的拆分应用,实现敏捷开发和部署
X轴: 运行多个负载均衡器之后的运行实例 Y轴: 将应用进一步分解为微服务(分库) Z轴: 大数据量时,将服务分区(分表)
微服务的具体特征
官方的定义:
1、一些列的独立的服务共同组成系统 2、单独部署,跑在自己的进程中 3、每个服务为独立的业务开发 4、分布式管理 5、非常强调隔离性
大概的标准:
1、分布式服务组成的系统 2、按照业务,而不是技术来划分组织 3、做有生命的产品而不是项目 4、强服务个体和弱通信( Smart endpoints and dumb pipes ) 5、自动化运维( DevOps ) 6、高度容错性 7、快速演化和迭代
SOA和微服务的区别
SOA
主要目的是为了企业各个系统更加容易地融合在一起
喜欢水平服务
给服务分层(如Service Layers模式)。我们常常见到一个Entity服务层的设计,美其名曰Data Access Layer。这种设计要求所有的服务都通过这个Entity服务层来获取数据。这种设计非常不灵活,比如每次数据层的改动都可能影响到所有业务层的服务
喜欢自上而下
在设计开始时会先定义好服务合同(service contract)。它喜欢集中管理所有的服务,包括集中管理业务逻辑,数据,流程,schema,等等。它使用Enterprise Inventory和Service Composition等方法来集中管理服务。SOA架构通常会预先把每个模块服务接口都定义好。模块系统间的通讯必须遵守这些接口,各服务是针对他们的调用者。
微服务
向微服务迁移的时候通常从耦合度最低的模块或对扩展性要求最高的模块开始,把它们一个一个剥离出来用敏捷地重写,可以尝试最新的技术和语言和框架,然后单独布署
喜欢垂直服务
每个微服务通常有它自己独立的data store。我们在拆分数据库时可以适当的做些去范式化(denormalization),让它不需要依赖其他服务的数据。
喜欢自下而上
微服务 则敏捷得多。只要用户用得到,就先把这个服务挖出来。然后针对性的,快速确认业务需求,快速开发迭代。
如何具体实现微服务
需要解决4点问题
1、客户端如何访问这些服务? 2、每个服务之间如何通信? 3、如此多的服务,如何实现? 4、服务挂了,如何解决(备份方案,应急处理机制)?
1. 客户端如何访问这些服务(网关)
① 提供统一服务入口,让微服务对前台透明; ② 聚合后台的服务,节省流量,提升性能; ③ 提供安全,过滤,流控等API管理功能。
2. 每个服务之间如何通信(HTTP/Dubbo/Kafka)
同步调用: ①REST(JAX-RS,Spring Boot) ②RPC(Thrift, Dubbo) 异步消息调用(Kafka, Notify, MetaQ)
同步: 一般REST基于HTTP,更容易实现,更容易被接受,服务端实现技术也更灵活些,各个语言都能支持,同时能跨客户端,对客户端没有特殊的要求,只要封装了HTTP的SDK就能调用 异步: 异步消息的方式在分布式系统中有特别广泛的应用,他既能减低调用服务之间的耦合,又能成为调用之间的缓冲,确保消息积压不会冲垮被调用方,同时能保证调用方的服务体验,继续干自己该干的活,不至于被后台性能拖慢。不过需要付出的代价是一致性的减弱,需要接受数据最终一致性;还有就是后台服务一般要 实现幂等性,因为消息发送出于性能的考虑一般会有重复(保证消息的被收到且仅收到一次对性能是很大的考验);最后就是必须引入一个独立的broker,如果公司内部没有技术积累,对broker分布式管理也是一个很大的挑战。
3. 如此多的服务,如何实现?(注册中心)
注册中心
注册与发现
一般有两类做法,也各有优缺点。基本都是通过zookeeper等类似技术做服务注册信息的分布式管理。当服务上线时,服务提供者将自己的服务信息注册到ZK(或类似框架),并通过心跳维持长链接,实时更新链接信息。服务调用者通过ZK寻址,根据可定制算法, 找到一个服务,还可以将服务信息缓存在本地以提高性能。当服务下线时,ZK会发通知给服务客户端。 客户端做: 优点是架构简单,扩展灵活,只对服务注册器依赖。缺点是客户端要维护所有调用服务的地址,有技术难度,一般大公司都有成熟的内部框架支持,比如Dubbo。 服务端做: 优点是简单,所有服务对于前台调用方透明,一般在小公司在云服务上部署的应用采用的比较多。
4. 服务挂了,如何解决(限流、降级、熔断、负载、重试)
①重试机制 ②限流 ③熔断机制 ④负载均衡 ⑤降级(本地缓存)
常见的微服务设计模式和应用
需要考虑的问题
1、API Gateway 2、服务间调用 3、服务发现 4、服务容错 5、服务部署 6、数据调用
1、聚合器微服务设计模式
聚合器调用多个服务实现应用程序所需的功能。它可以是一个简单的Web页面,将检索到的数据进行处理展示。它也可以是一个更高层次的组合微服务,对检索到的数据增加业务逻辑后进一步发布成一个新的微服务,这符合DRY原则。另外,每个服务都有自己的缓存和数据库。如果聚合器是一个组合服务,那么它也有自己的缓存和数据库。聚合器可以沿X轴和Z轴独立扩展。
2、代理微服务设计模式
这是聚合模式的一个变种,在这种情况下,客户端并不聚合数据,但会根据业务需求的差别调用不同的微服务。代理可以仅仅委派请求,也可以进行数据转换工作。
3、链式微服务设计模式
这种模式在接收到请求后会产生一个经过合并的响应。 在这种情况下,服务A接收到请求后会与服务B进行通信,类似地,服务B会同服务C进行通信。所有服务都使用同步消息传递。在整个链式调用完成之前,客户端会一直阻塞。
4、分支微服务设计模式
这种模式是聚合器模式的扩展,允许同时调用两个微服务链
5、数据共享微服务设计模式
自治是微服务的设计原则之一,就是说微服务是全栈式服务。但在重构现有的“单体应用(monolithic application)”时,SQL数据库反规范化可能会导致数据重复和不一致。 因此,在单体应用到微服务架构的过渡阶段,可以使用这种设计模式 在这种情况下,部分微服务可能会共享缓存和数据库存储。不过,这只有在两个服务之间存在强耦合关系时才可以。对于基于微服务的新建应用程序而言,这是一种反模式
6、异步消息传递微服务设计模式
虽然REST设计模式非常流行,但它是同步的,会造成阻塞。因此部分基于微服务的架构可能会选择使用消息队列代替REST请求/响应
微服务的优点和缺点
优点
复杂度可控,独立按需扩展,技术选型灵活,容错,可用性高。
缺点
系统部署依赖,服务间通信成本,数据一致性,系统集成测试,重复工作,性能监控等。
意识的转变
关于微服务的几点设计出发点: 1、应用程序的核心是业务逻辑,按照业务或客户需求组织资源(这是最难的); 2、做有生命的产品,而不是项目; 3、头狼战队,全栈化; 4、后台服务贯彻Single Responsibility Principle(单一职责原则); 5、VM->Docker (to PE); 6、DevOps (to PE)。 同时,对于开发同学,有这么多的中间件和强大的PE支持固然是好事,我们也需要深入去了解这些中间件背后的原理,知其然知其所以然,在有限的技术资源如何通过开源技术实施微服务? 最后,一般提到微服务都离不开DevOps和Docker,理解微服务架构是核心,devops和docker是工具,是手段。
微服务架构实施原理
外部请求 → 负载均衡 → 服务网关(GateWay)→ 微服务 → 数据服务/消息服务。服务网关和微服务都会用到服务注册和发现来调用依赖的其他服务,各服务集群都能通过配置中心服务来获得配置信息。 采用ELB(Elastic Load Balancing)做负载均衡。ELB弹性负载均衡,在多个实例间自动分配应用的传入流量 为了保证安全性,客户端请求需要使用https加密保护,这就需要我们进行SSL卸载,使用Nginx对加密请求进行卸载处理。外部请求经过ELB负载均衡后路由到GateWay集群中的某个GateWay服务,由GateWay服务转发到微服务。
服务网关(gateway)
基本能力
动态路由
动态的将请求路由到所需要的后端服务集群。虽然内部是复杂的分布式微服务网状结构,但是外部系统从网关看就像是一个整体服务,网关屏蔽了后端服务的复杂性。
限流和容错
为每种类型的请求分配容量,当请求数量超过阀值时抛掉外部请求,限制流量,保护后台服务不被大流量冲垮;当内部服务出现故障时直接在边界创建一些响应,集中做容错处理,而不是将请求转发到内部集群,保证用户良好的体验。
身份认证和安全性控制
对每个外部请求进行用户认证,拒绝没有通过认证的请求,还能通过访问模式分析,实现反爬虫功能。
监控
网关可以收集有意义的数据和统计,为后台服务优化提供数据支持。
访问日志
网关可以收集访问日志信息,比如访问的是哪个服务?处理过程(出现什么异常)和结果?花费多少时间?通过分析日志内容,对后台系统做进一步优化。
服务注册与发现(eureka)
所有的微服务(通过配置Eureka服务信息)到Eureka服务器中进行注册,并定时发送心跳进行健康检查,Eureka默认配置是30秒发送一次心跳,表明服务仍然处于存活状态,发送心跳的时间间隔可以通过Eureka的配置参数自行配置,Eureka服务器在接收到服务实例的最后一次心跳后,需要等待90秒(默认配置90秒,可以通过配置参数进行修改)后,才认定服务已经死亡(即连续3次没有接收到心跳),在Eureka自我保护模式关闭的情况下会清除该服务的注册信息。 所谓的自我保护模式是指,出现网络分区、Eureka在短时间内丢失过多的服务时,会进入自我保护模式,即一个服务长时间没有发送心跳,Eureka也不会将其删除。自我保护模式默认为开启,可以通过配置参数将其设置为关闭状态。
Eureka服务以集群的方式部署,集群内的所有Eureka节点会定时自动同步微服务的注册信息,这样就能保证所有的Eureka服务注册信息保持一致。那么在Eureka集群里,Eureka节点是如何发现其他节点的呢?我们通过DNS服务器来建立所有Eureka节点的关联,在部署Eureka集群之外还需要搭建DNS服务器。
微服务部署(docker)
微服务是一系列职责单一、细粒度的服务,是将我们的业务进行拆分为独立的服务单元,伸缩性好,耦合度低,不同的微服务可以用不同的语言开发,每一个服务处理的单一的业务。 由于每个微服务都是以集群的形式部署,服务之间相互调用的时候需要做负载均衡,因此每个服务中都有一个LB组件用来实现负载均衡。 使用Docker容器技术,我们只需要将所需的基础镜像(JDK等)和微服务生成一个新的镜像,将这个最终的镜像部署在Docker容器中运行,这种方式简单、高效,能够快速部署服务。
服务容错
服务要有对其故障进行隔离和容错,可以用Hystrix组件来进行容错处理,它通过熔断模式、隔离模式、回退(fallback)和限流等机制对服务进行弹性容错保护,保证系统的稳定性
熔断模式
当服务异常或者大量延时,满足熔断条件时服务调用方会主动启动熔断,执行fallback逻辑直接返回,不会继续调用服务进一步拖垮系统。
熔断器默认配置服务调用错误率阀值为50%,超过阀值将自动启动熔断模式。服务隔离一段时间以后,熔断器会进入半熔断状态,即允许少量请求进行尝试,如果仍然调用失败,则回到熔断状态,如果调用成功,则关闭熔断模式。
隔离模式
Hystrix默认采用线程隔离,不同的服务使用不同的线程池,彼此之间不受影响,当一个服务出现故障耗尽它的线程池资源,其他的服务正常运行不受影响,达到隔离的效果
回退(fallback)
fallback机制其实是一种服务故障时的容错方式,原理类似Java中的异常处理
几种情况会触发
程序抛出非HystrixBadRequestExcepption异常,当抛出HystrixBadRequestExcepption异常时,调用程序可以捕获异常,没有触发fallback,当抛出其他异常时,会触发fallback;
程序运行超时
熔断启动
线程池已满
限流
对服务的并发访问量进行限制,设置单位时间内的并发数,超出限制的请求拒绝并fallback,防止后台服务被冲垮。
动态配置中心
nacos、百度配置中心等
网络架构
核心技术
高可用设计
互联网系统可用性度量,即如何用指标来衡量系统的可用性,以及进行可用性管理时的一些手段。 高可用架构策略,主要包括负载均衡、备份与失效转移、消息队列隔离、限流与降级、异地多活这样几种架构方法。 高可用运维,如何在开发测试发布以及系统运行过程中,保障系统的高可用,包括自动化部署、自动化监控、自动化测试、预发布测试这几个方面。
可用性度量
可用性指标
系统高可用的挑战
一个互联网应用想要完整地呈现在最终用户的面前,需要经过很多个环节,任何一个环节出了问题,都有可能会导致系统不可用。
挑战
故障
1. 比如说 DNS 被劫持,域名解析就失败了,系统虽然完好无损,但用户依然不能访问系统。再比如,CDN 服务不可用,前面提过,CDN 服务是用户访问的第一跳,对于大型互联网系统而言,主要的静态资源都是通过 CDN 返回的。如果 CDN 服务不可用,那么大量的用户请求就会到达互联网数据中心,会给互联网数据中心带来巨大的请求负载压力,可能直接导致系统崩溃。
2.还有就是应用服务器及数据库宕机、网络交换机宕机、磁盘损坏、网卡松掉,这样的硬件故障;
3.机房停电了、空调失灵了、光缆被挖掘机挖断了,这些环境故障。以及程序代码 bug 引起的故障,等等。
外部因素
比如说系统被黑客攻击了
业务上要做一次大的促销,或者要做一个秒杀的活动,因此带来的访问压力冲击;
以及第三方合作伙伴服务不可用等等,各种带来系统故障的原因。
互联网应用可用性的度量
系统的可用性指标
年度可用性指标= 1 −(不可用时间/年度总时间)×100%
通常用多少个 9 来说明互联网应用的可用性
比如说 QQ 的可用性是 4 个 9,就是说 QQ 的服务 99.99% 可用,这句话的意思是 QQ 的服务要保证在其所有的运行时间里只有 0.01% 不可用,也就是说一年大概有 53 分钟不可用。这个 99.99% 就叫做系统的可用性指标
一般说来,两个 9 表示系统基本可用,年度停机时间小于 88 小时;3 个 9 是较高可用,年度停机时间小于 9 个小时;4 个 9 是具有自动恢复能力的高可用,年度停机时间小于 53 分钟;5 个 9 指极高的可用性,年度停机时间小于 5 分钟。事实上对于一个复杂的大型互联网系统而言,对可用性的影响因素是非常多的,能够达到 4 个 9 甚至 5 个 9 的可用性,除了具备过硬的技术、大量的设备资金投入、有责任心的工程师,有时候还需要好运气。
故障分类
在互联网企业中为了管理好系统的可用性,界定好系统故障以后的责任,通常会用故障分进行管理。 根据系统可用性指标换算成故障分,是整个系统的故障分,比如 10万 分,再根据各自团队各个产品各个职能角色承担的责任的不同,会把故障分下发给每个团队,直到每个人,也就是说每个工程师在年初的时候就会收到一个预计的故障分。然后每一次系统出现可用性故障的时候,都会进行故障考核,划定到具体的团队和责任人以后,会扣除他的故障分。如果到了年底的时候,一个工程师,他的故障分被扣为负分,那么就可能会影响他的绩效考核。
故障分类的计算方式是用故障时间乘以故障权重来计算得到的。而故障的权重通常是在故障产生以后,根据影响程度,由运营方确定的一个故障权重值
故障处理流程和故障时间
故障时间
这个故障结束时间减去开始时间
1.客服报告故障/监控系统发现故障
首先是故障的开始,故障的开始时间是客服报告故障的时间点,或者是监控系统发现故障的时间点,如果客服收到了投诉,说系统不可用,这个时候就开始计算故障时间。或者监控系统发现,用户访问量或者是订单量因系统故障而出现了大幅的下跌,那么监控系统监控到的故障时间点就是故障的开始时间。
2.故障交给相关部门
确定了故障以后,就把故障提交给相关部门的接口人,接口人再把故障现象发送给相关的责任人,责任人接手故障后,进行故障排查和处理。处理完毕以后系统重新启动,或者是代码重新发布上线以后,重新确认系统指标正常或者是功能恢复正常,确认故障处理完毕,这个时间就是故障的结束时间。
3.复盘会并进行责任划分
故障结束以后,通常要开一个故障复盘会,检讨故障产生的原因,亡羊补牢,避免下次出现类似的故障,同时也要对引起故障的原因进行责任划分,扣除相关责任者的故障分计入绩效考核。
高可用架构
策略
负载均衡
应用服务器的负载均衡
负载均衡核心要解决的就是通过一个负载均衡服务器,将用户的请求分发给多个应用服务器,将多个应用服务器构建成一个集群,共同对外提供服务。这样的架构可以提高系统的处理能力,以解决高并发用户请求下的系统性能问题。
负载均衡实现方法
HTTP 重定向负载均衡
用户的 HTTP 请求到达负载均衡服务器以后,负载均衡服务器根据某种负载均衡算法计算一个新的服务器,通过 HTTP 重定向响应,将新的 IP 地址发送给用户浏览器,用户浏览器收到重定向响应以后,重新发送请求到真正的应用服务器,以此来实现负载均衡。
DNS 负载均衡
浏览器访问我们数据中心的时候,通常是用域名进行访问,HTTP 协议则必须知道 IP 地址才能建立通信连接,那么域名是如何转换成 IP 地址的呢?就是通过 DNS 服务器来完成。当用户从浏览器发起发起 HTTP 请求的时候,他输入域名,首先要到 DNS 域名服务器进行域名解析,解析得到 IP 地址以后,用户才能够根据 IP 地址建立 HTTP 连接,访问真正的数据中心的应用服务器,那么就可以在 DNS 域名解析的时候进行负载均衡,不同的浏览器进行解析的时候,返回不同的 IP 地址,从而实现负载均衡。
实践中大型互联网系统几乎都使用域名解析负载均衡,主要原因是在于,这些大型互联网系统,比如像淘宝、Facebook、百度这些系统,根据域名解析出来的 IP 地址,并不是真正的 Web 服务器 IP 地址,是负载均衡服务器的 IP 地址,也就是说这些大型互联网系统,它们都采用了两级负载均衡机制,DNS 域名解析进行一次负载均衡解析出来的 IP 地址是负载均衡服务器的 IP 地址,然后由负载均衡服务器,再做一次负载均衡,将用户的请求分发到应用服务器,这样的话,我们的应用服务器的 IP 地址就不会暴露出去。同时由于负载均衡服务器通常是比较高可用的,也不存在应用程序发布的问题,所以很少有可用性方面的问题。
反向代理负载均衡
用户请求到达数据中心以后,最先到达的就是反向代理服务器。反向代理服务器,除了可以提供请求的缓存功能以外,还可以进行负载均衡,将用户的请求分发到不同的服务器上面去。反向代理是工作在 HTTP 协议层上的一个服务器,所以它代理的也是 HTTP 的请求和响应。而 HTTP 协议相对说来,作为互联网第七层的一个协议,它的协议比较重,效率比较低,所以反向代理负载均衡通常用在小规模的互联网系统上,只有几台或者十几台服务器的规模
IP 层负载均衡
规模再大一点的集群,通常就不会再使用反向代理服务器进行负载均衡。在七层网络通讯之下的另外一种负载均衡方法是在 IP 层进行负载均衡,IP 层是网络通讯协议的第四层,所以有时候叫四层负载均衡。它的主要工作原理是当用户的请求到达负载均衡服务器以后,负载均衡服务器会拿到 TCP/IP 的数据包,对数据包的 IP 地址进行转换,修改 IP 地址,将其修改为 Web 服务器的 IP 地址,然后把数据包重新发送出去。
数据链路层负载均衡
为了解决这个问题,将负载均衡的数据传输,再往下放一层,放到了数据链路层,实现数据链路层的负载均衡。在这一层上,负载均衡服务器并不修改数据包的 IP 地址,而是修改网卡的 MAC 地址。而应用服务器和负载均衡服务器都使用相同的虚拟 IP 地址,这样 IP 路由就不会受到影响,但是网卡会根据自己的 MAC 地址选择负载均衡发送到自己的网卡的数据包,交给对应的应用服务器去处理,处理结束以后,当他把响应的数据包发送到网络上的时候,因为 IP 地址没有修改过,所以这个响应会直接到达用户的浏览器,而不会再经过负载均衡服务器
限流和降级
限流
限流是指对进入系统的用户请求进行限流处理,如果访问量超过了系统的最大处理能力,就会丢弃一部分的用户请求,保证整个系统可用,保证大部分用户是可以访问系统的。这样虽然有一部分用户的请求被丢弃,产生了部分不可用,但还是好过整个系统崩溃,所有的用户都不可用要好。
降级
保护系统的另一种手段就是降级。有一些系统功能是非核心的,但是实际它也给系统产生了非常大的压力,比如说在电商系统中有“确认收货”这个功能,对于大多数互联网电商应用,我们即使是不去确认收货,超时它会自动确认收货。
但实际上确认收货这个操作是一个非常重的操作,因为它要更改订单状态,完成支付确认,并进行评价等一系列操作。这些操作都是一些非常重的、对数据库压力很大的操作。如果在系统高并发的时候去完成这些操作,那么会对系统雪上加霜,使系统的处理能力更加恶化。解决办法就是在系统高并发的时候,比如说像淘宝“双11“这样的时候,当天可能整天系统都处于一种极限的高并发访问压力之下,这一天就可以将确认收货、评价这些非核心的功能关闭,将宝贵的系统资源留下来,给正在购物的人,让他们去完成交易。
消息队列隔离
系统高可用的另一种策略是使用消息队列实现异步解耦,即消息队列隔离。我们在分布式消息队列一讲中也提到过这种架构方式的高可用。
一方面,消息的生产者和消费者通过消息队列进行隔离,那么如果消费者出现故障的时候,生产者可以继续向消息队列发送消息,而不会感知到消费者的故障,等消费者恢复正常以后再去到消息队列中消费消息,所以从用户处理的视角看,系统一直是可用的。
由于分布式消息队列具有削峰填谷的作用,所以在高并发的时候,消息的生产者可以将消息缓存在分布式消息队列中,消费者可以慢慢地到消息队列中去处理,而不会将瞬时的高并发负载压力直接施加到整个系统上,导致系统崩溃。
数据库复制与失效转移
数据库的高可用要比应用服务器复杂很多,因为应用服务器是无状态的,请求可以分发到任何一台服务器去处理,而数据库上必须存储有正确的数据才能将请求分发给它。对于数据库的高可用,通常是使用数据库复制与失效转移来完成的。我们在分布式数据库存储这一讲中提到过 MySQL 的主主复制,以及 MySQL 的主从复制。
因为有数据复制,所以用户请求可以访问到不同的从服务器上,当某一台从服务器宕机的时候,系统的读操作不会受到影响,实现数据库读操作高可用。而如果实现了主主复制,那么当主服务器宕机的时候,写请求连接到另外一台主服务器上,实现数据库的写操作高可用,而数据库部署的时候,可以同时部署如下图所示这样的主主复制和主从复制,也就是实现数据库的读写都高可用。
异地多活机房架构
将数据中心分布在多个不同地点的机房里,这些机房都可以对外提供服务,用户可以连接任何一个机房进行访问。这样每个机房都可以提供完整的系统服务,即使某一个机房不可使用,系统也不会宕机,依然保持可用。
异地多活的架构考虑的一个重点是,用户请求如何分发到不同的机房去。这个主要可以在域名解析的时候完成,也就是用户进行域名解析的时候,会根据就近原则或者其它一些策略,完成用户请求的分发。 另一个至关重要的技术点是,因为是多个机房都可以独立对外提供服务,所以也就意味着每个机房都要有完整的数据记录,所以用户在任何一个机房完成的数据操作,都必须要同步传输给其它的机房,需要进行数据实时同步。 目前远程数据库同步的解决方法有很多,最需要关注的是数据冲突问题。同一条数据,同时在两个数据中心被修改了,该如何解决?为了解决这种数据冲突的问题,很多异地多活的多机房架构实际上采用的是类似 MySQL 的主主模式,也就是说多个机房在某个时刻是有一个主机房的,某些请求只能到达主机房才能被处理,其它的机房不处理这一类请求,以此来避免关键数据的冲突。
高可用运维
自动化测试
一种高可用的运维是自动化测试。对于一个成熟的互联网系统,任何一次代码变更,都可能需要执行大量的回归测试,才能够保证系统没有 bug,而这种更新又是非常频繁的,如果依赖手工操作,测试效率和测试资源都难以满足如此大量的回归测试需求。所以对于成熟的互联网产品,很多时候采用自动化测试,通过自动化脚本自动对 APP 或者是服务接口进行测试。
自动化监控
还有一种是自动化监控。系统在线上运行的时候,必须要实时的监控系统的各项指标,包括业务指标和技术指标。业务指标包括用户访问量、订单量、查询量这些主要的业务指标,技术指标包括 CPU、磁盘、内存的使用率等。通过这些指标可以实时监控业务是否正常,系统是否正常。如果指标不正常,通过监控报警的手段,通知相关的人员,还可以在自动化监控的基础上去,触发自动化的运维工具,进行自动化的系统修复。
预发布
高可用运维的另一种手段是预发布。虽然在系统上线之前,系统在代码更新以后,要经过测试才会上线,但是还有一些情况在测试环境是无法复现的。比如对第三方服务的调用,数据库结构的变更,以及一些线上的配置参数变更等等,只有线上才能够发现。
在线上的服务器集群里面有一台服务器,是专门的预发布服务器,这台服务器不配置在负载均衡服务器,也就是说外部的用户是无法访问到这台服务器的,但是这台服务器跟其它的应用服务器,使用的配置、连接的数据库、连接的第三方服务都是完全一样的,它是一个完全线上的一个服务器,而这个服务器只有内部的工程师才可以访问到。
灰度发布
对于大型互联网系统,虽然有前面的各种保障措施,但还是可能发生上线以后用户报告出现故障,对于用户报告的故障或者监控到的故障,就需要对系统进行回滚到原来的代码,系统退回到前一个版本。但是对于大型互联网系统而言,它的服务器特别多,可能有数万台服务器,这个时候即使是进行系统回滚,也可能要花很长的时间。这段时间系统一直处于某种不可用的故障状态。
为了避免上述情况,大型互联网系统,会使用一种灰度发布的手段,也就是说每天都只发布一部分服务器,如果出现问题,那么只需要回滚这一部分服务器就可以。发布以后观察一天,第二天再发布一部分服务器。如果没有故障报告,那么就继续发布,如果有故障报告就进行回滚,减少故障的影响力和影响时间。灰度发布流程如下图。
总结
系统可用性是通过可用性指标来进行衡量的。当我们说一个系统 4 个 9 可用的时候,就是指这个系统 99.99% 的时间都是可用的,也就意味着一年中的不可用时间只占 53 分钟。 为了对故障进行管理和考核,很多互联网企业还引入了故障分这样一个手段。保障系统高可用的主要策略有应用服务器的负载均衡、数据库的备份与失效转移、消息队列隔离,限流、降级以及异地多活的多机房架构。 除了这些高可用的架构策略,还通过一系列的自动化手段,实现运维的高可用,包括自动化测试、自动化监控,预发布以及灰度发布这些手段。
无状态化设计
描述
实现无状态化设计是确保系统可扩展性、高可用性和容错性的重要一环。无状态化服务指的是服务本身不依赖任何客户端的上下文信息或状态,所有需要的信息都包含在请求中。
1. 理解无状态服务
无状态服务不存储任何客户端的会话信息或状态。 每个请求都是独立的,并且包含完成请求所需的所有信息。 无状态服务更容易扩展和容错,因为可以在任何时间点将请求路由到任何可用的服务实例。
2. 设计无状态服务
避免使用会话数据:不要在服务中存储任何与会话相关的数据。所有会话数据都应存储在客户端或外部存储系统中(如Redis或数据库)。 请求携带状态:确保每个请求都包含所有必要的信息,以便服务能够处理该请求而无需依赖任何先前的请求或状态。 使用HTTP方法:利用HTTP方法的语义(如GET、POST、PUT、DELETE)来标识请求的类型和目的。
3. 在Spring Cloud中实现无状态化
微服务拆分:将应用程序拆分为多个独立的微服务,每个微服务都是无状态的。 使用Spring Cloud组件:利用Spring Cloud提供的组件(如Eureka、Ribbon、Feign等)来实现服务发现、负载均衡和客户端负载均衡。 配置无状态Session: 对于Spring Security,可以通过设置SessionCreationPolicy.STATELESS来禁用会话创建。 使用JWT(JSON Web Tokens)或OAuth2等令牌机制来验证用户身份,并将用户信息包含在请求头中。 外部化状态管理:使用外部存储系统(如Redis、数据库等)来存储和管理状态信息。确保服务之间不直接共享状态信息。 使用消息队列:对于需要跨多个服务进行通信的场景,可以使用消息队列(如RabbitMQ、Kafka等)来实现异步通信和解耦。
幂等设计
描述
幂等设计是确保系统在高并发、分布式环境下保持数据一致性和可靠性的重要手段。幂等性指的是无论执行多少次操作,结果都是一样的。
1. 识别需要幂等性的场景
用户重复提交表单,如多次点击提交按钮。 分布式系统中的消息重复消费。 分布式事务中的重试操作。
2. 设计幂等性策略
使用唯一标识符:为每次操作生成一个全局唯一的ID,并在执行操作前检查该ID是否已经存在。如果存在,则拒绝执行该操作。 状态机幂等:将操作状态保存在状态机中,并在执行操作前检查当前状态是否允许该操作。如果允许,则执行操作并更新状态;否则,拒绝执行该操作。 去重表:在数据库中创建一张去重表,用于记录已经执行过的操作。在执行操作前,先到去重表中查询是否已经存在该操作。如果存在,则拒绝执行该操作。 利用数据库的唯一约束:在数据库表中设置唯一约束(如主键、唯一索引等),确保每条记录都是唯一的。当插入或更新数据时,如果违反了唯一约束,则数据库会拒绝该操作。 分布式锁:使用分布式锁来确保在某一时刻只有一个请求在对某个资源进行操作。Spring Cloud提供了基于Redis的分布式锁实现。 幂等接口设计:在设计服务接口时,尽可能保证接口的幂等性。例如,对于修改资源信息的操作,可以通过检查记录是否存在来决定是否进行更新。
3. 实现幂等性策略
使用UUID或雪花算法(Snowflake)来生成全局唯一ID。 使用Spring Cloud Stream或RabbitMQ等消息中间件来实现状态机幂等。 使用Spring Data JPA或MyBatis等ORM框架来实现去重表和数据库唯一约束。 使用Spring Cloud Redis Starter来实现基于Redis的分布式锁。
分布式锁设计
描述:保证一个方法在同一时间内只能被同一个线程执行
分布式锁是什么样的
1)可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。 2)这把锁要是一把可重入锁(避免死锁) 3)这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条) 4)有高可用的获取锁和释放锁功能 5)获取锁和释放锁的性能要好
分布式锁的特点
1、互斥性:任意时刻,只能有一个客户端获取锁,不能同时有两个客户端获取到锁。 2、安全性:锁只能被持有该锁的客户端删除,不能由其它客户端删除。 3、死锁:获取锁的客户端因为某些原因(如down机等)而未能释放锁,其它客户端再也无法获取到该锁。 4、容错:当部分节点(redis节点等)down机时,客户端仍然能够获取锁和释放锁。 5.、锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
数据库实现
基于数据库表 (唯一键)
1. 新建一张表(对method_name加唯一约束),通过操作该表记录来实现 2. 要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录 3. 如果有多个请求同时提交,表会保证只有一个操作成功,认为操作成功的那个线程获得了方法的锁。
问题: 1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。 2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。 3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。 4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
基于数据库排他锁
1.新建一张表table1,给method_name加索引且是唯一,才会使用行级锁 2.开启事务,执行select * from table1 where method_name='find' for update; 3.记录被加上排他锁,其他线程无法在该行记录上增加排他锁。 4.通过connection.commit()操作来释放锁。
问题: 无法直接解决数据库单点和可重入问题 排他锁长时间不提交,就会占用数据库连接。
数据库实现分布式锁优缺点
优点:直接借助数据库,容易理解 缺点: 会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。 操作数据库需要一定的开销,性能问题需要考虑。 使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。
缓存
实现要求
1.互斥性,在同一时刻,只能有一个客户端持有锁 2.防止死锁,如果持有锁的客户端崩溃而且没有主动释放锁,怎样保证锁可以正常释放,使得客户端可以正常加锁 3.加锁和释放锁必须是同一个客户端。 4.容错性,只有redis还有节点存活,就可以正常的加锁解锁操作。
缓存实现分布式锁总结
可以使用缓存来代替数据库来实现分布式锁,这个可以提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如Tair的put方法,redis的setnx方法等。并且,这些缓存服务也都提供了对数据的过期自动删除的支持,可以直接设置超时时间来控制锁的释放。
jedis.set(String key, String value, String nxxx, String expx, int time)
加锁
public class RedisTool { private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "PX"; /** * 尝试获取分布式锁 * * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @param expireTime 超期时间 * @return 是否获取成功 */ public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) { String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); if (LOCK_SUCCESS.equals(result)) { return true; } return false; } }
第一个为key,我们使用key来当锁,因为key是唯一的。 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作; 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。 第五个为time,与第四个参数相呼应,代表key的过期时间。 总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。
解锁
public class RedisTool { private static final Long RELEASE_SUCCESS = 1L; /** * 释放分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @return 是否释放成功 */ public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey),Collections.singletonList(requestId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } }
Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)
zookeeper
每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
1.创建一个ZooKeeper客户端,并连接到ZooKeeper服务器。 2.在ZooKeeper的一个目录下创建一个锁节点,例如/locks/lock_node。 3.当需要获取锁时,调用create()方法在/locks目录下创建一个临时有序节点,例如/locks/lock_node/lock_000000001,同时设置watcher事件,监控它的前一个节点。 4.调用getChildren()方法获取/locks目录下所有的子节点,判断自己创建的节点是否为序号最小的节点。 5.如果是序号最小的节点,则表示获取到了锁,可以执行临界区代码;否则调用exists()方法监控自己前面的一个节点。 6.当前面的一个节点被删除时,触发watcher事件,重复第4和第5步,直到获取到锁为止。 7.释放锁时,调用delete()方法删除自己创建的节点,其他等待锁的进程或节点就可以获取到锁。
解决问题
锁无法释放: 在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
非阻塞锁: 使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
不可重入
客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队
单点问题
ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。
比较
性能角度
缓存 > Zookeeper >= 数据库
注意
临时节点的创建和删除必须是原子性的。 创建的临时节点没有及时删除,就会造成死锁。
分布式事务设计
面试中讨论分布式事务的设计时,可以从以下几个方面来阐述: 分布式事务的概述: 首先简要介绍分布式事务的概念,即事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。分布式事务的目的是保证不同数据库或不同服务之间的数据一致性。 分布式事务的ACID特性: 讨论分布式事务的ACID特性(原子性、一致性、隔离性、持久性),并解释这些特性在分布式环境中的挑战和如何实现。 分布式事务的解决方案: 列举并详细解释几种常见的分布式事务解决方案,如: XA方案(两阶段提交): 描述XA方案的角色(如TM、RM、TC)、流程(准备阶段、提交/回滚阶段)以及优缺点。 TCC方案(三阶段提交): 讨论TCC方案的三阶段(准备、预提交、提交/回滚)、补偿机制以及相对于两阶段提交的改进。 本地消息异步确认: 解释如何通过消息队列实现最终一致性,并讨论其适用场景和限制。 可靠消息最终一致性: 探讨如何通过可靠的消息传递机制保证数据的最终一致性。 最大努力通知: 讨论在无法完全保证消息传递成功时,如何通过“最大努力”来通知接收方,并设计相应的容错机制。 选择适合的解决方案: 分析不同解决方案的适用场景和限制,讨论如何根据具体业务需求和技术栈选择合适的分布式事务解决方案。 设计实践: 分享在项目中设计分布式事务的实践经验,如如何设计事务的边界、如何处理失败和回滚、如何监控和调优分布式事务等。 挑战和应对策略: 讨论在分布式事务设计中可能遇到的挑战,如网络分区、系统崩溃、数据不一致等,并给出相应的应对策略。 未来趋势: 展望分布式事务技术的未来发展趋势,如基于分布式账本技术(如区块链)的分布式事务解决方案、云原生分布式事务服务等。
服务降级设计
服务降级概述
服务降级是微服务架构中一种重要的容错处理机制。在系统压力剧增或某个服务出现故障时,为了保证核心业务的正常运行,可以对某些非关键服务进行降级处理,即暂时关闭或简化这些服务的功能,以释放系统资源。
Spring Cloud服务降级设计
在Spring Cloud中,服务降级通常通过熔断器(Circuit Breaker)来实现。熔断器可以监控服务的调用情况,当服务调用失败率达到一定阈值时,熔断器会开启,阻止对服务的进一步调用,从而避免系统资源的进一步浪费。
实例说明
假设我们有一个电商系统,其中包含了商品服务、订单服务、支付服务等多个微服务。当支付服务出现故障时,为了保证订单服务的正常运行,我们可以对支付服务进行降级处理。
引入依赖
<!-- 引入熔断器 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-hystrix</artifactId> </dependency>
启用Hystrix
@SpringBootApplication @EnableDiscoveryClient @EnableFeignClients // 启用Feign客户端 public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } }
定义降级方法
在订单服务中,我们通常会通过Feign Client来调用支付服务。为了实现对支付服务的降级处理,我们可以在Feign Client的接口上添加@HystrixCommand注解,并定义一个降级方法: @FeignClient(name = "payment-service", fallback = PaymentFallback.class) public interface PaymentClient { @GetMapping("/payment/{id}") Payment getPaymentById(@PathVariable("id") Long id); } @Component public class PaymentFallback implements PaymentClient { @Override public Payment getPaymentById(Long id) { // 返回降级后的数据或抛出异常 return new Payment(id, "Payment failed due to service degradation"); } }
PaymentFallback类实现了PaymentClient接口,并定义了降级后的处理方法。当支付服务调用失败时,Spring Cloud会自动调用PaymentFallback类中的方法作为降级处理。
测试
启动订单服务和支付服务,并模拟支付服务出现故障的情况(例如关闭支付服务)。然后调用订单服务中的相关接口,观察是否能够正常返回降级后的数据。如果能够正常返回降级后的数据,则说明服务降级设计成功。
服务熔断设计
熔断(Circuit Breaker)和降级(Fallback)在服务容错和恢复机制中常常被一起讨论,因为它们通常是在同一个解决方案中协同工作的。不过,它们各自有特定的角色和用途
熔断机制是一种预防性的措施,它监控服务调用的健康状况,并在一定条件下(如失败次数超过阈值、服务响应时间过长等)断开服务的调用链路,即“熔断”。这样做的目的是快速失败,防止请求堆积和连锁故障(雪崩效应)。当服务恢复后,熔断器会允许少量请求通过,以检测服务是否已恢复正常。
关系
协同工作:熔断和降级通常是协同工作的。熔断机制决定何时断开服务调用链路,而降级逻辑则定义了当服务调用失败或熔断时应执行的操作。 目的不同:熔断的主要目的是预防连锁故障,通过快速失败来避免系统崩溃;而降级的主要目的是在服务出现问题时提供一个备选方案,确保系统的可用性和响应性。 配置不同:熔断器通常有一系列配置参数,如失败阈值、熔断窗口大小等,用于控制熔断的行为;而降级逻辑通常是根据具体业务需求来定义的。
服务限流设计
描述
限流是一种重要的保护机制,用于防止系统因过多的请求而崩溃或响应变慢。有多种方式可以实现限流,比如使用Spring Cloud Gateway的内置限流功能、Sentinel等。以下是一个使用Sentinel作为限流组件的实例。
实例
1. 引入依赖
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-sentinel</artifactId> <version>你的版本号</version> </dependency>
2. 配置Sentinel
在application.yml或application.properties中配置Sentinel的相关参数(如果需要的话)。但通常,Sentinel的默认配置已经足够使用。
3. 定义限流规则
Sentinel支持多种限流规则,如QPS(每秒查询率)、并发线程数等。这些规则可以通过编程方式定义,也可以通过Sentinel的控制台动态配置。 但通常,我们不会直接在代码中写死限流规则,而是使用Sentinel的控制台来动态配置。
import com.alibaba.csp.sentinel.Entry; import com.alibaba.csp.sentinel.SphU; import com.alibaba.csp.sentinel.slots.block.BlockException; public class SomeService { public String someMethod(String param) { Entry entry = null; try { // 定义一个资源名,用于限流 entry = SphU.entry("someResourceName", EntryType.IN); // 业务逻辑... return "result"; } catch (BlockException e) { // 触发限流后的处理逻辑 return "Blocked by Sentinel: " + e.getClass().getSimpleName(); } finally { if (entry != null) { entry.exit(); } } } }
4. 使用Sentinel控制台
Sentinel提供了一个控制台,用于动态配置和管理限流规则。 下载并启动Sentinel控制台。 在微服务配置中指定Sentinel控制台的地址。 登录Sentinel控制台,为指定的资源添加限流规则
5. 注意事项
确保Sentinel的版本与你的Spring Cloud版本兼容。 Sentinel不仅支持QPS限流,还支持并发线程数限流、热点参数限流等。 Sentinel提供了丰富的流控效果,如快速失败、Warm Up、排队等待等。 Sentinel还可以与Spring Cloud Gateway集成,实现API网关层的限流。
6. 示例代码中的EntryType.IN
在上面的示例代码中,EntryType.IN表示这是一个入口流量控制。除了入口流量控制,Sentinel还支持出口流量控制(EntryType.OUT),但通常我们更关注入口流量控制。
全链路压测设计
1. 确定压测目标和范围 压测目标:明确压测的目的,例如探测业务吞吐极限、验证架构能力、探测性能瓶颈等。 压测范围:确定需要压测的服务和接口,包括微服务之间的调用链路。 2. 梳理系统架构 端到端请求链路:梳理清楚从客户端到后端的整个请求链路。 技术架构:了解系统中使用的技术栈,包括Spring Cloud的版本、使用的组件(如Eureka、Ribbon、Feign、Hystrix等)以及中间件(如Redis、MQ、DB等)。 瓶颈点分析:基于系统架构,分析潜在的瓶颈点,例如数据库连接池大小、消息队列的吞吐量等。 3. 梳理业务模型 业务特性:了解业务的特点和场景,确定压测数据的分布。 业务模型:根据业务特性,构建合理的业务模型,用于生成压测数据。 4. 准备压测环境 资源隔离:在生产环境进行压测时,需要确保压测流量不会干扰到正常业务流量。可以通过资源隔离(如虚拟机、容器、云服务等)来实现。 环境准备:搭建与生产环境相似的压测环境,包括数据库、消息队列、缓存等中间件。 5. 编写压测脚本 业务场景:根据业务场景编写压测脚本,模拟真实的用户请求。 压测工具:选择适合的压测工具,如JMeter、Gatling等。 6. 实施压测 逐步加压:从较小的压力开始,逐步增加压力,观察系统的响应和性能指标。 监控和日志:在压测过程中,实时监控系统的各项指标(如CPU、内存、网络、数据库等),并收集详细的日志信息。 7. 分析和优化 结果分析:分析压测结果,找出系统的瓶颈点和潜在问题。 性能优化:针对发现的问题进行性能优化,例如调整配置参数、优化代码、升级硬件等。 8. 自动化和持续监控 自动化压测:将压测过程自动化,定期或不定期地进行压测,确保系统的稳定性和性能。 持续监控:建立持续监控系统,实时监控系统的性能指标和异常情况,及时发现并解决问题。 注意事项 安全性:确保压测过程不会对生产环境造成安全威胁。 数据一致性:在压测过程中,确保不会污染或破坏生产数据。 回滚机制:建立完善的回滚机制,以便在压测过程中出现问题时能够迅速恢复系统状态。
并发设计
描述:一般都是从最初的小规模网站甚至是单机应用发展而来,当系统的吞吐量越来越大,系统的各种资源占用率会逐渐上升,响应时间也会越来越慢。当超过一定临界值后,系统的吞吐量反而会下降。我们所要做的事情,就是要尽量让系统处于上面所示的舒适区。
瓶颈:数据库,网络,IO, 系统资源(如CPU,内存等)都有可能成为系统的瓶颈
并发性能的指标
1. 吞吐量( Throughput):,系统在单位时间内处理的信息量,直接反应了系统的负载能力,与请求对CPU的消耗,网络接口,IO等密切相关。吞吐量的几个重要的参数就包括系统的并发数,QPS/TPS, RT等指标。 2. 响应时间(Response Time):系统响应请求的时间。在其它条件既定的情况下,系统处理请求越快,用户得到反馈的时间就越短,单位时间内服务器能够处理请求的数量就会越多,直接反应了系统的快慢。一个游戏的响应时间在100ms, 一个网页,在4s内是可以接收的,否则用户就会选择离开。 3. QPS(Query Rate per Second):每秒响应的请求数:并发数/平均响应时间。如域名系统服务器的机器的性能经常用每秒查询率来衡量。 4. 并发用户数(Concurrency Users):同时承载正常使用系统功能的用户数量。 5. PV:综合浏览量(Page View),即页面浏览量或者点击量,一个访客在24小时内访问的页面数量; 6. TPS(Transaction per Second):每秒事务量,和QPS的区别在于,如对一个页面的一次访问,为一个T,它可能包含多个Q。 7. 带宽:计算带宽大小需关注两个指标,峰值流量和页面的平均大小 8. 峰值每秒请求数(QPS)=(总PV数*80%)/(24小时秒数*20%)。(假设80%的访问量集中在20%的时间,也有其他计算方法,如一天中算8小时)
什么样的系统是高并发
一个小型网站,比如有10万的用户,10%的日活跃用户,假设每个人平均会发起20次请求,PV约为20万次,按 80%的PV会集中在20%的时间算(也有统计认为一天中有8小时是属于用户比较活跃的时段),峰值QPS=200000*0.8/4*3600=11QPS
从哪些方面优化 为了让系统能够支持足够大的业务量,从前端到后端会采用各种各样的技术(比如前端静态资源的压缩整合,使用CDN,分布式架构,缓存,数据库读写分离等)来满足日益增长的业务需求,每个阶段需要根据实际情况来进行优化,优化的方案也与硬件条件、网络带宽息息相关。
系统的基本软硬件条件
CPU:内核个数的多少,以及主频频率,单核处理线程数有限,过多线程的切换也需要成本,反而会降低性能。
存储系统
经过计算的数据写入存储系统,经过各级缓存到内存到硬盘,一级一级往后速度呈数量级降低。如何利用缓存来提升系统并发性能。
数据库
单表数据超过100万条,性能将急剧下降 MySQL集群的情况下,QPS会更上一个台阶 至于实际使用中的QPS到底如何,还得在具体的环境上去测试,硬件配置的影响,网络连接会消耗不少时间,不同范围的查询任务和数据量也会使真实的QPS大打折扣。在数据库已知上限的情况下,就是对业务进行优化了,需要利用合适的策略,契合业务特点,对数据做分库分表,来提升最终业务的性能。
网络带宽
网卡有百兆,千兆,10GB的带宽。带宽的上限,决定了单位时间内,可以传输的数据量。如果网卡成为了系统瓶颈,我们就得升级网卡,或者扩展系统了。
系统IO
系统与外部系统打交道的方式,分为同步(阻塞式),异步。异步不需要等待系统完全处理完就可以先返回并继续接收下一个请求。此处就需要根据业务特点进行处理,哪些业务必须使用同步方式,哪些业务可以使用异步方式。
提升
单机性能提升
增强单机硬件性能,如增加CPU核数,升级更好的网卡,升级读写速度更快的硬盘,如SSD,扩充系统内存,扩充硬盘容量。 提升单机架构性能,如使用Cache来减少IO次数,使用异步来增加服务吞吐量,使用无锁数据结构来减少响应时间。
单机到了天花板,做分布式设计
反向代理层,服务层,数据层都可以实施水平扩展
思考CAP
任何一个子系统(模块),当横向扩展为多个时,如果其中有数据的状态信息,就都会存在一致性如何保证的问题。一致性又分为强一致性和弱一致性,不同的业务场景可能会有不同的一致性需求。 幂等性保证--同一请求多次发送,最终处理结果要保持一致。
如何设计
复杂架构
1. 要知道支持的并发数是多少,提前做好规划,再进行分解, 2. 对应到前端,后端需要支持的QPS是多少。 3. 为了支持高并发,使用到的技术手段主要有:缓存,消息队列,服务池化,异步化处理,模块组件横向扩展。
业务模块拆分,微服务化,横向扩展
引入CDN--内容分发网络(Content Delivery Network),实现动静分离,将一些静态资源存放在靠近用户的地方,提高响应速度。 业务分离,微服务化,减少业务系统耦合,可以分而治之。 业务模块无状态化设计,可以实现模块的动态缩放。
缓存-用空间换时间
1.客户端:前端浏览器页面缓存(HTTP Header中包含Expires/Cache of Control,Last Modified(304,Server不返回Body,客户端可以继续用Cache,减少流量),ETag)) 2.网络中端:反向代理缓存,缓存命中的话可以减少部分向后台系统发送的请求次数,降低后端系统的压力。 3.服务端:服务端缓存是三者中最重要的部分,数据库往往是后端并发性的瓶颈,为了改善数据读取的状况,我们在开发过程中,可以在平台侧使用缓存框架(如Java的Ehcache),当缓存框架无法满足系统性能要求时,就需要在应用层开发应用级缓存,如使用Redis
数据库使用优化
读写分离,互联网业务大多是读多写少,使用读写分离的方法,能大幅提高数据库的QPS,如前图,只读的QPS可到读写的5倍。 数据表的精细化设计,分库分表,从而减少单表的数据量,提高读写效率。
其他
1.异步化处理实现系统模块解耦,提高响应速度。 2.消息队列实现流量削峰,如非关键业务,可以放入消息队列,慢慢处理。 3.分层限流,提前拒绝,如在负载均衡层就可以做防止重复请求的判断,提前拒绝。在卖商品的场景下,请求已经超过实际的商品数,也可以直接拒绝请求,这样就可以把打到后端的请求大幅减少。 4.代码逻辑,关注代码细节实现,代码实现是否合理,是否创建了过多的对象,循环遍历是否高效,缓存使用是否合理,是否重用计算结果等,算法使用是否合理。
关键系统
注册中心
配置中心
消息队列
分布式请求跟踪系统
服务管理系统
分布式服务调度系统
个性化推荐系统
搜索引擎设计
ABTest平台设计
数据存储
关系型数据库分库分表设计
关系型数据库性能优化
关系型数据库高可用设计
NoSQL分布式数据库设计
数据库数据无缝迁移
缓存数据一致性设计
开源框架
开发框架选择
微服务网关选型
微服务业务逻辑层、数据访问层开发框架选型
springboot
RPC
RPC框架设计
简介
单体应用时代只有内外网通信,并没有服务间通信的诉求,随着单机服务性能下降,进入多服务分布式的时代后Rpc 框架才应运而生。主要是解决服务间连接及数据交互,但除了通信和数据交互,为适应分布式架构/微服务架构的设计,通常还需要实现增值、增强的附加功能。
RPC通信方式设计
1. 多传输协议支持
跨网络、机房问题 跨语言问题 长连接还是短连接 传输安全 传输性能
HTTP协议
灵活便于管理,可以跨语言,但明文、性能很差
DUBBO协议
性能高、长连接,但跨语言做的不够好,大文件不能传输
RMI
性能差、短连接,但对于单次大数据量传输却比较好
2. 多数据压缩/序列化支持
跨语言/异构平台间交互、性能方面考虑。跟传输协议搭配
有DUBBO序列化、Hessian、Java原生、JSON等
如何找到服务(寻址)并且实现资源合理
描述:消费者如何知道提供者,并且知道当前是否存活,是设计PRC框架需要考虑的第二大问题
1. 多样的注册中心支持
不同的业务系统,对于服务间一致性要求并不同,这里有一个CAP权衡问题。
Zookeeper
支持强一致并能通过Wacher机制主动进行通知,但可用性并不能完全保证
2. 多算法负载均衡、路由和多维度流量控制
负载均衡目的是为了最优使用同一服务间的资源使用,具体到设计中,需要考虑机器情况、服务的负载情况等。 算法主要有随机、轮询、活路情况、一致性Hash等
3. 容错机制
考虑容错机制是系统完整性的一部分,failover、failfast、failback、failsafe 、forking、Broadcast …等,通常和负载均衡搭配。
让业务更方便的使用
支持普通配置的同时,支持集成到Spring等主流框架使用。配置的方式也有很多种,比如支持XML、注解、YAML、Properties、Json配置等。
可跟踪
可以进行依赖分析,数据的调用统计,并能图形、数据化将其显示出来
解决问题
服务调用链路或依赖关系 调用次数及时间,提供容量/机器预算基准数据 预警
实战项目
分布式架构拆分
为什么需要应用拆分
人员角度
百号人同时在一个工程上开发,一旦线上出问题,所有代码都需要回滚,从人员的角度,也基本忍受到了极致。
业务角度
用户、商品、交易、支付…等等,所有的代码早期都在一个工程里,代码已经严重影响到业务的效率,每个业务有各自的需求,需要给自己应用部署,各自开发需求。
架构的角度
从数据库端mysql数据库集中式架构的瓶颈问题,连接池数量限制,数据库的CPU已经到达了极限90%。数据库端也需要考虑垂直拆分了。
公司角度
借鉴的角度
应用拆分
工程代码垂直拆分
把整个工程代码按照业务为单元进行垂直拆分。
应用服务拆分
按照业务为单位,把所有调用相关的接口以业务为单元进行拆分。
新的角度看4点
为什么做架构拆分
做系统之间解耦、子系统之间解耦,或模块之间的解耦
为什么要做系统解耦
系统解耦后,使得原本错综复杂的调用逻辑能有序地分布到各个独立的系统中,从而使得拆封后的各个系统职责更单一,功能更为内聚。
为什么要做职责单一
因为职责单一的系统功能逻辑的迭代速度会更快,会提高研发团队响应业务需求的速度,也就是提高了团队的开发效率。
为什么要关注开发效率
研发迭代效率的提升是任何一家公司在业务发展期间都最为关注的问题,所以从某种程度上看,架构拆分是系统提效最直接的手段
总结:公司发展要架构,系统复杂要拆分,功能单一迭代快
拆分需求
组织结构变化
从最初的一个团队逐渐成长并拆分为几个团队,团队按照业务线不同进行划分
安全
这里所指的安全不是系统级别的安全,而是指代码或成果的安全,尤其是对于很多具有核心算法的系统,为了代码不被泄露,需要对相关系统进行模块化拆分,隔离核心功能,保护知识产权。
替换性
有些产品为了提供差异化的服务,需要产品具有可定制功能,根据用户的选择自由组合为一个完整的系统,比如一些模块,免费用户使用的功能与收费用户使用的功能肯定是不一样的,这就需要这些模块具有替换性,判断是免费用户还是收费用户使用不同的模块组装,这也需要对系统进行模块化拆分。
交付速度
单体程序最大的问题在于系统错综复杂,牵一发而动全身,也许一个小的改动就造成很多功能没办法正常工作,极大的降低了软件的交付速度,因为每次改动都需要大量的回归测试确保每个模块都能正确工作,因为我们不清楚改动会影响到什么,所以需要做大量重复工作,增加了测试成本。这时候就需要对系统进行拆分,理清各个功能间的关系并解耦。
技术需求
单体程序由于技术栈固定,尤其的是比较庞大的系统,不能很方便的进行技术升级,或者说对引入新技术或框架等处于封闭状态;每种语言都有自己的特点,单体程序没有办法享受到其它语言带来的便利;对应到团队中,团队技术相对比较单一。
相比于基于业务的垂直拆分,基于技术的横向拆分也很重要,使用数据访问层可以很好的隐藏对数据库的直接访问、减少数据库连接数、增加数据使用效率等;横向拆分可以极大的提高各个层级模块的重用性。
业务需求
由于业务上的某些特殊要求,比如对某个功能或模块的高可用性、高性能、可伸缩性等的要求,虽然也可以将单体整体部署到分布式环境中实现高可用、高性能等,但是从系统维护的角度来考虑,每次改动都要重新部署所有节点,显然会增加很多潜在的风险和不确定定性因素,所以有时候不得不选择将那些有特殊要求的功能从系统中抽取出来,独立部署和扩展。
如何拆分
拆分原则
单一职责 服务粒度适中 考虑团队结构 以业务模型切入 演进式拆分 避免环形依赖和双向依赖
拆分实战
设计module骨架
root web app
网关:api-gateway
webapp
微服务:pom module、 user-service、order-service,...
业务:biz-user、biz-order
框架:framework
工具类:tools
拆分技术commons
第一步:先对整个工程按业务和功能进行了maven多module的拆分。
首先是分离出技术上的commons,感觉这应该是最好拆分的了,把相关的类重构到一个包里,在分离出一个module即可。
拆分entity
子主题
很多在业务代码上都会共享entity类,通常没法也没法把entity类分门别类,最简单就是把所有的entity类放到一个module,谁需要谁依赖的原则。entity类也没有太多jar依赖和业务依赖,也不会形成污染源。
公共业务
同commons和entity方法,不在复述,也被各个业务依赖,这种业务大部分是过渡性的,在未来迭代演进时可以通过其他方式抽象集成。
拆分entity
很多在业务代码上都会共享entity类,通常没法也没法把entity类分门别类,最简单就是把所有的entity类放到一个module,谁需要谁依赖的原则。entity类也没有太多jar依赖和业务依赖,也不会形成污染源。
拆分微服务
有了以上的拆分基础,可以在合适的业务迭代将各个微服务module迁移到不同的代码仓库,完成进一步隔离管理。
拆分业务代码
拆分业务是最痛苦的事情了,这个要看原来的代码整洁度和互相依赖程度。有两种方式
1. 新建业务module,加入基础module的pom依赖,再从源module复制和该业务module相关的代码(包括单元测试代码)过来,解决编译错误和单元测试错误,完成本业务拆分。
2. 从源module复制一个新业务module,保持代码一致,先删除和本义务无关的代码(包括单元测试代码),再删除无关的pom依赖,解决编译错误和单元测试错误,完成本业务拆分。
应用拆分总结
1.明确拆分原则和拆分需求。 2.梳理出业务模块和之间的依赖关联关系。 3.按照业务为单位,拆分实体、以及应用工程单独部署。 4.按照业务为单位拆分应用服务,避免环形依赖和双向依赖。 5.抽离出公用的接口、实体,以及服务单独部署为公用服务。
微服务架构框架
springboot+dubbo
springCloud
系统服务治理设计
技术成长与技术人生
新技术探索
数据库
分库分表
设计方案
订单数据为分库分表
面临问题
超大容量问题
订单相关表都已经是超大表,达上几十亿,数据库处理能力已经到了极限。 单库包含多个超大表,占用的硬盘空间大。
性能问题
单一服务器处理能力有限,单一订单库的TPS有上限,限制了单位时间的订单处理能力。
升级扩展问题
单一主库无法灵活的进行升级和扩展,无法满足公司快速发展要求。 所有的订单数据都放在同一库里面,存在单点故障的风险。
2大类型
描述:每天上千万的数据,需要分两大类数据处理。规划如:热数据:使用MySQL进行存储,分库分表;冷数据:ES或TiDB或Hive存储
热数据
2个星期内的订单数据,查询实时性较高
冷数据
归档订单数据,查询频率不高
按业务拆分(垂直拆分)
订单库可以根据不同的业务场景,如大客户订单、散客户订单、用户等等,进行DB拆分
将不同的业务放到不同的库中,将原来所有压力由同一个库中分散到不同的库中,提升了系统的吞吐量。
分表策略(水平拆分)
在订单表中,order_id 允许重复,可以将该字段作为sharding key。假设单个库需要分配 10 张表可以满足业务需要,可以简单地取模计算出订单分配到哪张表。 解决问题:解决单表数据量很大的时候数据查询的效率问题。
一旦确定sharding key,就只能根据sharding key定位到子表进而查询该子表下的数据;如果确实想根据user_id 去查询相关订单,那么需要先根据user_id 查询映射到的order_id list,然后再根据order_id list 再查询。
分库策略
通过取模的方式进行路由。如果order_id 不是整数类型,可以先hash 在进行取模,如 hash(order_id) % DB数量。 解决问题:解决数据库的并发操作带来效率上的提高
分库分表(垂直水平拆分)结合使用策略
数据库分表可以解决单表海量数据的查询性能问题,分库可以解决单台数据库的并发访问压力问题。 如果分库和分表都使用同一个拆分键进行 Sharding 时,根据拆分键的键值按总的分表数(分库数x分表数)取余。
2 个分库,每个分库 4 张分表,那么 0 库上保存分表 0~3,1 库上保存分表 4~7。某个键值为 15,15 % (2 * 4) = 7,所以 15 被分到 1 库的表 7 上。
垂直拆分策略
订单销售、订单售后、订单任务在同一数据库中,不符电商系统分层设计,订单销售数据,性能第一。而售后数据,在订单生成以后,用于订单物流、订单客服等,保证数据的及时性即可
水平拆分策略
订单销售数据量大,需进一步水平分表处理,分表的目标是保证每个数据表的数量在1000~5000万左右。如果十几个表都在一个订单库中,运行于单组服务器,受限于单组服务器的处理能力,数据库TPS有限,要考虑分库,把分表放到分库里面,减轻单库的压力,增加总的订单TPS。
用户编号Hash切分
使用用户编号哈希取模,把单库拆成n个库,n个库分别存放到m组服务器中
使用用户编号进行sharding,可以使得创建订单的处理更简单,不需要进行跨库的事务处理,提高下单的性能与成功率。
订单号索引表
用户编号进行哈希分库分表,满足创建订单和通过用户编号维度进行查询的需求。但按订单号查询的也不少,需要解决订单号进行订单的CURD操作,建立订单号索引表。 订单号索引表用于用户编号与订单号的对应关系表,根据订单号进行哈希取模,存入分库里。再查出订单号对应的用户编号,再根据用户编号取模查询去对应的库查询订单数据。 订单号与用户编号的关系存放到缓存中,减少查询表的操作,提升性能
分布式数据库集群
订单水平分库分表以后,通过用户编号,订单号的查询可以通过上面的方法快速定位到订单数据,其他条件的查询、统计操作,无法简单做到,要引入分布式数据库中间件。
整体架构图
订单请求分为查询和更新请求,更新请求比较简单,就是根据分库分表规则写入DB即可。 查询请求需要计算出查询的是热数据还是冷数据,根据查询的时间范围计算出查询的是热数据还是冷数据。无法确定热数据、冷数据,就走ES或TiDB。 冷数据指近期1年的数据,查询一年前的数据,直接离线查hive即可。 定时任务,主要用来定时迁移订单数据,需要将冷数据分别迁移到ES和hive中。
组件
tddl
mycat
ShardingSphere
ShardingSphere 实现分片引擎的方案是重写 JDBC 规范,从而为应用程序提供与 JDBC 完全兼容的使用方式
JDBC 规范的重写
JDBC 规范简介
JDBC(Java Database Connectivity)的设计初衷是提供一套用于各种数据库的统一标准,而不同的数据库厂家共同遵守这套标准,并提供各自的实现方案供应用程序调用 对于开发人员而言,JDBC API 是我们访问数据库的主要途径,也是 ShardingSphere 重写 JDBC 规范并添加分片功能的入口。
// 创建池化的数据源 PooledDataSource dataSource = new PooledDataSource (); // 设置MySQL Driver dataSource.setDriver ("com.mysql.jdbc.Driver"); // 设置数据库URL、用户名和密码 dataSource.setUrl ("jdbc:mysql://localhost:3306/test"); dataSource.setUsername ("root"); dataSource.setPassword ("root"); // 获取连接 Connection connection = dataSource.getConnection(); // 执行查询 PreparedStatement statement = connection.prepareStatement ("select * from user"); // 获取查询结果应该处理 ResultSet resultSet = statement.executeQuery(); while (resultSet.next()) { … } // 关闭资源 statement.close(); resultSet.close(); connection.close();
DataSource
DataSource 在 JDBC 规范中代表的是一种数据源,核心作用是获取数据库连接对象 Connection
public interface DataSource extends CommonDataSource, Wrapper { Connection getConnection() throws SQLException; Connection getConnection(String username, String password) throws SQLException; }
在 ShardingSphere 中,暴露给业务开发人员的同样是一个经过增强的 DataSource 对象 ShardingSphere 提供的就是非 JDBC 标准的接口,所以也应该会用到这个 Wrapper 接口,并提供了类似的实现方案。
Connection
DataSource 的目的是获取 Connection 对象,我们可以把 Connection 理解为一种会话(Session)机制。 ShardingSphere 同样也实现了定制化的 Connection 类 ShardingConnection。
Statement
JDBC 规范中的 Statement 存在两种类型,一种是普通的 Statement,一种是支持预编译的 PreparedStatement。所谓预编译,是指数据库的编译器会对 SQL 语句提前编译,然后将预编译的结果缓存到数据库中,这样下次执行时就可以替换参数并直接使用编译过的语句,从而提高 SQL 的执行效率
在 ShardingSphere 中,同样也提供了 ShardingStatement 和 ShardingPreparedStatement 这两个支持分片操作的 Statement 对象。
ResultSet
一旦通过 Statement 或 PreparedStatement 执行了 SQL 语句并获得了 ResultSet 对象后,那么就可以通过调用 Resulset 对象中的 next() 方法遍历整个结果集。
ShardingSphere 中也提供了分片环境下的 ShardingResultSet 对象。
总结
ShardingSphere 提供了与 JDBC 规范完全兼容的 API。也就是说,开发人员可以基于这个开发流程和 JDBC 中的核心接口完成分片引擎、数据脱敏等操作
基于适配器模式的 JDBC 重写实现方案
在 ShardingSphere 中,实现与 JDBC 规范兼容性的基本策略就是采用了设计模式中的适配器模式(Adapter Pattern)。适配器模式通常被用作连接两个不兼容接口之间的桥梁,涉及为某一个接口加入独立的或不兼容的功能。 这里有一个 JdbcObject 接口,这个接口泛指 JDBC API 中的 DataSource、Connection、Statement 等核心接口。前面提到,这些接口都继承自包装器 Wrapper 接口。ShardingSphere 为这个 Wrapper 接口提供了一个实现类 WrapperAdapter
子主题
总结
JDBC 规范是理解和应用 ShardingSphere 的基础,ShardingSphere 对 JDBC 规范进行了重写,并提供了完全兼容的一套接口。在这一课时中,我们首先给出了 JDBC 规范中各个核心接口的介绍;正是在这些接口的基础上,ShardingSphere 基于适配器模式对 JDBC 规范进行了重写;我们对这一重写方案进行了抽象,并基于 ShardingConnectin 类的实现过程详细阐述了 ShardingSphere 重写 Connection 接口的源码分析。
使用 ShardingSphere 的方式
开发人员使用 ShardingSphere 时就像在使用 JDBC 规范所暴露的各个接口一样

底层工具
对于 ShardingSphere 而言,这里所说的底层工具实际上指的是关系型数据库。目前,ShardingSphere 支持包括 MySQL、Oracle、SQLServer、PostgreSQL 以及任何遵循 SQL92 标准的数据库。
基础规范
对于 ShardingSphere 而言,所涉及的基础规范很明确,就是我们在上一课时中所详细阐述的 JDBC 规范。
开发框架
ShardingSphere 同时集成了 Spring 和 Spring Boot 这两款 Spring 家族的主流开发框架。熟悉这两款框架的开发人员在应用 ShardingSphere 进行开发时将不需要任何学习成本。
领域框架
所谓领域框架,是指与所设计的开源框架属于同一专业领域的开发框架。
对于 ShardingSphere 而言,领域框架指的是 MyBatis、Hibernate 等常见的 ORM 框架。ShardingSphere 对这领域框架提供了无缝集成的实现方案
子主题
JDBC 驱动
driverClassName: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/test_database username: root password: root
数据库连接池
配置 JDBC 驱动的目的是获取访问数据库所需的 Connection。
ShardingSphere 支持一批主流的第三方数据库连接池,包括 DBCP、C3P0、BoneCP、Druid 和 HikariCP 等。
spring.shardingsphere.datasource.names= test_datasource spring.shardingsphere.datasource.test_datasource.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.test_datasource.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.test_datasource.jdbc-url=jdbc:mysql://localhost:3306/test_database spring.shardingsphere.datasource.test_datasource.username=root spring.shardingsphere.datasource.test_datasource.password=root
开发框架集成
例
假设系统中存在一个用户表 User,这张表的数据量比较大,所以我们将它进行分库分表处理,计划分成两个数据库 ds0 和 ds1,然后每个库中再分成两张表 user0 和 user1
Java原生
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>sharding-jdbc-core</artifactId> </dependency>
public final class DataSourceHelper{ private static final String HOST = "localhost"; private static final int PORT = 3306; private static final String USER_NAME = "root"; private static final String PASSWORD = "root"; public static DataSource createDataSource(final String dataSourceName) { DruidDataSource result = new DruidDataSource(); result.setDriverClassName(com.mysql.jdbc.Driver.class.getName()); result.setUrl(String.format("jdbc:mysql://%s:%s/%s, HOST, PORT, dataSourceName)); result.setUsername(USER_NAME); result.setPassword(PASSWORD); return result; } }
private static Map<String, DataSource> createDataSourceMap() { Map<String, DataSource> result = new HashMap<>(); result.put("ds0", DataSourceHelper.createDataSource("ds0")); result.put("ds1", DataSourceHelper.createDataSource("ds1")); return result; }
/* ShardingSphere 中的规则配置类,包含分片规则配置、分表规则配置、分布式主键生成配置等。 同时,我们在分片规则配置中使用行表达式来设置具体的分片规则。 */ public DataSource dataSource() throws SQLException { //创建分片规则配置类 ShardingRuleConfiguration shardingRuleConfig = new ShardingRuleConfiguration(); //创建分表规则配置类 TableRuleConfiguration tableRuleConfig = new TableRuleConfiguration("user", "ds${0..1}.user${0..1}"); //创建分布式主键生成配置类 Properties properties = new Properties(); properties.setProperty("worker.id", "33"); KeyGeneratorConfiguration keyGeneratorConfig = new KeyGeneratorConfiguration("SNOWFLAKE", "id", properties); tableRuleConfig.setKeyGeneratorConfig(keyGeneratorConfig); shardingRuleConfig.getTableRuleConfigs().add(tableRuleConfig); //根据性别分库,一共分为 2 个库 shardingRuleConfig.setDefaultDatabaseShardingStrategyConfig(new InlineShardingStrategyConfiguration("sex", "ds${sex % 2}")); //根据用户 ID 分表,一共分为 2 张表 shardingRuleConfig.setDefaultTableShardingStrategyConfig(new StandardShardingStrategyConfiguration("id", "user${id % 2}")); //通过工厂类创建具体的 DataSource return ShardingDataSourceFactory.createDataSource(createDataSourceMap(), shardingRuleConfig, new Properties()); }
List<User> getUsers(final String sql) throws SQLException { List<User> result = new LinkedList<>(); try (Connection connection = dataSource.getConnection(); PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { User user= new User(); //省略设置User对象的赋值语句 result.add(user); } } return result; }
Spring
ShardingSphere 中基于命名空间(NameSpace)机制完成了与 Spring 框架的无缝集成。要想使用这种机制,需要先引入对应的 Maven 依赖:
Spring 中的命名空间机制本质上就是基于 Spring 配置文件的 XML Scheme 添加定制化的配置项并进行解析,所以我们会在 XML 配置文件中看到一系列与分片相关的自定义配置项
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>sharding-jdbc-spring-namespace</artifactId> </dependency>
<bean id="ds0" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/ds0"/> <property name="username" value="root"/> <property name="password" value="root"/> </bean>
<!-- 创建分库配置 --> <sharding:inline-strategy id="databaseStrategy" sharding-column="sex" algorithm-expression="ds${sex % 2}" /> <!-- 创建分表配置 --> <sharding:inline-strategy id="tableStrategy" sharding-column="id" algorithm-expression="user${id % 2}" /> <!-- 创建分布式主键生成配置 --> <bean:properties id="properties"> <prop key="worker.id">33</prop> </bean:properties> <sharding:key-generator id="keyGenerator" type="SNOWFLAKE" column="id" props-ref="properties" /> <!-- 创建分片规则配置 --> <sharding:data-source id="shardingDataSource"> <sharding:sharding-rule data-source-names="ds0, ds1"> <sharding:table-rules> <sharding:table-rule logic-table="user" actual-data-nodes="ds${0..1}.user${0..1}" database-strategy-ref="databaseStrategy" table-strategy-ref="tableStrategy" key-generator-ref="keyGenerator" /> </sharding:table-rules> </sharding:sharding-rule> </sharding:data-source>
Spring Boot
如果你使用的开发框架是 Spring Boot,那么所需要做的也是编写一些配置项。在 Spring Boot 中,配置项的组织形式有两种,一种是 .yaml 文件,一种是 .properties 文件,这里以 .properties 为例给出 DataSource 的配置:
spring.shardingsphere.datasource.names=ds0,ds1 spring.shardingsphere.datasource.ds0.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds0.jdbc-url=jdbc:mysql://localhost:3306/ds0 spring.shardingsphere.datasource.ds0.username=root spring.shardingsphere.datasource.ds0.password=root spring.shardingsphere.datasource.ds1.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds1.jdbc-url=jdbc:mysql://localhost:3306/ds1 spring.shardingsphere.datasource.ds1.username=root spring.shardingsphere.datasource.ds1.password=root
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=sex spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=ds$->{sex % 2} spring.shardingsphere.sharding.tables.user.actual-data-nodes=ds$->{0..1}.user$->{0..1} spring.shardingsphere.sharding.tables.user.table-strategy.inline.sharding-column=id spring.shardingsphere.sharding.tables.user.table-strategy.inline.algorithm-expression=user$->{id % 2} spring.shardingsphere.sharding.tables.user.key-generator.column=id spring.shardingsphere.sharding.tables.user.key-generator.type=SNOWFLAKE spring.shardingsphere.sharding.tables.user.key-generator.props.worker.id=33
MyBatis
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </dependency>
Spring Boot 的 application.properties 中引用这个配置文件 mybatis.config-location=classpath:META-INF/mybatis-config.xml
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <mappers> <mapper resource="mappers/UserMapper.xml"/> </mappers> </configuration>
@ComponentScan("com.user.mybatis") @MapperScan(basePackages = "com.user.mybatis.repository") public class UserApplication
配置体系是如何设计
什么是行表达式?
行表达式是 ShardingSphere 中用于实现简化和统一配置信息的一种工具 行表达式在 ShardingSphere 中另一个常见的应用场景就是配置各种分片算法,我们会在后续的示例中大量看到这种使用方法。
使用的"ds${0..1}.user${0..1}"就是一个行表达式,用来设置可用的数据源或数据表名称。
${begin..end} 表示的是一个从"begin"到"end"的范围区间,而多个 ${expression} 之间可以用"."符号进行连接,代表多个表达式数值之间的一种笛卡尔积关系
使用到的 ds${age % 2} 表达式,它表示根据 age 字段进行对 2 取模,从而自动计算目标数据源是 ds0 还是 ds1。
ShardingRuleConfiguration
这个 ShardingRuleConfiguration 就是用于分片规则的配置入口。
TableRuleConfiguration
TableRuleConfiguration 是表分片规则配置
actualDataNodes
actualDataNodes 代表真实的数据节点,由数据源名+表名组成,支持行表达式。例如,前面介绍的"ds${0..1}.user${0..1}"就是比较典型的一种配置方式。
databaseShardingStrategyConfig
databaseShardingStrategyConfig 代表分库策略,如果不设置则使用默认分库策略,这里的默认分库策略就是 ShardingRuleConfiguration 中的 defaultDatabaseShardingStrategyConfig 配置。
tableShardingStrategyConfig
和 databaseShardingStrategyConfig 一样,tableShardingStrategyConfig 代表分表策略,如果不设置也会使用默认分表策略,这里的默认分表策略同样来自 ShardingRuleConfiguration 中的 defaultTableShardingStrategyConfig 配置。
keyGeneratorConfig
keyGeneratorConfig 代表分布式环境下的自增列生成器配置,ShardingSphere 中集成了雪花算法等分布式 ID 的生成器实现。
ShardingStrategyConfiguration
databaseShardingStrategyConfig 和 tableShardingStrategyConfig 的类型都是一个 ShardingStrategyConfiguration 对象。在 ShardingSphere 中,ShardingStrategyConfiguration 实际上是一个空接口,存在一系列的实现类,其中的每个实现类都代表一种分片策略:
KeyGeneratorConfiguration
对于一个自增列而言,KeyGeneratorConfiguration 中首先需要指定一个列名 column。同时,因为 ShardingSphere 中内置了一批自增列的实现机制(例如雪花算法 SNOWFLAKE 以及通用唯一识别码 UUID),所以需要通过一个 type 配置项进行指定。最后,我们可以利用 Properties 配置项来指定自增值生成过程中所需要的相关属性配置。
配置方式
Java 代码配置
Yaml 配置
dataSources: dsmaster: !!com.alibaba.druid.pool.DruidDataSource driverClassName: com.mysql.jdbc.Driver url: jdbc:mysql://119.3.52.175:3306/dsmaster username: root password: root dsslave0: !!com.alibaba.druid.pool.DruidDataSource driverClassName: com.mysql.jdbc.Driver url: jdbc:mysql://119.3.52.175:3306/dsslave0 username: root password: root dsslave1: !!com.alibaba.druid.pool.DruidDataSource driverClassName: com.mysql.jdbc.Driver url: jdbc:mysql://119.3.52.175:3306/dsslave1 username: root password: root masterSlaveRule: name: health_ms masterDataSourceName: dsmaster slaveDataSourceNames: [dsslave0, dsslave1]
配置了 dsmaster、dsslave0 和 dsslave1 这三个 DataSource,然后针对每个 DataSource 分别设置了它们的驱动信息。最后,基于这三个 DataSource 配置了一个 masterSlaveRule 规则,用于指定具体的主从架构。
Spring 命名空间配置
通过自定义配置标签实现方案来扩展 Spring 的命名空间,从而在 Spring 中嵌入各种自定义的配置项。
<master-slave:load-balance-algorithm id="randomStrategy"/>
master-slave 是命名空间,从这个命名空间中可以明确地区分出所属的逻辑分类是用于实现读写分离;load-balance-algorithm 是一种元素,代表用于设置读写分离中的负载均衡算法;而 ID 就是负载均衡下的一个配置选项,它的值为随机策略 randomStrategy。
<beans ... http://shardingsphere.apache.org/schema/shardingsphere/masterslave http://shardingsphere.apache.org/schema/shardingsphere/masterslave/master-slave.xsd"> <bean id=" dsmaster " class=" com.alibaba.druid.pool.DruidDataSource" destroy-method="close"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/dsmaster"/> <property name="username" value="root"/> <property name="password" value="root"/> </bean> <bean id="dsslave0" class="com.alibaba.druid.pool.DruidDataSource" destroy-method="close"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/dsslave0"/> <property name="username" value="root"/> <property name="password" value="root"/> </bean> <bean id="dsslave1" class="com.alibaba.druid.pool.DruidDataSource" destroy-method="close"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/dsslave1"/> <property name="username" value="root"/> <property name="password" value="root"/> </bean> <master-slave:load-balance-algorithm id="randomStrategy" type="RANDOM" /> <master-slave:data-source id="masterSlaveDataSource" master-data-source-name="dsmaster" slave-data-source-names="dsslave0, dsslave1" strategy-ref="randomStrategy" /> </beans>
Spring Boot配置
开发人员可以把配置项放在 application.properties 文件中。同时,为了便于对配置信息进行管理和维护,Spring Boot 也提供了 profile 的概念,可以基于 profile 来灵活组织面对不同环境或应用场景的配置信息。在采用 profile 时,配置文件的命名方式有一定的约定:
{application}-{profile}.properties
基于这种命名约定,如果我们根据面向的是传统的单库单表场景,还是主从架构的读写分离场景进行命名,就需要分别提供两个不同的 .properties 配置文件
application-traditional.properties application-master-slave.properties
这两个文件名中的 traditional 和 master-slave 就是具体的 profile,现在在 application.properties 文件中就可以使用 spring.profiles.active 配置项来设置当前所使用的 profile:
#spring.profiles.active=traditional spring.profiles.active=master-slave
spring.shardingsphere.datasource.names=dsmaster,dsslave0,dsslave1 spring.shardingsphere.datasource.dsmaster.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.dsmaster.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.dsmaster.url=jdbc:mysql://localhost:3306/dsmaster spring.shardingsphere.datasource.dsmaster.username=root spring.shardingsphere.datasource.dsmaster.password=root spring.shardingsphere.datasource.dsslave0.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.dsslave0.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.dsslave0.url=jdbc:mysql://localhost:3306/dsslave0 spring.shardingsphere.datasource.dsslave0.username=root spring.shardingsphere.datasource.dsslave0.password=root spring.shardingsphere.datasource.dsslave1.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.dsslave1.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.dsslave1.url=jdbc:mysql://localhost:3306/dsslave1 spring.shardingsphere.datasource.dsslave1.username=root spring.shardingsphere.datasource.dsslave1.password=root spring.shardingsphere.masterslave.load-balance-algorithm-type=random spring.shardingsphere.masterslave.name=health_ms spring.shardingsphere.masterslave.master-data-source-name=dsmaster spring.shardingsphere.masterslave.slave-data-source-names=dsslave0,dsslave1
ShardingRuleConfiguration 配置体系
对于 ShardingSphere 而言,配置体系的作用本质上就是用来初始化 DataSource 等 JDBC 对象。例如,ShardingDataSourceFactory 就是基于传入的数据源 Map、ShardingRuleConfiguration 以及 Properties 来创建一个 ShardingDataSource 对象:
public final class ShardingDataSourceFactory { public static DataSource createDataSource( final Map<String, DataSource> dataSourceMap, final ShardingRuleConfiguration shardingRuleConfig, final Properties props) throws SQLException { return new ShardingDataSource(dataSourceMap, new ShardingRule(shardingRuleConfig, dataSourceMap.keySet()), props); } }
在 ShardingSphere 中,所有规则配置类都实现了一个顶层接口 RuleConfiguration。RuleConfiguration 是一个空接口,ShardingRuleConfiguration 就是这个接口的实现类之一,专门用来处理分片引擎的应用场景。下面这段代码就是 ShardingRuleConfiguration 类的实现过程
public final class ShardingRuleConfiguration implements RuleConfiguration { //表分片规则列表 private Collection<TableRuleConfiguration> tableRuleConfigs = new LinkedList<>(); //绑定表规则列表 private Collection<String> bindingTableGroups = new LinkedList<>(); //广播表规则列表 private Collection<String> broadcastTables = new LinkedList<>(); //默认数据源 private String defaultDataSourceName; //默认分库策略 private ShardingStrategyConfiguration defaultDatabaseShardingStrategyConfig; //默认分表策略 private ShardingStrategyConfiguration defaultTableShardingStrategyConfig; //默认自增列值生成器 private KeyGeneratorConfiguration defaultKeyGeneratorConfig; //读写分离规则 private Collection<MasterSlaveRuleConfiguration> masterSlaveRuleConfigs = new LinkedList<>(); //数据脱敏规则 private EncryptRuleConfiguration encryptRuleConfig; }
public final class TableRuleConfiguration { //逻辑表 private final String logicTable; //真实数据节点 private final String actualDataNodes; //分库策略 private ShardingStrategyConfiguration databaseShardingStrategyConfig; //分表策略 private ShardingStrategyConfiguration tableShardingStrategyConfig; //自增列生成器 private KeyGeneratorConfiguration keyGeneratorConfig; public TableRuleConfiguration(final String logicTable) { this(logicTable, null); } public TableRuleConfiguration(final String logicTable, final String actualDataNodes) { Preconditions.checkArgument(!Strings.isNullOrEmpty(logicTable), "LogicTable is required."); this.logicTable = logicTable; this.actualDataNodes = actualDataNodes; } }
YamlShardingRuleConfiguration 配置体系
与 RuleConfiguration 一样,ShardingSphere 同样提供了一个空的 YamlConfiguration 接口。这个接口的实现类非常多,但我们发现其中包含了唯一的一个抽象类 YamlRootRuleConfiguration,显然,这个类是 Yaml 配置体系中的基础类。 在这个 YamlRootRuleConfiguration 中,包含着数据源 Map 和 Properties:
public abstract class YamlRootRuleConfiguration implements YamlConfiguration { private Map<String, DataSource> dataSources = new HashMap<>(); private Properties props = new Properties(); }
如何实现分库、分表、分库+分表以及强制路由?
单库单表系统
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>sharding-jdbc-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </dependency>
spring.datasource.driverClassName = com.mysql.jdbc.Driver spring.datasource.url = jdbc:mysql://localhost:3306/ds spring.datasource.username = root spring.datasource.password = root
spring.profiles.active=traditional
业务场景
我们考虑一个在医疗健康领域中比较常见的业务场景。在这类场景中,每个用户(User)都有一份健康记录(HealthRecord),存储着代表用户当前健康状况的健康等级(HealthLevel),以及一系列健康任务(HealthTask)。通常,医生通过用户当前的健康记录创建不同的健康任务,然后用户可以通过完成医生所指定的任务来获取一定的健康积分,而这个积分决定了用户的健康等级,并最终影响到整个健康记录。健康任务做得越多,健康等级就越高,用户的健康记录也就越完善,反过来健康任务也就可以越做越少,从而形成一个正向的业务闭环。这里,我们无意对整个业务闭环做过多的阐述,而是关注这一业务场景下几个核心业务对象的存储和访问方式。 在这个场景下,我们关注 User、HealthRecord、HealthLevel 和 HealthTask 这四个业务对象。在下面这张图中,对每个业务对象给出最基础的字段定义,以及这四个对象之间的关联关系:
@Service public class HealthRecordServiceImpl implements HealthRecordService { @Autowired private HealthRecordRepository healthRecordRepository; @Autowired private HealthTaskRepository healthTaskRepository; @Override public void processHealthRecords() throws SQLException{ insertHealthRecords(); } private List<Integer> insertHealthRecords() throws SQLException { List<Integer> result = new ArrayList<>(10); for (int i = 1; i <= 10; i++) { HealthRecord healthRecord = insertHealthRecord(i); insertHealthTask(i, healthRecord); result.add(healthRecord.getRecordId()); } return result; } private HealthRecord insertHealthRecord(final int i) throws SQLException { HealthRecord healthRecord = new HealthRecord(); healthRecord.setUserId(i); healthRecord.setLevelId(i % 5); healthRecord.setRemark("Remark" + i); healthRecordRepository.addEntity(healthRecord); return healthRecord; } private void insertHealthTask(final int i, final HealthRecord healthRecord) throws SQLException { HealthTask healthTask = new HealthTask(); healthTask.setRecordId(healthRecord.getRecordId()); healthTask.setUserId(i); healthTask.setTaskName("TaskName" + i); healthTaskRepository.addEntity(healthTask); } }
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) public class UserServiceTest { @Autowired private UserService userService; @Test public void testProcessUsers() throws Exception { userService.processUsers(); } }
系统改造:实现分库
初始化数据源
针对分库场景,我们设计了两个数据库,分别叫 ds0 和 ds1。显然,针对两个数据源,我们就需要初始化两个 DataSource 对象,这两个 DataSource 对象将组成一个 Map 并传递给 ShardingDataSourceFactory 工厂类:
spring.shardingsphere.datasource.names=ds0,ds1 spring.shardingsphere.datasource.ds0.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds0.url=jdbc:mysql://localhost:3306/ds0 spring.shardingsphere.datasource.ds0.username=root spring.shardingsphere.datasource.ds0.password=root spring.shardingsphere.datasource.ds1.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds1.url=jdbc:mysql://localhost:3306/ds1 spring.shardingsphere.datasource.ds1.username=root spring.shardingsphere.datasource.ds1.password=root
设置分片策略
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=ds$->{user_id % 2}
在 ShardingSphere 中存在一组 ShardingStrategyConfiguration,这里使用的是基于行表达式的 InlineShardingStrategyConfiguration。 InlineShardingStrategyConfiguration 包含两个需要设置的参数,一个是指定分片列名称的 shardingColumn,另一个是指定分片算法行表达式的 algorithmExpression
在我们的配置方案中,将基于 user_id 列对 2 的取模值来确定数据应该存储在哪一个数据库中。同时,注意到这里配置的是“default-database-strategy”项。结合上一课时的内容,设置这个配置项相当于是在 ShardingRuleConfiguration 中指定了默认的分库 ShardingStrategy。
设置绑定表和广播表
所谓绑定表,是指与分片规则一致的一组主表和子表。例如,在我们的业务场景中,health_record 表和 health_task 表中都存在一个 record_id 字段。如果我们在应用过程中按照这个 record_id 字段进行分片,那么这两张表就可以构成互为绑定表关系。
引入绑定表概念的根本原因在于,互为绑定表关系的多表关联查询不会出现笛卡尔积,因此关联查询效率将大大提升。
SELECT record.remark_name FROM health_record record JOIN health_task task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
路由SQL->
SELECT record.remark_name FROM health_record0 record JOIN health_task0 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2); SELECT record.remark_name FROM health_record1 record JOIN health_task1 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
请注意,如果想要达到这种效果,互为绑定表的各个表的分片键要完全相同。
在配置文件中添加对这种关系的配置
spring.shardingsphere.sharding.binding-tables=health_record, health_task
广播表(BroadCastTable)
指所有分片数据源中都存在的表,也就是说,这种表的表结构和表中的数据在每个数据库中都是完全一样的。
适用场景
通常针对数据量不大且需要与海量数据表进行关联查询的应用场景,典型的例子就是每个分片数据库中都应该存在的字典表。
spring.shardingsphere.sharding.broadcast-tables=health_level
设置表分片规则
TableRuleConfiguration 是表分片规则配置,包含了用于设置真实数据节点的 actualDataNodes;用于设置分库策略的 databaseShardingStrategyConfig;以及用于设置分布式环境下的自增列生成器的 keyGeneratorConfig。前面已经在 ShardingRuleConfiguration 中设置了默认的 databaseShardingStrategyConfig,现在我们需要完成剩下的 actualDataNodes 和 keyGeneratorConfig 的设置。
对于 health_record 表而言,由于存在两个数据源,所以,它所属于的 actual-data-nodes 可以用行表达式 ds$->{0..1}.health_record 来进行表示,代表在 ds0 和 ds1 中都存在表 health_record。而对于 keyGeneratorConfig 而言,通常建议你使用雪花算法。明确了这些信息之后,health_record 表对应的 TableRuleConfiguration 配置也就顺理成章了:
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=ds$->{0..1}.health_record spring.shardingsphere.sharding.tables.health_record.key-generator.column=record_id spring.shardingsphere.sharding.tables.health_record.key-generator.type=SNOWFLAKE spring.shardingsphere.sharding.tables.health_record.key-generator.props.worker.id=33
spring.shardingsphere.sharding.tables.health_task.actual-data-nodes=ds$->{0..1}.health_task spring.shardingsphere.sharding.tables.health_task.key-generator.column=task_id spring.shardingsphere.sharding.tables.health_task.key-generator.type=SNOWFLAKE spring.shardingsphere.sharding.tables.health_task.key-generator.props.worker.id=33
问题/回答
分库之后如何解决关联查询和模糊查询呢
hardingSphere对于普通的关联查询和模糊查询是透明的,会基于输入的SQL语句自动从各个库中获取目标数据进行归并。当然,对有些SQL语句是有限制的,官网上有说明。
未分片时向数据库中插入式局报错解决方法,把spring.shardingsphere.enable设置为false。因为开启它的话好像读取数据库相关信息的方式变了,需要通过 spring.shardingsphere.datasource来设定。
对的,如果不分片就不要启动shardingsphere,shardingsphere会拦截原有sql做对应的处理
分表分库之后怎么分页呢
ShardingSphere自动集成了分页机制的
我看了一下绑定表的配置,如果存在多个绑定表的话,配置起来不是非常的多?
是的,针对每个绑定表都需要配置。一般一个系统中,真正用到分库分表的表不会太多的,太多的话就不适合用关系型数据库了,所以绑定表也都有限。
系统改造:实现分表
分表操作是在同一个数据库中,完成对一张表的拆分工作。所以从数据源上讲,我们只需要定义一个 DataSource 对象即可,这里把这个新的 DataSource 命名为 ds2:
spring.shardingsphere.datasource.names=ds2 spring.shardingsphere.datasource.ds2.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds2.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds2.url=jdbc:mysql://localhost:3306/ds2 spring.shardingsphere.datasource.ds2.username=root spring.shardingsphere.datasource.ds2.password=root
//设置了绑定表和广播表 spring.shardingsphere.sharding.binding-tables=health_record, health_task spring.shardingsphere.sharding.broadcast-tables=health_level
TableRuleConfiguration 配置,该配置中的 tableShardingStrategyConfig 代表分表策略。与用于分库策略的 databaseShardingStrategyConfig 一样,设置分表策略的方式也是指定一个用于分表的分片键以及分片表达式:
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 2}
对于 health_record 表而言,我们设置它用于分表的分片键为 record_id,以及它的分片行表达式为 health_record$->{record_id % 2}。也就是说,我们会根据 record_id 将 health_record 单表拆分成 health_record0 和 health_record1 这两张分表。 基于分表策略,再加上 actualDataNodes 和 keyGeneratorConfig 配置项,我们就可以完成对 health_record 表的完整分表配置:
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=ds2.health_record$->{0..1} spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 2} spring.shardingsphere.sharding.tables.health_record.key-generator.column=record_id spring.shardingsphere.sharding.tables.health_record.key-generator.type=SNOWFLAKE spring.shardingsphere.sharding.tables.health_record.key-generator.props.worker.id=33
spring.shardingsphere.sharding.tables.health_task.actual-data-nodes=ds2.health_task$->{0..1} spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.sharding-column=record_id spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.algorithm-expression=health_task$->{record_id % 2} spring.shardingsphere.sharding.tables.health_task.key-generator.column=task_id spring.shardingsphere.sharding.tables.health_task.key-generator.type=SNOWFLAKE spring.shardingsphere.sharding.tables.health_task.key-generator.props.worker.id=33
系统改造:如何实现分库+分表?
在完成独立的分库和分表操作之后,系统改造的第三步是尝试把分库和分表结合起来。这个过程听起来比较复杂,但事实上,基于 ShardingSphere 提供的强大配置体系,开发人员要做的只是将分表针对分库和分表的配置项整合在一起就可以了。这里我们重新创建 3 个新的数据源,分别为 ds3、ds4 和 ds5:
spring.shardingsphere.datasource.names=ds3,ds4,ds5 spring.shardingsphere.datasource.ds3.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds3.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds3.url=jdbc:mysql://localhost:3306/ds3 spring.shardingsphere.datasource.ds3.username=root spring.shardingsphere.datasource.ds3.password=root spring.shardingsphere.datasource.ds4.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds4.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds4.url=jdbc:mysql://localhost:3306/ds4 spring.shardingsphere.datasource.ds4.username=root spring.shardingsphere.datasource.ds4.password=root spring.shardingsphere.datasource.ds5.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds5.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds5.url=jdbc:mysql://localhost:3306/ds5 spring.shardingsphere.datasource.ds5.username=root spring.shardingsphere.datasource.ds5.password=root
注意,到现在有 3 个数据源,而且命名分别是 ds3、ds4 和 ds5。所以,为了根据 user_id 来将数据分别分片到对应的数据源,我们需要调整行表达式,这时候的行表达式应该是 ds$->{user_id % 3 + 3}:
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=ds$->{user_id % 3 + 3} spring.shardingsphere.sharding.binding-tables=health_record,health_task spring.shardingsphere.sharding.broadcast-tables=health_level
对于 health_record 和 health_task 表而言,同样需要调整对应的行表达式,我们将 actual-data-nodes 设置为 ds$->{3..5}.health_record$->{0..2},也就是说每张原始表将被拆分成 3 张分表:
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=ds$->{3..5}.health_record$->{0..2} spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 3} spring.shardingsphere.sharding.tables.health_record.key-generator.column=record_id spring.shardingsphere.sharding.tables.health_record.key-generator.type=SNOWFLAKE spring.shardingsphere.sharding.tables.health_record.key-generator.props.worker.id=33 spring.shardingsphere.sharding.tables.health_task.actual-data-nodes=ds$->{3..5}.health_task$->{0..2} spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.sharding-column=record_id spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.algorithm-expression=health_task$->{record_id % 3} spring.shardingsphere.sharding.tables.health_task.key-generator.column=task_id spring.shardingsphere.sharding.tables.health_task.key-generator.type=SNOWFLAKE spring.shardingsphere.sharding.tables.health_task.key-generator.props.worker.id=33
系统改造:如何实现强制路由?
从 SQL 执行效果而言,分库分表可以看作是一种路由机制,也就是说把 SQL 语句路由到目标数据库或数据表中并获取数据。在实现了分库分表的基础之上,我们将要引入一种不同的路由方法,即强制路由。
什么是强制路由?
强制路由与一般的分库分表路由不同,它并没有使用任何的分片键和分片策略
我们需要为 SQL 执行开一个“后门”,允许在没有分片键的情况下,同样可以在外部设置目标数据库和表,这就是强制路由的设计理念。 在 ShardingSphere 中,通过 Hint 机制实现强制路由。
MySQL 中的强制索引能够确保所需要执行的 SQL 语句只作用于所指定的索引上,我们可以通过 FORCE INDEX 这一 Hint 语法实现这一目标:
SELECT * FROM TABLE1 FORCE INDEX (FIELD1)
数据库设计
描述:数据库选型、范式设计、ACID、事务的隔离
数据结构与算法
二叉树
最差查找
红黑树
还是太深
HASH
虽定位快,但不能解决查询范围
页的概念
InnoDB存储引擎中有页(Page)的概念,页是其磁盘管理的最小单位。InnoDB存储引擎中默认每个页的大小为16KB,可通过参数innodb_page_size将页的大小设置为4K、8K、16K,在MySQL中可通过如下命令查看页的大小: 1 mysql> show variables like 'innodb_page_size';
BTREE
深度可以控制 比如:设置度为3,4,5...
度:节点的数据存储个数 叶节点具有相同的深度 叶节点的指针为空 节点中的数据key从左到右递增排列
节点内的数据是有序的
模拟查找关键字29的过程: 根据根节点找到磁盘块1,读入内存。【磁盘I/O操作第1次】 比较关键字29在区间(17,35),找到磁盘块1的指针P2。 根据P2指针找到磁盘块3,读入内存。【磁盘I/O操作第2次】 比较关键字29在区间(26,30),找到磁盘块3的指针P2。 根据P2指针找到磁盘块8,读入内存。【磁盘I/O操作第3次】 在磁盘块8中的关键字列表中找到关键字29。
分析上面过程,发现需要3次磁盘I/O操作,和3次内存查找操作。由于内存中的关键字是一个有序表结构,可以利用二分法查找提高效率。而3次磁盘I/O操作是影响整个B-Tree查找效率的决定因素。B-Tree相对于AVLTree缩减了节点个数,使每次磁盘I/O取到内存的数据都发挥了作用,从而提高了查询效率。
B-Tree结构图中可以看到每个节点中不仅包含数据的key值,还有data值。而每一个页的存储空间是有限的,如果data数据较大时将会导致每个节点(即一个页)能存储的key的数量很小,当存储的数据量很大时同样会导致B-Tree的深度较大,增大查询时的磁盘I/O次数,进而影响查询效率。
B+Tree
3阶的tree
度:Mysql会做优化,一个节点一个页
描述: 非叶子节点不存储data,只存储key(索引),可以增大度 叶子节点不存储指针 顺序访问指针,提高区间访问的性能
注: 叶子节点的度可以很大, 叶子节点可以放数据 数据节点是有顺序的,适用于范围查询
索引的性能分析
一般使用磁盘I/O次数评价索引结构的优劣 预读:磁盘一般会顺序向后读取一定长度的数据(页的整数倍)放入内存 局部性原理:当一个数据被用到时,其附近的数据也通常会马上被使用 B+Tree节点的大小设为等于一个页,每次新建节点直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,就实现了一个节点的载入只需一次I/O B+Tree的度一般会超过100,因此h非常小(一般为3到5之间)
在B+Tree中,所有数据记录节点都是按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只存储key值信息,这样可以大大加大每个节点存储的key值数量,降低B+Tree的高度。
区别
B+Tree相对于B-Tree有几点不同: 非叶子节点只存储键值信息。 所有叶子节点之间都有一个链指针。 数据记录都存放在叶子节点中。
推算
InnoDB存储引擎中页的大小为16KB,一般表的主键类型为INT(占用4个字节)或BIGINT(占用8个字节),指针类型也一般为4或8个字节,也就是说一个页(B+Tree中的一个节点)中大概存储16KB/(8B+8B)=1K个键值(因为是估值,为方便计算,这里的K取值为〖10〗^3)。也就是说一个深度为3的B+Tree索引可以维护10^3 * 10^3 * 10^3 = 10亿 条记录。
查询次数
实际情况中每个节点可能不能填充满,因此在数据库中,B+Tree的高度一般都在2~4层。mysql的InnoDB存储引擎在设计时是将根节点常驻内存的,也就是说查找某一键值的行记录时最多只需要1~3次磁盘I/O操作
聚集索引和辅助索引
数据库中的B+Tree索引可以分为聚集索引(clustered index)和辅助索引(secondary index)。上面的B+Tree示例图在数据库中的实现即为聚集索引,聚集索引的B+Tree中的叶子节点存放的是整张表的行记录数据。 辅助索引与聚集索引的区别在于辅助索引的叶子节点并不包含行记录的全部数据,而是存储相应行数据的聚集索引键,即主键。当通过辅助索引来查询数据时,InnoDB存储引擎会遍历辅助索引找到主键,然后再通过主键在聚集索引中找到完整的行记录数据。
存储引擎
MyISAM索引实现(非聚集)
MyISAM索引文件和数据文件是分离的
存的是文件的指针,右下的是数据文件部分
InnoDB索引实现(聚集)
myisam是三个文件,索引文件与数据文件是分开的。 innodb是二个文件,索引与数据是一个文件。
数据也在叶子节点 用于主索引
辅助索引(节点中的字符串是辅助索引,叶子节点的值是主键索引) 还存了一份主键索引,由主键索引找记录。
性能优化
联合索引
联合主键索引
会用索引吗
explain
字段分析: ID是序号;id值大的先执行。如果相等,上面的先执行。 1. select_type:语句类型 1. primary:复杂查询,最外层的查询 2. simple:简单查询 3. subquery:包含在select中的子查询(不在from子句中) 4. derived:包含在from子句中的子查询。mysql 会将结果存放在一个临时表中,也称为派生表。衍生一个表 5. union:在union中的第二个随后的select。如explain select 1 union all select 1; 6. union result:从union临时表检索结果的select 2. table:正在访问哪个表,derived3就是一个延生表(id=3) 3. type:关联类型或访问类型,即MySQL决定如何查找表中的行,查找数据行记录的大概范围 。 依次从最优到最差分别为:system>const>eq_ref(唯一值关联)>ref>rang>index>ALL 1. system是const的一个特例,const是已知大小 2. eq_ref:primary key或unique key索引的所有部分被连接使用,最多只会返回一条符合条件的记录。 这可能是在const之外最好的联接类型 了,简单的select查询不会出现这种type. 3. ref:相比eq_ref,不使用唯一索引,而是使用普通索引或者唯一性索引的部分前缀,索引要和某个值相比较,可能会找到多个符合条件的行。 4. index:扫描全表索引,这通常比ALL快一些。(index是从索引中读取的,而all是从硬盘中读取的) 5. ALL:即全表扫描,意味着mysql需要从头到尾去查找所需要的行。通常情况下这需要增加索引来进行优化了。 4. possible_keys::可能用到的索引 5. key:去执行用到的索引 6. ken_len:显示了mysql在索引里使用的字节数,通过这个值可以算出具体使用了索引中的哪些列。 字符串:char(n),n字节长度。 varchar(n),2字节存储字符串长度,如果是utf-8,则长度3n+2。注:一个字符占三个字节 数值类型:tinyint:1字节,smallint:2字节,int:4字节,bigint:8字节 时间类型:date:3个字节,timestamp:4字节,datetime:8字节 如果字段允许为NULL,需要1字节记录是否为NULL。 走了哪几个字段,用到联合索引的哪几个字段。 7. ref:显示了在key列记录的索引中,表查找值所用到的列或常量,常见的有:const(常量),字段名 8. rows:这一列是mysql估计要读取并检测的行数,注意这个不是结果集里的行数。 9. Extra:额外信息
联合索引 指针是双向的
Extra列
Using index 走索引
Using where 未走索引
Using where Using index 无法用索引,但查询出来的列在索引中
NULL
Using index condition
Using temporary distinct会建临时表,子查询也会建临时表
最佳实践
第二个SQL没有走索引,因为>号无法使用索引,范围查询会破坏索引
子主题
子主题
子主题
子主题
子主题
!=或<>索引失效
is null, is not null索引失效
%放前索引失效
字符串不加单引号索引失效
少用or
总结
最左前缀的索引失效,后面的索引都失效
c1,c2,c3,c4是联合索引
order by
例:where c1='1' and c2='1' and c4='1' order by c3
c3是用到了索引
group by
例:where c1='' and c4='' group by c2,c3
会进行一次order by,多了一个临时表,一个filesort
1. 第一个范围查询一般不走索引 2. 可以用覆盖索引(在展示字段处加入索引字段),就可以走索引
子主题
范围查询右边都会失效
子主题
子主题
高性能优化规范建议
命令规范
•所有数据库对象名称必须使用小写字母并用下划线分割 •数据库对象的命名要能做到见名识意,并且最后不要超过 32 个字符 所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低)
基本设计规范
1. 所有表必须使用 Innodb 存储引擎
2. 数据库和表的字符集统一使用 UTF8
如果数据库中有存储 emoji 表情的需要,字符集需要采用 utf8mb4 字符集。
4. 尽量控制单表数据量的大小,建议控制在 500 万以内。
5. 谨慎使用 MySQL 分区表
6.尽量做到冷热数据分离,减小表的宽度
MySQL 限制每个表最多存储 4096 列,并且每一行数据的大小不能超过 65535 字节。
减少磁盘 IO,保证热数据的内存缓存命中率(表越宽,把表装载进内存缓冲池时所占用的内存也就越大,也会消耗更多的 IO);
8. 禁止在数据库中存储图片,文件等大的二进制数据
通常文件很大,会短时间内造成数据量快速增长,数据库进行数据库读取时,通常会进行大量的随机 IO 操作,文件很大时,IO 操作很耗时。
字段设计规范
1. 优先选择符合存储需要的最小的数据类型
列的字段越大,建立索引时所需要的空间也就越大,这样一页中所能存储的索引节点的数量也就越少也越少,在遍历时所需要的 IO 次数也就越多,索引的性能也就越差。
2. 避免使用 TEXT,BLOB 数据类型,最常见的 TEXT 类型可以存储 64k 的数据
a. 建议把 BLOB 或是 TEXT 列分离到单独的扩展表中
如果一定要使用,建议把 BLOB 或是 TEXT 列分离到单独的扩展表中,查询时一定不要使用 select * 而只需要取出必要的列,不需要 TEXT 列的数据时不要对该列进行查询。
b、TEXT 或 BLOB 类型只能使用前缀索引
4. 尽可能把所有列定义为 NOT NULL
索引 NULL 列需要额外的空间来保存,所以要占用更多的空间 进行比较和计算时要对 NULL 值做特别的处理
5. 使用 TIMESTAMP(4 个字节) 或 DATETIME 类型 (8 个字节) 存储时间
6. 同财务相关的金额类数据必须使用 decimal 类型
占用空间由定义的宽度决定,每 4 个字节可以存储 9 位数字,并且小数点要占用一个字节 可用于存储比 bigint 更大的整型数据
索引规范
1. 限制每张表上的索引数量,建议单张表索引不超过 5 个
2. 禁止给表中的每一列都建立单独的索引
3. 每个 Innodb 表必须有个主键
Innodb 是一种索引组织表:数据的存储的逻辑顺序和索引的顺序是相同的。每个表都可以有多个索引,但是表的存储顺序只能有一种。 Innodb 是按照主键索引的顺序来组织表的
5.如何选择索引列的顺序
建立索引的目的是:希望通过索引进行数据查找,减少随机 IO,增加查询性能 ,索引能过滤出越少的数据,则从磁盘中读入的数据也就越少。
•尽量把字段长度小的列放在联合索引的最左侧(因为字段长度越小,一页能存储的数据量越大,IO 性能也就越好)
7. 对于频繁的查询优先考虑使用覆盖索引
覆盖索引:就是包含了所有查询字段 (where,select,ordery by,group by 包含的字段) 的索引
•避免 Innodb 表进行索引的二次查询: Innodb 是以聚集索引的顺序来存储的,对于 Innodb 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询 ,减少了 IO 操作,提升了查询效率。
•可以把随机 IO 变成顺序 IO 加快查询效率: 由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。
SQL开发规范
1. 建议使用预编译语句进行数据库操作
只传参数,比传递 SQL 语句更高效。
2. 避免数据类型的隐式转换
隐式转换会导致索引失效如: select name,phone from customer where id = '111';
3. 充分利用表上已经存在的索引
4. 数据库设计时,应该要对以后扩展进行考虑
5. 程序连接不同的数据库使用不同的账号,进制跨库查询
6. 禁止使用 SELECT * 必须使用 SELECT <字段列表> 查询
•消耗更多的 CPU 和 IO 以网络带宽资源 •无法使用覆盖索引 •可减少表结构变更带来的影响
8. 避免使用子查询,可以把子查询优化为 join 操作
9. 避免使用 JOIN 关联太多的表
对于 MySQL 来说,是存在关联缓存的,缓存的大小可以由 join_buffer_size 参数进行设置。 在 MySQL 中,对于同一个 SQL 多关联(join)一个表,就会多分配一个关联缓存,如果在一个 SQL 中关联的表越多,所占用的内存也就越大。
10. 减少同数据库的交互次数
13. WHERE 从句中禁止对列进行函数转换和计算
15. 拆分复杂的大 SQL 为多个小 SQL
•大 SQL 逻辑上比较复杂,需要占用大量 CPU 进行计算的 SQL •MySQL 中,一个 SQL 只能使用一个 CPU 进行计算 •SQL 拆分后可以通过并行执行来提高处理效率
索引是什么
帮助DB高效获取数据的排好序的数据结构
数据库与索引的关系
锁与事务
描述:
锁分类
从性能分
乐观锁(用版本对比来实现)、悲观锁
从数据库操作的类型分
读锁、写锁(悲观锁)
读锁
场景:一般用来做数据迁移,不能被修改操作
lock table test read; unlock tables;
读锁会阻塞写,但是不会阻塞读。
写锁
lock tables test write; unlock tables;
写锁则会把读和写都阻塞
从对数据操作的粒度分
表锁、行锁
表锁
MySQL中锁定 粒度最大 的一种锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。其锁定粒度最大,触发锁冲突的概率最高,并发度最低,MyISAM和 InnoDB引擎都支持表级锁。
MyISAM
在执行查询语句前,会自动给涉及的所有表加读锁,在执行增删改操作前,会自动给涉及的表加定锁
行锁
MySQL中锁定 粒度最小 的一种锁,只针对当前操作的行进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。
InnoDB
三锁(InnoDB)
行锁
Record lock:单个行记录上的锁
select * from test where id=2 for update;
间隙锁
Gap lock:间隙锁,锁定一个范围,不包括记录本身
update test set id=1 where id i>1 and i <6; 解决幻读问题 insert into test values(2); 会被阻,因为上间隙锁了
范围
Next-key lock:record+gap 锁定一个范围,包含记录本身
事务
四大特性
原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的; 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的; 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
并发事务处理带来的问题
丢失修改(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1,事务2也修改A=A-1,最终结果A=19,事务1的修改被丢失。
丢失修改 多个操作同一条记录,导制数据被修改
脏读(Dirty read): 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
脏读 2个事务,事务A读取了事务B未提交的数据
不可重复读(Unrepeatableread): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
不可重复读 不符合事务隔离性,原因是一个事务内两次读取的同一数据不一样
幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。
幻读 读取了另一个事务新增的数据
四个隔离级别
可以帮我们解决脏读、不可重复读、幻读 --------- InnoDB默认是可重复读,无法解决幻读 select @@tx_isolation;
READ-UNCOMMITTED(读取未提交): 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。 READ-COMMITTED(读取已提交): 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。 REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 SERIALIZABLE(可串行化): 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
幻读问题,通过间隙锁(Gap lock)解决幻读问题。
update test set id=1 where id i>1 and i <6; 解决幻读问题 insert into test values(2); 会被阻,因为上间隙锁了
MVCC机制
查询时用的快照,但更新时用的最新的数据
相关知识点
innodb对于行的查询使用next-key lock Next-locking keying为了解决Phantom Problem幻读问题 当查询的索引含有唯一属性时,将next-key lock降级为record key Gap锁设计的目的是为了阻止多个事务将记录插入到同一范围内,而这会导致幻读问题的产生 有两种方式显式关闭gap锁:(除了外键约束和唯一性检查外,其余情况仅使用record lock) A. 将事务隔离级别设置为RC B. 将参数innodb_locks_unsafe_for_binlog设置为1
缓存
Redis
描述:Redis 是完全开源免费的,遵守BSD协议,是一个高性能的key-value数据库 可用于缓存、事件发布或订阅、高速队列等场景。该数据库使用ANSI C语言编写,支持网络,提供字符串、哈希、列表、队列、集合结构直接存取,基于内存,可持久化,支持多种开发语言。
优点: 1.速度快(ns级别),因为数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1) 2.支持丰富数据类型,支持string,list,set,sorted set,hash 3.支持事务,操作都是原子性,所谓的原子性就是对数据的更改要么全部执行,要么全部不执行 4.丰富的特性:可用于缓存,消息,按key设置过期时间,过期后将会自动删除
核心原理
单进程单线程
Redis 是单进程单线程的,redis 利用队列技术将并发访问变为串行访问,消除了传统数据库串行控制的开销。
为什么快: 1. 纯内存操作,避免大量访问数据库,减少直接读取磁盘数据。 2. 单线程避免多线程切换性能损耗,多个并发客户端通过队列技术变为串行访问。 3. IO多路复用,redis利用epoll来实现IO多路复用,使用了单线程来轮询描述符,将数据库的开、关、读、写都转换成了事件,减少了线程切换时上下文的切换和竞争。将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器 4.Redis避免了多线程的锁的消耗。 5.Redis采用自己实现的事件分离器,效率比较高,内部采用非阻塞的执行方式,吞吐能力比较大。
多路复用原理
1. 用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。 2. 当数据到达时,socket被激活,select函数返回。 3. 用户线程正式发起read请求,读取数据并继续执行。 这样用户可以注册多个socket,然后不断地调用select读取被激活的socket,redis服务端将这些socke置于队列中,然后,文件事件分派器,依次去队列中取,转发到不同的事件处理器中,提高读取效率。
采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作,从而提高效率。
操作:keys少用,会占更多CPU执行时间,让其他连接也有处理的可能
特点
1.单线程,利用redis队列技术并将访问变为串行访问,消除了传统数据库串行控制的开销 2.redis具有快速和持久化的特征,速度快,因为数据存在内存中。 3.分布式 读写分离模式 4.支持丰富数据类型 5.支持事务,操作都是原子性,所谓原子性就是对数据的更改要么全部执行,要不全部不执行。 6.可用于缓存,消息,按key设置过期时间,过期后自动删除
数据结构
动态字符串 (SDS)
len: 表示字符串的真正长度(不包含NULL结束符在内)。 alloc: 表示字符串的最大容量(不包含最后多余的那个字节)。 flags: 总是占用一个字节。其中的最低3个bit用来表示header的类型。 buf: 字符数组。
空间预分配
SDS 的结构可以减少修改字符串时带来的内存重分配的次数,这依赖于内存预分配和惰性空间释放两大机制。
1. 如果修改后, SDS 的长度(也就是len属性的值)将小于 1MB ,那么 Redis 预分配和 len 属性相同大小的未使用空间。 2. 如果修改后, SDS 的长度将大于 1MB ,那么 Redis 会分配 1MB 的未使用空间。
惰性空间释放
当 SDS 缩短其保存的字符串长度时,并不会立即释放多出来的字节,而是等待之后使用。
链表
列表对象的底层实现之一就是链表。除了链表对象外,发布和订阅、慢查询、监视器等功能也用到了链表。
双向链表
Redis 的链表结构的dup 、 free 和 match 成员属性是用于实现多态链表所需的类型特定函数: dup 函数用于复制链表节点所保存的值,用于深度拷贝。 free 函数用于释放链表节点所保存的值。 match 函数则用于对比链表节点所保存的值和另一个输入值是否相等。
字典
字典被广泛用于实现 Redis 的各种功能,包括键空间和哈希对象。
Redis 使用 MurmurHash2 算法来计算键的哈希值,并且使用链地址法来解决键冲突,被分配到同一个索引的多个键值对会连接成一个单向链表。
跳跃表
Redis 使用跳跃表作为有序集合对象的底层实现之一。它以有序的方式在层次化的链表中保存元素, 效率和平衡树媲美 —— 查找、删除、添加等操作都可以在对数期望时间下完成, 并且比起平衡树来说, 跳跃表的实现要简单直观得多。
1. zskiplistNode 是跳跃表的节点,其 ele 是保存的元素值,score 是分值,节点按照其 score 值进行有序排列,而 level 数组就是其所谓的层次化链表的体现。 2. 每个 node 的 level 数组大小都不同, level 数组中的值是指向下一个 node 的指针和 跨度值 (span),跨度值是两个节点的score的差值。越高层的 level 数组值的跨度值就越大,底层的 level 数组值的跨度值越小。 3. level 数组就像是不同刻度的尺子。度量长度时,先用大刻度估计范围,再不断地用缩小刻度,进行精确逼近。 4. 当在跳跃表中查询一个元素值时,都先从第一个节点的最顶层的 level 开始。比如说,在上图的跳表中查询 o2 元素时,先从o1 的节点开始,因为 zskiplist 的 header 指针指向它。 5. 先从其 level[3] 开始查询,发现其跨度是 2,o1 节点的 score 是1.0,所以加起来为 3.0,大于 o2 的 score 值2.0。所以,我们可以知道 o2 节点在 o1 和 o3 节点之间。这时,就改用小刻度的尺子了。就用level[1]的指针,顺利找到 o2 节点。
1. 表头:是链表的哨兵节点,不记录主体数据。 2. 是个双向链表 3. 分值是有顺序的 4. o1、o2、o3是节点所保存的成员,是一个指针,可以指向一个SDS值。 5. 层级高度最高是32。每次创建一个新的节点的时候,程序都会随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是“高度”
整数集合 (intset)
整数集合 intset 是集合对象的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时, Redis 就会使用整数集合作为集合对象的底层实现。
整数集合的 encoding 表示它的类型,有int16t,int32t 或者int64_t。其每个元素都是 contents 数组的一个数组项,各个项在数组中按值的大小从小到大有序的排列,并且数组中不包含任何重复项。 length 属性就是整数集合包含的元素数量。
压缩列表 (ziplist)
压缩队列 ziplist 是列表对象和哈希对象的底层实现之一。当满足一定条件时,列表对象和哈希对象都会以压缩队列为底层实现。
压缩队列是 Redis 为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。它的属性值有: zlbytes : 长度为 4 字节,记录整个压缩数组的内存字节数。 zltail : 长度为 4 字节,记录压缩队列表尾节点距离压缩队列的起始地址有多少字节,通过该属性可以直接确定尾节点的地址。 zllen : 长度为 2 字节,包含的节点数。当属性值小于 INT16_MAX时,该值就是节点总数,否则需要遍历整个队列才能确定总数。 zlend : 长度为 1 字节,特殊值,用于标记压缩队列的末端。
元素遍历
1. zlbytes地址+zltail字节数=尾部元素 2. 然后再根据ziplist节点元素中的 previous_entry_length属性,来逐个遍历:
中间每个节点 entry 由三部分组成: previous_entry_length : 压缩列表中前一个节点的长度,和当前的地址进行指针运算,计算出前一个节点的起始地址。 encoding: 节点保存数据的类型和长度 content :节点值,可以为一个字节数组或者整数。
对象
Redis基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合这五种类型的对象
其中 type 是对象类型,包括REDIS_STRING, REDIS_LIST, REDIS_HASH, REDIS_SET 和 REDIS_ZSET。
encoding是指对象使用的数据结构,全集如图
支持多种数据类型
String(字符串)
String数据结构是简单的key-value类型,value其实不仅可以是String,也可以是数字。 常规key-value缓存应用;
二进制安全,可以存任何数据,比如序列化的图片。最大长度位512M.
如果一个字符串对象保存的是一个字符串值,并且长度大于32字节,那么该字符串对象将使用 SDS 进行保存,并将对象的编码设置为 raw,如图的上半部分所示。如果字符串的长度小于32字节,那么字符串对象将使用embstr 编码方式来保存。
embstr 编码是专门用于保存短字符串的一种优化编码方式,这个编码的组成和 raw 编码一致,都使用 redisObject 结构和 sdshdr 结构来保存字符串
但是 raw 编码会调用两次内存分配来分别创建上述两个结构,而 embstr 则通过一次内存分配来分配一块连续的空间,空间中一次包含两个结构。
embstr 只需一次内存分配,而且在同一块连续的内存中,更好的利用缓存带来的优势,但是 embstr 是只读的,不能进行修改,当一个 embstr 编码的字符串对象进行 append 操作时, redis 会现将其转变为 raw 编码再进行操作。
操作:set key value
setex key 5 value1 //等价于 set +expire setnx key value1 //key存在则set失败
原子计数器
set n 1 incr n //+1
也可以做分布式锁:多个客户端,都执行incr操作,第一个是1,第二个是2等
getset key value1
为key设置一个值返回原值
场景
单值缓存
Set Key Value Get Key
对象缓存
1.Set user:1 value (json格式数据) 2.MSet user:1:name guajia use:1:balance 1888 MGet user1:name user:1:balance
分布式锁
下单减库存
SETNX product:10001 true //返回1代表获取锁成功 返回0代表获取锁失败---》 执行业务操作
SET product:10001 true ex 10 nx //防止程序意外终止而导致死锁
分布式运用
计数器
INCR article:readcount:{文章ID} GET article:read:count:{文章ID}
Web集群session共享
spring session + redis实现session共享
分布式系统全局序列号
INCRBY orderId 10000
web 离线分析
统计:任意时间窗口内 用户的登录次数
SETBIT sean 49 1
jd:618 11送礼 备货 2E 活跃用户
OA 权限 linux rwx int4
自己就有布隆过滤器
Hash(散列)
Redis hash是一个string类型的field和value的映射表,hash特别适合用于存储对象。 存储部分变更的数据,如用户信息等。 Redis 中的字典相当于 Java 中的 HashMap,内部实现也差不多类似,都是通过 "数组 + 链表" 的链地址法来解决部分 哈希冲突,同时这样的结构也吸收了两种不同数据结构的优点。源码定义如 dict.h/dictht 定义:
哈希对象的编码可以使用 ziplist 或 dict
table 属性是一个数组,数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针,而每个 dictEntry 结构保存着一个键值对:
typedef struct dictEntry { // 键 void *key; // 值 union { void *val; uint64_t u64; int64_t s64; double d; } v; // 指向下个哈希表节点,形成链表 struct dictEntry *next; } dictEntry;
当哈希对象使用压缩队列作为底层实现时,程序将键值对紧挨着插入到压缩队列中,保存键的节点在前,保存值的节点在后。如下图的上半部分所示,该哈希有两个键值对,分别是 name:Tom 和 age:25。
当哈希对象可以同时满足以下两个条件时,哈希对象使用 ziplist 编码: 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节。 哈希对象保存的键值对数量小于512个。 不能满足这两个条件的哈希对象需要使用 dict 编码或者转换为 dict 编码。
操作
> HSET books java "think in java" # 命令行的字符串如果包含空格则需要使用引号包裹 (integer) 1 > HSET books python "python cookbook" (integer) 1 > HGETALL books # key 和 value 间隔出现 1) "java" 2) "think in java" 3) "python" 4) "python cookbook" > HGET books java "think in java" > HSET books java "head first java" (integer) 0 # 因为是更新操作,所以返回 0 > HMSET books java "effetive java" python "learning python" # 批量操作 OK
场景
商品详情:商品名称,规格,价格等 聚合场景 1. hset cart:{用户ID} {商品ID} {数量}:加入购物车的商品使用 hset cart:1001 10088 1, cart代表的购物车, :1001 这里代表的是用户id,后面的10088 代表的是商品id。 2. 商品数量:hlen cart:1001,所有的加购设置key 和field的格式是一样的 不然查出来的数量肯定不对 3. 删除商品:hdel cart:1001 10088
hash的优点 缺点
优点
1.同类数据归类整合储存,方便数据管理 2.相比string操作消耗内存与cpu更小 3.相比string储存更节省空间
缺点
1.过期功能不能使用在field上,只能用在key上 2.Redis集群架构下不适合大规模使用
List(列表)
Redis的list是每个子元素都是String类型的双向链表,可以通过push和pop操作从列表的头部或者尾部添加或者删除元素,这样List即可以作为栈,也可以作为队列。 Redis 的列表相当于 Java 语言中的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。 注:是链表
列表对象的编码可以是 ziplist 或 linkedlist
当列表对象可以同时满足以下两个条件时,列表对象使用 ziplist 编码: 列表对象保存的所有字符串元素的长度都小于 64 字节。 列表对象保存的元素数量数量小于 512 个。 不能满足这两个条件的列表对象需要使用 linkedlist 编码或者转换为 linkedlist 编码。
操作
LPUSH 和 RPUSH 分别可以向 list 的左边(头部)和右边(尾部)添加一个新元素; LRANGE 命令可以从 list 中取出一定范围的元素; LINDEX 命令可以从 list 中取出指定下表的元素,相当于 Java 链表操作中的 get(int index) 操作; //扫描KEY集合 scan 渐进式遍历键 //遍历完游标返回0 scan 0 match key99* count 1000 // 游标(hash),正则, 查询数量 lrange命令,可实现分页,可以做类似微博那种下拉不断分页的功能。
队列,先进先出的结构,用于消息队列和异步逻辑处理 RPUSH books python java golang LPOP books
栈:先进后出结构,和队列相反 RPUSH books python java golang RPOP books
场景
栈: LPUSH +LPOP = > 放进去的数据放在左边 导致最后放进去的元素处于栈顶 最先的元素是处于栈底 使用LPOP 取值【或称移除值】是先从最左侧【栈顶】取值的 符合 先进后出的规则 【FILO】 队列: 与上面相反 取值时是使用RPOP 是 移除值是从最右侧开始的 所有最后进入的会被取出 符合 队列的先进先出的规则【FIFO】 **BLOCKIng MQ(阻塞队列) **: = LPUSH +BRPOP [这个就是一个消息队列 ,消息队列中有个发送者 和 接受者 ] BRPOP 就是从key列表尾弹出一个元素,如果列表中没有元素,就会一直处于阻塞等待多少秒,后面又会循环地执行 直到取到元素为止
放入顺序、栈、队列、数组、ltrim删除数据
数据共享、迁出
微博和公众号的消息
如微博你关注了1000个大V 每个大V 一天放两条数据 ,有1亿用户 。那么数据量有多大。可能有几百M的数据。 如果使用数据库 查询效率那就不是很高了 比如 你关注了小明和小红。 小明发了一条消息: 使用 LPUSH msg:小明Id 消息Id 小红发了一条消息: 使用 LPUSH msg:小红Id 消息Id 查看最新的微博消息: 使用LRANGE msg:小红Id 0 4 这个就是从左侧取下标是0到4的消息 意味着是取小红发的最新的5条消息的消息ID 进而从缓存里面取出对应的消息内容
Sets(集合)
set就是一个集合,集合的概念就是一堆不重复值的组合。利用Redis提供的set数据结构,可以存储一些集合性的数据。set中的元素是没有顺序的。 set轻易实现交集、并集、差集 场景:一个用户所有的关注人存在一个集合中。共同关注、共同粉丝、共同喜好等,也是求交集的过程。 sinterstore key1,key2,key3
String类型的无序集合,内部实现是一个 value永远为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员是否在集合内的原因。
集合对象的编码可以使用 intset 或者 dict
intset 编码的集合对象使用整数集合最为底层实现,所有元素都被保存在整数集合里边。 而使用 dict 进行编码时,字典的每一个键都是一个字符串对象,每个字符串对象就是一个集合元素,而字典的值全部都被设置为NULL
当集合对象可以同时满足以下两个条件时,对象使用 intset 编码: 集合对象保存的所有元素都是整数值。 集合对象保存的元素数量不超过512个。 否则使用 dict 进行编码。
操作
SADD books java SMEMBERS books # 注意顺序,set 是无序的 SISMEMBER books java # 查询某个 value 是否存在,相当于 contain SCARD books # 获取长度 SPOP books # 弹出一个
场景
集合: 去重、无序、交集、并集 //-------- 随机事件 共同好友:交集 推荐好友:差集 并集
微信抽奖
1. 点击参与抽奖加入集合 SADD key {userID} 2. 查看参与抽奖所有用户 SMEMBERS key 3.抽取count名中奖者 SRANDMEMBER key [count] / SPOP key [count]
方式一:DMEMBER key [count] 方式二: SPOP key [count] 方式一和方式二的运用常见是 方式一 只有中奖单一 没有多次抽奖和设置奖品等级。因为方式一 每次执行不会把抽取的数据删掉,后面执行还可能会抽取到原来的用户
微信微博关注模型
SDIFF set1 set2 set3 是以 set1为基准 秋 与set2和set3的并集 的差集 [得到a是set2和set3的并集中所没有的】 关注模型: 1.你关注的人 set guanzhu:我的id {张三、李四、王五、小明、程咬金} 2.小明关注的人 set guanzhu:小明的id {张三、赵六、尼古拉斯} 3.程咬金关注的人 set guanzhu:程咬金的id {小明、李四} 4.我和小明的共同关注: SINTER guanzhu:我的id guanzhu:小明的id 得到就是 张三 5.我关注的人也在关注他 【我关注的某人 否也请关注小明】 SISMEMBER guanzhu:程咬金的id 小明的ID SISMEMBER guanzhu:张三的id 小明的ID SISMEMBER //判断 member 元素是否是集合 key 的成员 6.我可能认识的人 SDIFF guanzhu:小明的id 我的ID
Sorted set(有序集合)
每个元素会关联一个double类型的score,然后根据score进行排序。注意:元素不能重复,但是score是可以重复的。使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score. 这可能使 Redis 最具特色的一个数据结构了,它类似于 Java 中 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重。 它的内部实现用的是一种叫做 「跳跃表」 的数据结构 场景:直播中,实时排行信息,包含直播间在线用户列表,各种礼物排行榜等。
有序集合的编码可以为 ziplist 或者 skiplist。
想象你是一家创业公司的老板,刚开始只有几个人,大家都平起平坐。后来随着公司的发展,人数越来越多,团队沟通成本逐渐增加,渐渐地引入了组长制,对团队进行划分,于是有一些人又是员工又有组长的身份。 再后来,公司规模进一步扩大,公司需要再进入一个层级:部门。于是每个部门又会从组长中推举一位选出部长。 跳跃表就类似于这样的机制,最下面一层所有的元素都会串起来,都是员工,然后每隔几个元素就会挑选出一个代表,再把这几个代表使用另外一级指针串起来。然后再在这些代表里面挑出二级代表,再串起来。最终形成了一个金字塔的结构。 想一下你目前所在的地理位置:亚洲 > 中国 > 某省 > 某市 > ....,就是这样一个结构!
有序集合使用 ziplist 编码时,每个集合元素使用两个紧挨在一起的压缩列表节点表示,前一个节点是元素的值,第二个节点是元素的分值,也就是排序比较的数值。 压缩列表内的集合元素按照分值从小到大进行排序,如图上半部分所示。 有序集合使用 skiplist 编码时使用 zset 结构作为底层实现,一个 zet 结构同时包含一个字典和一个跳跃表。 其中,跳跃表按照分值从小到大保存所有元素,每个跳跃表节点保存一个元素,其score值是元素的分值。而字典则创建一个一个从成员到分值的映射,字典的键是集合成员的值,字典的值是集合成员的分值。通过字典可以在O(1)复杂度查找给定成员的分值。如图所示。 跳跃表和字典中的集合元素值对象都是共享的,所以不会额外消耗内存。
当有序集合对象可以同时满足以下两个条件时,对象使用 ziplist 编码: 有序集合保存的元素数量少于128个; 有序集合保存的所有元素的长度都小于64字节。 否则使用 skiplist 编码。
操作
ZADD books 9.0 "think in java" ZADD books 8.9 "java concurrency" ZADD books 8.6 "java cookbook" > ZRANGE books 0 -1 # 按 score 排序列出,参数区间为排名范围 > ZREVRANGE books 0 -1 # 按 score 逆序列出,参数区间为排名范围 > ZSCORE books "java concurrency" # 获取指定 value 的 score > ZRANK books "java concurrency" # 排名 > ZRANGEBYSCORE books 0 8.91 # 根据分值区间遍历 zset > ZRANGEBYSCORE books -inf 8.91 withscores # 根据分值区间 (-∞, 8.91] 遍历 zset,同时返回分值。inf 代表 infinite,无穷大的意思。 > ZREM books "java concurrency" # 删除 value
Pub/Sub
在Redis中,你可以设定对某一个key值进行消息发布及消息订阅,当一个key值上进行了消息发布后,所有订阅它的客户端都会收到相应的消息。
数据库键空间
键空间的键也就是数据库的键,每个键都是一个字符串对象,而值对象可能为字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的一种对象。 除了键空间,Redis 也使用 dict 结构来保存键的过期时间,其键是键空间中的键值,而值是过期时间,如上图所示。 通过过期字典,Redis 可以直接判断一个键是否过期,首先查看该键是否存在于过期字典,如果存在,则比较该键的过期时间和当前服务器时间戳,如果大于,则该键过期,否则未过期。
持久化
RDB-Redis DataBase snapshotting
优点
1、只有一个文件 dump.rdb,方便持久化。 2、容灾性好,一个文件可以保存到安全的磁盘。 3、性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以是 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 redis 的高性能 4、相对于数据集大时,比 AOF 的启动效率更高
缺点
1、数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 redis 发生 故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候
原理是将Reids在内存中的数据库记录定时dump到磁盘上的RDB持久化。
指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。
AOF-Append Only File
优点
1、数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次 命令操作就记录到 aof 文件中一次。 2、通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。 3、AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令 进行合并重写),可以删除其中的某些命令(比如误操作的 flushall))
缺点
1、AOF 文件比 RDB 文件大,且恢复速度慢。 2、数据集大的时候,比 rdb 启动效率低。
描述: 与快照持久化相比,AOF持久化的实时性更好,因此已成为主流的持久化方案。 将redis执行过的所有写指令记录下来放入文件,在下次redis重新启动时,只要把这些写指令从前到后再重复执行一遍,就可以实现数据恢复了。
参数开启: appendonly yes 持久化方式: appendsync always # 每次有数据发声变化时都会写入AOF文件。 appendsync everysec # 每秒钟同步一次,显示地将多个写命令同步到硬盘 appendfsync no #让操作系统决定何时进行同步
文件名:appendonly.aof
混合持久化
RDB是二进制文件,恢复快 AOF存放的是增量信息。 AOF重写的时候直接把RDB的内容写到AOF文件开头。 AOF重写可以产生一个新的AOF文件,,这个新的AOF文件和原有的AOF文件所保存的数据库状态一样,但体积更小。
参数开启:aof-use-rdb-preamble
子主题
适用场景
会话缓存-Session Cache
用户登陆信息,Redis将用户的Session进行集中管理,每次用户更新或查询登陆信息都直接从Redis中集中获取。
队列
提供 list 和 set 操作,这使得Redis能作为一个很好的消息队列平台来使用
全页缓存-FPC
如果你使用的是服务器端内容渲染,你又不想为每个请求重新渲染每个页面,就可以使用 Redis 把常被请求的内容缓存起来,能够大大的降低页面请求的延迟。
Magento提供一个插件来使用Redis作为全页缓存后端
排行榜
Redis在内存中对数字进行递增或递减的操作实现的非常好 集合(Set)和有序集合(Sorted Set)也使得我们在执行这些操作的时候变的非常简单,Redis只是正好提供了这两种数据结构
消息队列
例如 email 的发送队列、等待被其他应用消费的数据队列,Redis 可以轻松而自然的创建出一个高效的队列。
发布/订阅
pub/sub 是 Redis 内置的一个非常强大的特性,例如可以创建一个实时的聊天系统、社交网络中的通知触发器等等。
高可用策略
高可用
当一台服务器停止服务后,对于业务及用户毫无影响。
主备方式
通常是一台主机、一台或多台备机,在正常情况下主机对外提供服务,并把数据同步到备机,当主机宕机后,备机立刻开始服务。 Redis HA中使用比较多的是keepalived,它使主机备机对外提供同一个虚拟IP,客户端通过虚拟IP进行数据操作,正常期间主机一直对外提供服务,宕机后VIP自动漂移到备机上。
主从方式
采取一主多从的办法,主从之间进行数据同步。 当Master宕机后,通过选举算法(Paxos、Raft)从slave中选举出新Master继续对外提供服务,主机恢复后以slave的身份重新加入。 主从另一个目的是进行读写分离,这是当单机读写压力过高的一种通用型解决方案。 其主机的角色只提供写操作或少量的读,把多余读请求通过负载均衡算法分流到单个或多个slave服务器上。
主从同步采用异步方式
哨兵集群
第一步,先访问哨兵节点,然后拿到主节点,访问主节点 作用:起到主节点挂了,重新选举一个主节点。 注:哨兵是一个程序。
子主题
方案选择
主备(keepalived)方案配置简单、人力成本小,在数据量少、压力小的情况下推荐使用。 如果数据量比较大,不希望过多浪费机器,还希望在宕机后,做一些自定义的措施,比如报警、记日志、数据迁移等操作,推荐使用主从方式,因为和主从搭配的一般还有个管理监控中心。
Redis的数据同步方式
同步方式
当主机收到客户端写操作后,以同步方式把数据同步到从机上,当从机也成功写入后,主机才返回给客户端成功,也称数据强一致性。 很显然这种方式性能会降低不少,当从机很多时,可以不用每台都同步,主机同步某一台从机后,从机再把数据分发同步到其他从机上,这样提高主机性能分担同步压力。 在redis中是支持这样配置的,一台master,一台slave,同时这台salve又作为其他slave的master
异步方式
主机接收到写操作后,直接返回成功,然后在后台用异步方式把数据同步到从机上。 这种同步性能比较好,但无法保证数据的完整性,比如在异步同步过程中主机突然宕机了,也称这种方式为数据弱一致性。
分布式与集群
Redis集群
缓存数据量不断增加时,单机内存不够使用,需要把数据切分不同部分,分布到多台服务器上。 可在客户端对数据进行分片,数据分片算法详见一致性Hash详解、虚拟桶分片。
分布式集群
当数据量持续增加时,应用可根据不同场景下的业务申请对应的分布式集群。 这块最关键的是缓存治理这块,其中最重要的部分是加入了代理服务。 应用通过代理访问真实的Redis服务器进行读写
手动搭建
子主题
子主题
访问集群
集群节点信息
子主题
优点: 避免越来越多的客户端直接访问Redis服务器难以管理,而造成风险。 在代理这一层可以做对应的安全措施,比如限流、授权、分片。 避免客户端越来越多的逻辑代码,不但臃肿升级还比较麻烦。 代理这层无状态的,可任意扩展节点,对于客户端来说,访问代理跟访问单机Redis一样。
原理
节点之间有心跳
分片:每个主节点都有一个slots[0-5460],第二个是[5461-10922],第三个是[10923-16383]
//设置值,如何存数据 set name liqiang 1. 先讲算hash,找到slot,则找到IP,将数据存入机器 2. 内部还有自己的数据结构
扩容
加入新的节点
命令:
添加节点,通知内部节点已加入新节点
分配slots 1. 要分多少 2. 要分到哪个节点上 3. 数据也会迁过去
选举原理
当slave发现自己的master变为FAIL状态时,便尝试进行Failover,以期成为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程, 其过程如下: 1.slave发现自己的master变为FAIL 2.将自己记录的集群currentEpoch加1,并广播FAILOVER_AUTH_REQUEST 信息 3.其他节点收到该信息,只有master响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个epoch只发送一次ack 4.尝试failover的slave收集FAILOVER_AUTH_ACK 5.超过半数后变成新Master 6.广播Pong通知其他集群节点。
谁的票数多谁是master
命令学习
./redis-cli --cluster help
问题
Redlock失效情况
1.时钟发生跳跃 2.长时间的GC pause 3.长时间的网络延迟
1. Redlock对系统时钟的要求并不需要完全精确,只要误差不超过一定范围不会产生影响,在实际环境中是完全合理的,通过 恰当的运维完全可以避免时钟发生大的跳动 。 2. 为锁增加一个 token-fencing,获取锁的时候,还需要获取一个递增的token,发生了上文的 FGC 问题后,Client 获取了 token=34 的锁。 在提交数据的时候,需要判断token的大小,如果token 小于 上一次提交的 token 数据就会被拒绝。
1、Master 最好不要写内存快照,如果 Master 写内存快照,save 命令调度 rdbSave 函数,会阻塞主线程的工作,当快照比较大时对性能影响是非常大的,会间断性 暂停服务 2、如果数据比较重要,某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一次 3、为了主从复制的速度和连接的稳定性,Master 和 Slave 最好在同一个局域网 4、尽量避免在压力很大的主库上增加从 5、主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <- Slave3...这样的结构方便解决单点故障问题,实现 Slave 对 Master 的替换。如果 Master 挂了,可以立刻启用 Slave1 做 Master,其他不变。
题
redis 过期键的删除策略?
1、定时删除:在设置键的过期时间的同时,创建一个定时器 timer). 让定时器在键的过期时间来临时,立即执行对键的删除操作。 2、惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。 3、定期删除:每隔一段时间程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定(可能随机删一部分)。
Redis 的回收策略(淘汰策略)?
volatile-lru: 从已设置过期时间的数据集(server.db[i].expires)中挑选最近最 少使用的数据淘汰 volatile-ttl: 从已设置过期时间的数据集(server.db[i].expires)中挑选将要过 期的数据淘汰 volatile-random: 从已设置过期时间的数据集(server.db[i].expires)中任意 选择数据淘汰 allkeys-lru: 从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰 allkeys-random: 从数据集(server.db[i].dict)中任意选择数据淘汰 no-enviction(驱逐): 禁止驱逐数据 注意这里的 6 种机制,volatile 和 allkeys 规定了是对已设置过期时间的数据集淘 汰数据还是从全部数据集淘汰数据,后面的 lru、ttl 以及 random 是三种不同的 淘汰策略,再加上一种 no-enviction 永不回收的策略。 使用策略规则: 1、如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率 低,则使用 allkeys-lru 2、如果数据呈现平等分布,也就是所有的数据访问频率都相同,则使用 allkeys-random
4.x版本
为什么redis 需要把所有数据放到内存中?
Redis 为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数 据写入磁盘。所以 redis 具有快速和数据持久化的特征。如果不将数据放在内存中, 磁盘 I/O 速度为严重影响 redis 的性能。在内存越来越便宜的今天,redis 将会越 来越受欢迎。如果设置了最大使用的内存,则数据已有记录数达到内存限值后不能继续插入新值。
Redis 的同步机制了解么?
Redis 可以使用主从同步,从从同步。第一次同步时,主节点做一次 bgsave, 并同时将后续修改操作记录到内存 buffer,待完成后将 rdb 文件全量同步到复制 节点,复制节点接受完成后将 rdb 镜像加载到内存。加载完成后,再通知主节点 将期间修改的操作记录同步到复制节点进行重放就完成了同步过程。
Pipeline 有什么好处,为什么要用 pipeline?
可以将多次 IO 往返的时间缩减为一次,前提是 pipeline 执行的指令之间没有 因果相关性。使用 redis-benchmark 进行压测的时候可以发现影响 redis 的 QPS 峰值的一个重要因素是 pipeline 批次指令的数目。
是否使用过 Redis 集群,集群的原理是什么?
1)、Redis Sentinal 着眼于高可用,在 master 宕机时会自动将 slave 提升为 master,继续提供服务。 2)、Redis Cluster 着眼于扩展性,在单个 redis 内存不足时,使用 Cluster 进行 分片存储。
Redis 集群方案什么情况下会导致整个集群不可用?
有 A,B,C 三个节点的集群,在没有复制模型的情况下,如果节点 B 失败了, 那么整个集群就会以为缺少 5501-11000 这个范围的槽而不可用。
说说 Redis 哈希槽的概念?
Redis 集群没有使用一致性 hash,而是引入了哈希槽的概念,Redis 集群有 16384 个哈希槽,每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽, 集群的每个节点负责一部分 hash 槽。
Redis 集群的主从复制模型是怎样的?
为了使在部分节点失败或者大部分节点无法通信的情况下集群仍然可用,所 以集群使用了主从复制模型,每个节点都会有 N-1 个复制品.
Redis 集群会有写操作丢失吗?为什么?
Redis 并不能保证数据的强一致性,这意味这在实际中集群在特定的条件下可 能会丢失写操作。
Redis 集群之间是如何复制的?
异步复制
Redis 集群最大节点个数是多少?
16384 个。
Redis 集群如何选择数据库?
Redis 集群目前无法做数据库选择,默认在 0 数据库。
怎么理解 Redis 事务?
1)事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。 事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。 2)事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。
有MULTI、EXEC、DISCARD、WATCH
Redis key 的过期时间和永久有效分别怎么设置?
EXPIRE 和 PERSIST 命令。
Redis 如何做内存优化?
尽可能使用散列表(hashes),散列表(是说散列表里面存储的数少)使用 的内存非常小,所以你应该尽可能的将你的数据模型抽象到一个散列表里面。比 如你的 web 系统中有一个用户对象,不要为这个用户的名称,姓氏,邮箱,密码 设置单独的 key,而是应该把这个用户的所有信息存储到一张散列表里面.
Redis 回收进程如何工作的?
一个客户端运行了新的命令,添加了新的数据。Redi 检查内存使用情况,如 果大于 maxmemory 的限制, 则根据设定好的策略进行回收。一个新的命令被执 行,等等。所以我们不断地穿越内存限制的边界,通过不断达到边界然后不断地 回收回到边界以下。如果一个命令的结果导致大量内存被使用(例如很大的集合 的交集保存到一个新的键),不用多久内存限制就会被这个内存使用量超越。
都有哪些办法可以降低 Redis 的内存使用情况呢?
如果你使用的是 32 位的 Redis 实例,可以好好利用 Hash,list,sorted set,set 等集合类型数据,因为通常情况下很多小的 Key-Value 可以用更紧凑的方式存放 到一起。
MySQL 里有 2000w 数据,redis 中只存 20w 的数据,如 何保证 redis 中的数据都是热点数据?
Redis 内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略。 相关知识:Redis 提供 6 种数据淘汰策略: 1. volatile-lru: 从已设置过期时间的数据集(server.db[i].expires)中挑选最近最 少使用的数据淘汰 2. volatile-ttl: 从已设置过期时间的数据集(server.db[i].expires)中挑选将要过 期的数据淘汰 3. volatile-random: 从已设置过期时间的数据集(server.db[i].expires)中任意 选择数据淘汰 4. allkeys-lru: 从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰 5. allkeys-random: 从数据集(server.db[i].dict)中任意选择数据淘汰 6. no-enviction(驱逐): 禁止驱逐数据
如果有大量的 key 需要设置同一时间过期,一般需要注意 什么?
如果大量的 key 过期时间设置的过于集中,到过期的那个时间点,redis 可能 会出现短暂的卡顿现象。一般需要在时间上加一个随机值,使得过期时间分散一 些。
使用过 Redis 做异步队列么,你是怎么用的?
一般使用 list 结构作为队列,rpush 生产消息,lpop 消费消息。当 lpop 没有 消息的时候,要适当 sleep 一会再重试。list 还有个指令叫 blpop,在没有消息的时候,它会阻塞住直到消息到来。如果对 方追问能不能生产一次消费多次呢?使用 pub/sub 主题订阅者模式,可以实现 1:N 的消息队列。
如果对方追问 redis 如何实现延时队列?
使用 sortedset,拿时间戳作为 score,消息内容作为 key 调用 zadd 来生产消息,消费者用 zrangebyscore 指令 获取 N 秒之前的数据轮询进行处理。
使用过 Redis 分布式锁么,它是什么回事?
先拿 setnx 来争抢锁,抢到之后,再用 expire 给锁加一个过期时间防止锁忘记了 释放。 这时候对方会告诉你说你回答得不错,然后接着问如果在 setnx 之后执行 expire 之前进程意外 crash 或者要重启维护了,那会怎么样? 这时候你要给予惊讶的反馈:唉,是喔,这个锁就永远得不到释放了。紧接着你 需要抓一抓自己得脑袋,故作思考片刻,好像接下来的结果是你主动思考出来的, 然后回答:我记得 set 指令有非常复杂的参数,这个应该是可以同时把 setnx 和 expire 合成一条指令来用的!对方这时会显露笑容,心里开始默念:摁,这小子 还不错。
缓存雪崩
描述:同一时间大面积的失效,后面的请求都会落到DB上,造成数据库短时间内承受大量请求而崩掉。
解决办法
事前:尽量保证整个redis集群的高可用性,发现机器宕机尽快补上。 事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL崩掉 事后:利用redis持久化机制保存的数据尽快恢复缓存。
缓存穿透
描述:大量请求的key根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。 (一般MySQL默认的最大连接数在150左右,可以通过show variables like '%max_connection%';查看。)
正常流程
穿透情况
解决办法
1. 校验参数合法性,如ID不能小于0、格式不对等 2. 缓存无效key:如果缓存和DB都查不到某个key,就写一个到redis中去并设置过期时间,命令:set key value EX 10066。尽量将无效过期时间设置短一点,如1分钟。 3. 布隆过滤器:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,查布隆过滤器,不存在,直接返回请求参数错误信息给用户,存在会走右图流程
布隆过滤器
布隆过滤器原理 当一个元素加入布隆过滤器中的时候,会进行如下操作: 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。 根据得到的哈希值,在位数组中把对应下标的值置为 1。 当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作: 对给定元素再次进行相同的哈希计算; 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
布隆过滤器hash计算
如何保证缓存与数据库双写时的数据一致性
Cache Aside Pattern
读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。 更新的时候,先更新数据库,然后再删除缓存。
最初级的缓存不一致问题及解决方案
先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中。
比较复杂的数据不一致问题分析
更新数据,根据数据唯一标识,放入JVM内部队列中。 这里可以做优化,一个队列中,其实多个更新缓存请求串在一起是没有意义的,因此可以过滤,如果发现队列中已经有一个更新缓存的请求了,那么就不用再放个更新请求操作进去了,直接等待前面的更新操作请求完成即可。 如果请求还在等待时间范围内,不断轮询发现可以取值了,那就就直接返回;如果请求等待时间超过一定时长(超时时长要根据实际业务估算或是压测计算),那么这一次直接从数据库中读取当前的旧值。
大数据
ES
概述
全文搜索引擎
工作原理
计算机索引程序通过 扫描 文章中的每一个词,对每一个词建立一个索引,指明该词在文章中出现的次数和位置,当用户查询时,检索程序就根据事先建立的索引进行查找,并将查找的结果反馈给用户。这个过程类似于通过字典中的检索字表查字的过程
什么是ES (全文搜索引擎)
整体架构
是一款开源的分布式、RESTful 风格的搜索和数据分析引擎,它底层基于 Apache Lucene 开源库进行封装,其不仅仅提供分布式多用户能力的全文搜索引擎
1、一个分布式的实时文档存储,每个字段可以被索引与搜索; 2、一个分布式实时分析搜索引擎; 3、能胜任上百个节点的扩展,并支持 PB 级别结构化和非结构化数据。
基本概念
节点&集群
分布式数据库,允许多台服务器协同工作,每台服务器可以运行多个 Elasticsearch 实例。 单个 Elasticsearch 实例称为一个节点(Node),一组节点构成一个集群(Cluster)。
映射关系
1. 一个索引就是一个拥有几分相似特征的文档的集合 2. 类型是索引内部的逻辑分区(7.x 版本默认使用 _doc 作为 type) 3. 文档是 Lucene 索引和搜索的 原子单位,它是包含了一个或多个域的容器,基于 Json 格式进行表示 4. Field 是相当于数据库中的 Column 5. Mapping是定义文档及其包含的字段如何存储和索引的过程。
index(索引)相当于数据库, type(类型)相当于一张表, document(文档)相当于一行数据, mapping(映射)对应数据字段上的类型/主键/非空等约束(Schema)信息 node(节点) 是ES集群中一台物理机或虚拟机 cluster集群,将个或多个节点组织在一起干一件大事 shard分片,将一份数据划分为多小份的能力,允许水平分割和扩展容量。多个分片可以影响请求,提高性能和吞吐量 copy(副本),复制数据,一个节点出问题时,其余节点可以顶上
分片&副本
什么是Shard(分片)
一个索引可以划分成多份,这些份就叫做分片
分片重要有几个原因
1.允许你水平分割/扩展你的内容容量 2. 允许你在分片(潜在地,位于多个节点上)之上进行分布式的、并行的操作,进而提高性能/吞吐量 3. 一个分片怎样分布,它的文档怎样聚合回搜索请求,是完全由 Elasticsearch 管理的,对于作为用户的你来说,这些都是透明的
什么是Replica(副本)
副本是一个分片的精确复制,每个分片可以有零个或多个副本
副本的作用
提高系统的容错性,当某个节点某个分片损坏或丢失时,可以从副本中恢复。 提高 ES 查询效率,ES 会自动对搜索请求进行负载均衡。
接口
RESTful
对外提供的 API 是以 HTTP 协议的方式,通过 JSON 格式以 REST 约定对外提供。
读写原理
写操作
索引新文档(Create)
当用户向一个节点提交了一个索引新文档的请求,节点会计算新文档应该加入到哪个分片(shard)中。 注:(segment,translog) 1. 会先写入到内存中,并将这一操作写入一个 translog 文件(transaction log)中。 2. ES会每隔1秒时间进行刷新操作,将这1秒的新文档写入到一个文件系统缓存中,并构成一个分段。(这个分段里的文档可以被搜索到,但未写入磁盘) 3. 不断重复执行,每隔一秒将生成一个新的 segment,而 translog 文件将越来越大 4. 每隔30分钟或translog文件变得很大,则执行一次fsync操作。此时所有在文件系统缓存中的 segment 将被写入磁盘,而 translog 将被删除(此后会生成新的 translog) 5. translog也是文件,每隔5秒也写入磁盘。 6. 合并segment就创建了一个新的segment
更新和删除文档
ES 的索引是不能修改的,因此更新和 删除 操作并不是直接在原索引上直接执行。 每一个磁盘上的 segment 都会维护一个 del 文件,用来记录被删除的文件。每当用户提出一个删除请求,文档并没有被真正删除,索引也没有发生改变,而是在 del 文件中标记该文档已被删除。因此被删除的文档依然可以被检索到,只是在返回检索结果时被过滤掉了。每次在启动 segment 合并工作时,那些被标记为删除的文档才会被真正删除。
查询阶段
当一个节点接收到一个搜索请求,则这个节点就变成了协调节点。
第一步是广播请求到索引中每一个节点的分片拷贝。 查询请求可以被某个主分片或某个副本分片处理,协调节点将在之后的请求中轮询所有的分片拷贝来分摊负载。 协调节点会将所有分片的结果汇总,并进行全局排序,得到最终的查询排序结果。此时查询阶段结束。
取回阶段
查询过程得到的是一个排序结果,标记出哪些文档是符合搜索要求的,此时仍然需要获取这些文档返回客户端。 协调节点会确定实际需要返回的文档,并向含有该文档的分片发送get请求;分片获取文档返回给协调节点;协调节点将结果返回给客户端。
ES是我们通常说的NoSQL数据库,也就是非关系型数据库
数据类型
字符串 text:可分词,支持模糊查询。如中华人民共和国,分词后中华 华人 共和国等 keyword:全字体匹配,如北京、天津
数值型
byte short long等
日期类型
format格式化
范围型
integer_range long_range
布尔boolean
二进制binary
复杂数据类型
专用数据类型IP
如何实现已有映射字段的修改调整
1. 创建一个全新的索引,映射包含调整后的字段或类型 #job #job2 2. 将原有索引下的数据迁移到新的索引 #job reindex -> #job2 3. 删除原有索引 #delete job 4. 将新的索引的别名设置原有索引相同名称 #job2 alias -> job
注意
ES前期字段设计很重要,尤其是对名称和类型进行规划,避免出现重命名的情况
Bulk API
批量导入接口Bulk
原理
Analysis分词
分词器
IK、默认分词器等
安装分词器
下载分词器ZIP文件elasticsearch-analysis-ik-5.6.12.zip,解压到ik目录,重启服务
正排索引
是另一种常见的索引方式,它按照文档的原始顺序存储文档的信息
用途: 1. 文档信息的直接访问:通过正排索引,可以快速地根据文档ID获取到文档的完整内容或特定字段的值。 2. 支持聚合操作:正排索引非常适合用于对文档进行分组、统计和聚合等操作。例如,可以统计某个字段的不同值的数量,或者计算某个字段的平均值等。 3. 结构化数据的查询:对于结构化数据(如数据库中的记录),正排索引可以高效地支持基于字段的查询和筛选。
doc1 nice doc1 to
通过文档ID查询文档内容
倒排索引
支持快速、高效的文本搜索
用途: 1. 快速检索:由于倒排索引将词条与文档直接关联,因此可以快速地找到包含特定词条的文档。 2. 支持布尔查询:倒排索引可以高效地处理包含AND、OR和NOT等布尔运算符的复杂查询。 3. 支持全文检索:通过结合词条的权重、文档的长度和查询的相关性评分算法,倒排索引可以实现对文本的深度分析和精确匹配。
文档编号:词频TF:出现位置POS:<偏移量OFFSET>
nice doc1:1:0<0,4>, ... to doc1:1:1:1:<5,7>
通过字段查询文档内容
文档排序
搜nice时快速获得doc1与doc2两个文档,由相关性算分,描述了一个文档和查询语句匹配的程度。ES会对每个匹配的查询条件的结果进行算分_score,分值越高的说明文档越符合预期,排名也就越靠前。
TF-IDF算法
Term Frequency 检索词在一篇文档中出现的频率
相关性分数=TF(区块链) * IDF(区块链) + TF(的) * IDF(的) + TF(应用) * IDF(应用)
Document Frequency
检索词在所有文档中出现的频率
Inverse Document Frequency
IDF=log(全部文档数/检索词出现过的文档总数) IDF的意义:IDF认为词条在总文档数中出现比率越低,该词更重要,这个词条权重也应越大
BM25算法
ES5后默认算法
索引、Mapping、文档
1. 索引操作
规范
· 以小写英文字母命名索引 · 如过出现多个单词的索引名称,以全小写 + 下划线分隔的方式:如test_index。
创建索引
格式: PUT /索引名称
PUT /es_db
注:ES 索引创建成功之后,以下属性将不可修改
查询索引
格式: GET /索引名称
GET /es_db
是否存在
HEAD /es_db
删除索引
DELETE /es_db
2. 设置Settings
创建索引时可以设置分片数和副本数
#创建索引es_db,指定其主分片数量为 3,每个主分片的副本数量为 2 PUT /es_db { "settings": { "number_of_shards": 3, "number_of_replicas": 2 } }
(1)静态索引设置:只能在创建索引时或在关闭状态的索引上设置。 - index.number_of_shards:索引的主分片的个数,默认为 1,此设置只能在创建索引时设置。 (2)动态索引设置:即可以使用 _setting API 在实时修改的配置项。 - index.number_of_replicas:每个主分片的副本数。默认为 1,允许配置为 0。 - index.refresh_interval:执行刷新操作的频率,默认为1s. 可以设置 -1 为禁用刷新。 - index.max_result_window:from + size 搜索此索引 的最大值,默认为 10000。
修改索引配置项
#修改索引配置,把每个主分片的副本数量修改为 1 PUT /es_db/_settings { "index" : { "number_of_replicas" : 1 } }
指定IK分词器作为默认分词器
PUT /es_db { "settings" : { "index" : { "analysis.analyzer.default.type": "ik_max_word" } } }
POST _analyze { "analyzer": "ik_max_word", "text": "万般都是命,半点不由人" }
3. 设置文档Mapping
#查看完整的索引 mapping GET /<index_name>/_mappings
#查看索引中指定字段的 mapping GET /<index_name>/_mappings/field/<field_name>
动态映射和静态映射
注:生产环境尽可能的避免使用 动态映射(dynamic mapping)
动态映射
Elasticsearch中不需要定义Mapping映射,在文档写入Elasticsearch时,会根据文档字段自动识别类型,这种机制称之为动态映射。
例
#删除原索引 DELETE /user #创建文档(ES根据数据类型, 会自动创建映射) PUT /user/_doc/1 { "name": "fox", "age": 32, "address":"长沙麓谷" } #获取文档映射 GET /user/_mapping
静态映射
静态映射也叫做显式映射,即:在索引文档写入之前,人为创建索引并且指定索引中每个字段类型、分词器等参数。
例
PUT /user { "settings": { "number_of_shards": "3", "number_of_replicas": "1" }, "mappings": { "properties": { "userId": { "type": "keyword" }, "userName" : { "type" : "text", "analyzer": "ik_max_word" }, "realName" : { "type" : "text", "analyzer": "ik_max_word" }, "mobile" : { "type" : "keyword" }, "age" : { "type" : "integer" }, "grades" : { "type" : "double" }, "createTime" : { "type" : "date", "format": "yyyy-MM-dd HH:mm:ss" } } } }
插入文档
#插入文档 PUT /user/_doc/3 { "userId":"1000", "userName":"lisi2", "realName":"李四2", "mobile":"18611661234", "age":22, "grades":1.10, "createTime":"2024-06-27 19:54:00" }
- index: 控制当前字段是否被索引,默认为true。如果设置为false,该字段不可被搜索
"address" : { "type" : "text", "index": false },
4. 使用ReIndex重建索引
具体方法: 1)如果要推倒现有的映射, 你得重新建立一个静态索引 2)然后把之前索引里的数据导入到新的索引里 3)删除原创建的索引 4)为新索引起个别名, 为原索引名
# 1. 重新建立一个静态索引 PUT /user2 { "mappings": { "properties": { "name": { "type": "text" }, "address": { "type": "text", "analyzer": "ik_max_word" } } } }
# 2. 把之前索引里的数据导入到新的索引里 POST _reindex { "source": { "index": "user" }, "dest": { "index": "user2" } }
# 3. 删除原创建的索引 DELETE /user # 4. 为新索引起个别名, 为原索引名 PUT /user2/_alias/user GET /user
文档操作
索引文档
- 格式: [PUT | POST] /索引名称/[_doc | _create ]/id
# 创建文档, 指定id # 如果id不存在,创建新的文档,否则先删除现有文档,再创建新的文档,版本会增加 PUT /es_db/_doc/1 { "name": "张三", "sex": 1, "age": 25, "address": "广州天河公园", "remark": "java developer" }
#创建文档,ES生成id POST /es_db/_doc { "name": "张三", "sex": 1, "age": 25, "address": "广州天河公园", "remark": "java developer" }
注
POST和PUT都能起到创建/更新的作用,PUT需要对一个具体的资源进行操作也就是要确定id才能进行更新/创建,而POST是可以针对整个资源集合进行操作的,如果不写id就由ES生成一个唯一id进行创建新文档,如果填了id那就针对这个id的文档进行创建/更新
查询文档
- 根据id查询文档,格式: GET /索引名称/_doc/id
GET /es_db/_doc/1
- 条件查询 _search,格式: /索引名称/_doc/_search
# 查询前10条文档 GET /es_db/_doc/_search
API提供两种查询搜索方式
· REST风格的请求URI,直接将参数带过去
#通过URI搜索,使用“q”指定查询字符串,“query string syntax” KV键值对 #条件查询, 如要查询age等于28岁的 _search?q=*:*** GET /es_db/_doc/_search?q=age:28 #范围查询, 如要查询age在25至26岁之间的 _search?q=***[** TO **] 注意: TO 必须为大写 GET /es_db/_doc/_search?q=age[25 TO 26] #查询年龄小于等于28岁的 :<= GET /es_db/_doc/_search?q=age:<=28 #查询年龄大于28前的 :> GET /es_db/_doc/_search?q=age:>28 #分页查询 from=*&size=* GET /es_db/_doc/_search?q=age[25 TO 26]&from=0&size=1 #对查询结果只输出某些字段 _source=字段,字段 GET /es_db/_doc/_search?_source=name,age #对查询结果排序 sort=字段:desc/asc GET /es_db/_doc/_search?sort=age:desc
· 封装到request body中,这种方式可以定义更加易读的JSON格式
DSL(Domain Specific Language领域专用语言)查询是使用Elasticsearch的查询语言来构建查询的方式。 # match 匹配查询,会对查询文本分词后匹配 GET /es_db/_search { "query": { "match": { "address": "广州白云" } } } # term 词项查询,属于精确查询,不会对查询文本分词 # 思考:能否查到文档? GET /es_db/_search { "query": { "term": { "address": "广州白云" } } }
修改文档
- 全量更新,整个json都会替换,格式: [PUT | POST] /索引名称/_doc/id
# 全量更新,替换整个json PUT /es_db/_doc/1 { "name": "张三", "sex": 1, "age": 25 } #查询文档 GET /es_db/_doc/1
- 使用_update部分更新,格式: POST /索引名称/_update/id
# 部分更新:在原有文档上更新 # Update -文档必须已经存在,更新只会对相应字段做增量修改 POST /es_db/_update/1 { "doc": { "age": 28 } } #查询文档 GET /es_db/_doc/1
- 使用 _update_by_query 更新文档:更新符合条件的文档
POST /es_db/_update_by_query { "query": { "match": { "_id": 1 } }, "script": { "source": "ctx._source.age = 30" } }
并发场景下修改文档
_seq_no和_primary_term是对_version的优化,7.X版本的ES默认使用这种方式控制版本,所以当在高并发环境下使用乐观锁机制修改文档时,要带上当前文档的_seq_no和_primary_term进行更新:
GET /es_db/_doc/1 POST /es_db/_doc/1?if_seq_no=6&if_primary_term=1 { "name": "李四xxx" }
删除文档
格式: DELETE /索引名称/_doc/id
DELETE /es_db/_doc/1
搜索
URI Request Search
http://localhost:9200/job/_search?q=springboot&df=description&sort=jid:desc&from=0&size=10&timeout=1s
单字段查询
GET http://localhost:9200/job/_search?q=description:spring
Phrase查询
按查询保证按分词的前后位置进行查询,需要在值前后增加引号 http://localhost:9200/job/_search?q=description:"spring boot" 结果中description字段包含spring ... boot会被选中,如包含 boot spring则不会被选中
Boolean查询
Request Body Search
选项
子主题
类型
精准
单值
多值匹配
全文检索
不匹配任何文档
单字段
单字段
简单写法
多字段
两个字段包含“金融”的数据
单字段Boolean
两个字段用 与 或
Phrase(短语)
slop设置为1表示中间可以有一个词
范围
子主题
正则表达式
数据建模相关
描述如何设计 Elasticsearch 索引以支持高效的全文搜索和聚合操作。
倒排索引以支持全文检索; 正排索引以支持聚合操作。
在数据建模过程中,你如何决定使用嵌套类型还是平面结构?
关于嵌套结构、平面结构——实践表明: 如果能平面宽表存储,咱们就宽表,空间换时间的方式是非常有效的数据建模方式; 除非特殊情况,当子文档更新不频繁的场景,推荐使用 Nested 类型; 子文档更新频繁的场景,推荐使用:Join 类型。
ES查询和分析相关问题
描述你如何优化复杂的 Elasticsearch 查询,以提高性能。 如果需要对大数据集进行实时分析,你会采取哪些策略?
1. 硬件资源层面,要给到位
1. 能 SSD 磁盘的,咱们优先SSD磁盘 2. CPU核数决定并发支持力度 3. 网络带宽
2. 数据建模层面
1. 字段类型是否设置合理 2. 多表关联要减少
3. 复杂检索考虑优化点
比如:wildcard 能否通过 ngram 分词修改检索方式?能不用,咱们尽量不用。 如果能用 filter 过滤提升缓存性能的,咱们是否用了? “profile:true”,看看哪个环节出问题了,咱们有针对性的进行优化。
4. 其他因素
检索的时候,是否有大量的写入操作?看有没有优化空间。 是否采取必要的段合并的策略,以优化检索。 其他业务场景细节有针对的调优。
Elasticsearch 索引数据同步相关问题
描述如何保证数据库和 Elasticsearch 索引间的数据同步
保证同步——我用 logstash 多,主要基于时间戳和自增id实现同步
你是如何处理批量索引和更新大量文档的?
批量索引——就是基于 bulk API 批量导入或者写入数据
分片数计算
50G一个分片,一天1.5T,分30个片,时间保留1天
性能调优和索引维护相关
如何处理 Elasticsearch 的索引碎片化
架构层面
单分片的最大值尽量控制在 30 GB- 50GB,过大了不便于维护,过小了性能会有影响。
段合并
不定期在非业务密集区域实现段合并,以保证性能优化。
有没有经验进行索引的映射迁移或重建?
看数据量大小: 如果数据量不大,直接 reindex 数据迁移; 如果数据量适中,使用 reindex + slice 的方式迁移; 如果数据过大,推荐 elasticdump(适合跨集群同步),索引快照和恢复的方式保障数据迁移的高可用性。 如果跨集群,其实也可以使用:reindex, 但是要配置白名单。 如果版本兼容,快照和恢复机制也是推荐的!
容错性和高可用性
你如何确保你开发的Elasticsearch应用具备高容错性?
高可用性的策略
副本策略
多节点集群至少一个副本,确保某个节点宕机后,副本提升为主分片,确保集群的高可用性。
当 Elasticsearch 集群不可用时,你的应用程序如何处理?
快照和恢复
集群的不定时快照和恢复策略,确保集群万一故障能恢复到某一个时刻的可用状态。
面视题
倒排索引
对文档内容分词,对词条创建索引,并记录词条所在文档的信息。查询时先根据词条查询到文档id,而后获取到文档,倒排索引是为了解决非结构化数据的检索问题而产生的
Elasticsearch 中的集群、节点、索引、文档、类型是什么
集群
多个节点(服务器)的集合,共同保存整个数据,提供跨所有节点的联合索引和搜索功能。
节点
集群中单个服务器。存储部分数据,提供索引和搜索功能。
索引(Index)
像关系型数据库,可以定义多个类型映射,可以有多个副本分片
类型(Mapping)
是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
文档(Document)
像关系型数据库中的一行,索引中的每个文档可以具有不同的结构(字段),但 是对于通用字段应该具有相同的数据类型
Elasticsearch => Indices => Types =>具有属性的文档
地址位置查询
可以查询圆、矩型和自定义边界框
写入数据流程
1. 选一个节点发过去,这个节点是协调节点 2. 对document进行路由,hash计算,算出要落到哪个分片上,将请求转发到对应的实际节点 3. 实际节点主分片处理请求,并同步副本节点 4. 主、副节点都执行成功,返回响应结果给客户端
选主流程
1. 配置多个参与选主节点 2. 所有参与选主的节点,启动后都会选自己为Master节点 3. 相互piing对方,节点ID最小的就成为Master节点
脑裂问题
1. 网络问题,出现多个Master 2. 设置一个阈值,当集群中参选的节点大于阈值,才进行选举 Quorum = (master 节点总数 /2)+ 1
与MongoDB
数据模型
mongoDB
主要特点
文档(一行)
MongoDB中的记录是一个文档,它是由字段和值对组成的数据结构。 注: 文档中的值不仅可以是双引号中的字符串,也可以是其他的数据类型,例如,整型、布尔型等,也可以是另外一个文档,即文档可以嵌套。文档中的键类型只能是字符串。
集合(表)
集合就是一组文档,类似于关系数据库中的表
集合是无模式的,集合中的文档可以是各式各样的
例如,{“hello,word”:“Mike”}和{“foo”: 3}
数据库
MongoDB 中多个文档组成集合,多个集合组成数据库。
一个MongoDB 实例可以承载多个数据库。它们之间可以看作相互独立,每个数据库都有独立的权限控制。在磁盘上,不同的数据库存放在不同的文件中。
存在3种系统数据库 1. Admin 数据库:一个权限数据库,如果创建用户的时候将该用户添加到admin 数据库中,那么该用户就自动继承了所有数据库的权限。 2. Local 数据库:这个数据库永远不会被复制,可以用来存储本地单台服务器的任意集合。 3. Config 数据库:当MongoDB 使用分片模式时,config 数据库在内部使用,用于保存分片的信息。
数据模型
一个MongoDB 实例可以包含一组数据库,一个DataBase 可以包含一组Collection(集合),一个集合可以包含一组Document(文档)。 一个Document包含一组field(字段),每一个字段都是一个key/value pair key: 必须为字符串类型 value:可以包含如下类型 基本类型,例如,string,int,float,timestamp,binary 等类型 一个document 数组类型
存储数据类型
BSON
BSON是一种类JSON的二进制形式的存储格式,简称Binary JSON,它和JSON一样,支持内嵌的文档对象和数组对象,但是BSON有JSON没有的一些数据类型,如Date和BinData类型,MongoDB使用BSON做为文档数据存储和网络传输格式。
主从复制
子主题
MongoDB 的主从复制至少需要两个服务器或者节点。其中一个是主节点,负责处理客户端请求,其它的都是从节点,负责同步主节点的数据。
数据同步
主节点记录在其上执行的所有写操作,从节点定期轮询主节点获取这些操作,然后再对自己的数据副本执行这些操作。由于和主节点执行了相同的操作,从节点就能保持与主节点的数据同步。
由oplog操作记录,同步从节点: 主节点的操作记录称为oplog(operation log),它被存储在 MongoDB 的 local 数据库中。oplog 中的每个文档都代表主节点上执行的一个操作。需要重点强调的是oplog只记录改变数据库状态的操作。
副本集
子主题
官方推荐的副本集最小配置需要有三个节点:一个主节点接收和处理所有的写操作,两个备份节点通过复制主节点的操作来对主节点的数据进行同步备份。
原理
就是具有自动故障恢复功能的主从集群,和主从复制最大的区别就是在副本集中没有固定的“主节点;整个副本集会选出一个节点作为“主节点”,当其挂掉后,再在剩下的从节点中选举一个节点成为新的“主节点”,在副本集中总有一个主节点(primary)和一个或多个备份节点(secondary)。
题
简述什么是MongoDB?
是一个基于分布式文件存储的开源数据库系统 (1)在高负载的情况下,添加更多的节点,可以保证服务器性能。 (2)MongoDB 旨在为WEB应用提供可扩展的高性能数据存储解决方案。 (3)MongoDB 将数据存储为一个文档,数据结构由键值(key=>value)对组成。 MongoDB的数据存储结构是以文档为单位的,每个文档是一个灵活的、可嵌套的数据结构,由键值对组成,使用BSON格式进行存储。文档组织在集合中,每个文档都有一个唯一的标识符。 (4)MongoDB 文档类似于 JSON 对象。字段值可以包含其他文档,数组及文档数组
总结:分布式数据库,节点可扩展,存储结构是kv,类似JSON(其实是BSON)
MYSQL与MongoDB差别
关系型-MYSQL
1. 不同的引擎有不同的存储方式 2. 查询语句是传统SQL 3. 缺点是海量数据处理时会变慢
非关系型-MongoDB
NOSQL,属于文档型数据库,即存储JSON、XML、BSON类型系的数据,结构由KV对组成 1. 1、存储方式:虚拟内存+持久化。 2、查询语句:是独特的MongoDB的查询方式。 3、适合场景:事件的记录,内容管理或者博客平台等等。 4、架构特点:可以通过副本集,以及分片来实现高可用。 5、数据处理:数据是存储在硬盘上的,只不过需要经常读取的数据会被加载到内存中,将数据存储在物理内存中,从而达到高速读写。
总结:NOSQL,BSON文档KV对,虚似内存查询,副本集架构及分片高可用
高性能
索引机制
单字段索引(Single Field Indexes) 复合索引(Compound Indexes) 多键索引(Multikey Indexes) 全文索引(text Indexes) Hash 索引(Hash Indexes) 通配符索引(Wildcard Index)
数据存储
MongoDB使用BSON(Binary JSON)作为数据存储格式,它是一种二进制编码格式,比JSON更加紧凑和高效。BSON支持更多的数据类型,包括日期、正则表达式、二进制数据等。BSON的使用有助于减少网络传输的数据量,提高数据存储和传输的效率。
WiredTiger存储引擎 1. 数据先放缓存(B树)和WAL日志中 (页子节点存数据,中间页存索引) 2. 每60s或WAL日志达2G,就同步到磁盘中 3. 由WAL可以恢复数据 https://blog.csdn.net/ko0491/article/details/108821446
内部缓存机制
使用内部缓存来存储最近访问的数据和索引
采用LRU(Least Recently Used)算法来淘汰最久未使用的数据
索引前缀压缩
在存储索引时采用了前缀压缩技术。这意味着相同的索引前缀只存储一次,从而减少了索引的大小并降低了对物理内存的使用
写操作流程优化
写操作首先将数据写入到内部缓存,并持久化到WAL(Write-Ahead Logging,预写日志)中。每隔一段时间或当文件达到特定大小时,WiredTiger会执行一个checkpoint操作,将数据持久化到磁盘上,并生成一个快照。
B树结构
在内部使用B树(或变种)结构来组织和存储数据。B树是一种平衡的多路搜索树,能够保持数据的有序性并提供高效的查找、插入和删除操作。这种数据结构使得WiredTiger能够快速地定位数据,并支持范围查询等复杂操作。
实现原理
B树
这是一种自平衡的搜索树。B树能够在O(log n)的时间复杂度内进行查找、插入和删除操作,这使得WiredTiger能够在大规模数据存储的场景下,仍能够保持较低的查询延迟和高吞吐量。WiredTiger使用B树来管理数据的索引,以实现高效的数据访问和修改。
多版本并发控制(MVCC)
采用了多版本并发控制(MVCC)的机制,这是一种并发控制技术。通过为每个事务创建不同的数据版本,MVCC实现了并发事务的隔离性
数据压缩算法
数据压缩可以通过使用更少的磁盘空间来存储数据,从而减少IO操作的次数,提高系统的整体性能
事物管理与日志记录
在WiredTiger中,每个事务都有一个唯一的事务ID,用于标识事务的开始和结束
Cache与WAL
WiredTiger的Cache采用Btree的方式组织,每个Btree节点为一个page,root page是btree的根节点,internal page是btree的中间索引节点,leaf page是真正存储数据的叶子节点。btree的数据以page为单位按需从磁盘加载或写入磁盘。 WiredTiger采用Copy on write的方式管理修改操作(insert、update、delete),修改操作会先缓存在cache里,持久化时,修改操作不会在原来的leaf page上进行,而是写入新分配的page。
复制
MongoDB支持复制机制,可以将数据复制到多个节点上,提高系统的可用性和容错性。复制通过主节点和从节点的方式进行,主节点负责写操作,从节点负责读操作。当主节点宕机时,从节点可以自动接替成为新的主节点,保证系统的连续性。
分片
MongoDB支持分片机制,可以将数据分散存储在多个节点上,提高系统的存储容量和处理能力。分片通过路由器进行,路由器负责将数据按照一定的规则分发到各个分片上。分片可以动态添加和删除,实现系统的水平扩展和负载均衡。
classDiagram MONGODB --> SHARD_ROUTER : distribution SHARD_ROUTER }o-- MONGODB : routing
总结
总结来说,WiredTiger存储引擎的实现原理主要包括B树的使用、MVCC机制、数据压缩算法以及事务管理和日志记录等。这些机制的结合使得WiredTiger能够在大规模数据存储和高并发访问的场景下,提供高性能和高可靠性的数据存储解决方案。
MongoDB是最好的NOSQL数据库原因
1. 非常容易扩展 文档查询语言在支持动态查询、 数据存储在内存中,更快的访问数据, 也可以用作文件系统,使得负载平衡更加容易
2. MongoDB流行起来 高性能:无论规模大小,NoSQL数据库在吞吐量和延迟方面性能出色 灵活的数据模型:文档数据格式,让存储和聚合任何类型数据变得简单,而无需验证规则或索引功能 一组集成功能:分析、文本搜索、地理定位、内存性能、数据可视化和全局复制,在单个平台上可靠、安全地提供广泛的实时应用程序 可扩展性:可以在地理上相距遥远的数据中心内外进行扩展,从而为表带来更高级别的可用性和可扩展性
总结:容易扩展,高性能(吞吐量、延迟)、数据模型、集成功能(文档搜索、定位、内存性能等)
MongoDB场景
传统关系型数据库:在高并发、高效率海量存储、可扩展性和高可用性较差
场景
社交场景
存储用户信息,发表的朋友圈信息,通过地理位置索引实现功能
物流场景
存储订单信息,订单状态在运送过程中会不断更新,以 MongoDB 内嵌数组的形式来存储,一次查询就能将订单所有的变更读取出来。
物联网场景
存储所有接入的智能设备信息,以及设备汇报的日志信息,并对这些信息进行多维度的分析。
视频直播
使用 MongoDB 存储用户信息、点赞互动信息等。
特点
数据量大 读写频繁 价值较低的数据 数据模型无法确定,快速迭代 QPS在2000以上 存储需要TB级别 快速水平扩展 存储数据不丢失 高可用99.999% 地理位置查询、文本查询
上述有1个符合,可以考虑MongoDB
支持数据类型
正则表达式、数组、内嵌文档、二进制数据(图像、音频、视频)、代码、时间戳、字符串、数字、bool、对象ID(文档唯一标识符)、日期、地址位置(GeoJSON)、文本
索引机制
索引是一种特殊的文档,它包含集合中的字段值以及该值出现的位置
支持多种类型的索引,包括单字段索引、复合索引、全文本索引等。
单字段索引:把集合中的某个字段(如_id)的值与该字段所对应文档的位置关联起来 db.students.ensureIndex({_id: 1})
复合索引:复合索引是将多个字段的索引合并到一起,形成一个新的索引。复合索引的查找速度比单字段索引更快。 db.students.ensureIndex({name: 1, age: -1}) 注:复合索引包含了多个字段,查询时需要完全满足索引顺序才能进行查找。
唯一索引:唯一索引可以保证集合中某个或某些字段的值是唯一的。 db.students.ensureIndex({_id: 1}, {unique: true})
稀疏索引:稀疏索引只包含有索引键的文档,它可以跳过未包含索引键的文档。在某些场景下使用稀疏索引可以大大降低索引建立和查询所需空间。 db.students.ensureIndex({address: 1}, {sparse: true})
全文本索引:是特殊的文本索引,它可以帮助MongoDB在文本数据中匹配出最相关的文档。 db.articles.ensureIndex({content: "text"})
索引作用
在进行查询操作时优先使用索引
五大特性
面向文档
BSON格式,多个字段的KV值对
动态Schema
MongoDB是一种无模式的数据库,它允许存储的文档结构可以随意改变,为数据建模带来了很大的灵活性。例如,可以将一个文档中的文本字段替换为另一个文档的二进制对象,而不需要对现有数据进行修改。
支持多种查询和索引
MongoDB支持丰富的查询语言,包括匹配、范围查询、正则表达式匹配等等。同时,它还支持多种类型的索引,如单键、复合键、全文索引等等,能够支持更加灵活的数据库查询。
高可扩展性
分片+副本: MongoDB可以通过对集群进行分片和副本集的方式,来扩展数据库的存储容量和读写性能。MongoDB的副本集是将数据复制到多个服务器的数据库,而分片则是将数据分散到多个服务器上的数据库。
开源免费
内部构造
数据库
一个MongoDB实例可以包含多个数据库
集合(表)
存储文档的地方,每个集合都有一个唯一的名称,集合中的文档可以采用不同的结构。
文档(一行记录)
文档采用BSON(Binary JSON)格式,是一个包含键值对的文档,其中键值对是有序的,并且值可以是一个文档、数组或其他数据类型。
索引
索引用于快速查找文档,提高查询性能。 MongoDB支持多种类型的索引,包括单字段、组合、哈希、全文本和地理空间等。
如何创建索引
>db.test.createIndex({“username”:1})
复合索引
多字段查询,就要用到复合索引
何时不使用索引
每个索引占据一定的存储空间,在进行插入,更新和删除操作时也需要对索引进行操作。所以,如果你很少对集合进行读取操作,建议不使用索引。
索引类型
单字段索引(Single Field Indexes) 复合索引(Compound Indexes) 多键索引(Multikey Indexes) 全文索引(text Indexes) Hash 索引(Hash Indexes) 通配符索引(Wildcard Index)
操作
集合创建与删除操作
db.createCollection(name, options)
name: 要创建的集合名称 options: 可选参数, 指定有关内存大小及索引的选项
db.collection.drop()
如果成功删除选定集合,则 drop() 方法返回 true,否则返回 false。
查询操作
使用db.collection.find()方法来执行查询操作
db.students.find({ age: { $gt: 22 } })
分页操作
使用skip()和limit()方法
skip()方法是指跳过指定数量的记录,limit()方法是指限制返回的记录数量 db.students.find().sort({ age: 1 }).skip(2).limit(2)
排序
按照指定的字段对查询结果进行排序
使用sort()方法 db.students.find().sort({ age: 1 })
增删改查
insert() 或 save()
db.COLLECTION_NAME.insert(document)
update() 方法 用于更新已存在的文档 save() 方法通过传入的文档来替换已有文档
remove()函数是用来移除集合中的数据。
find() 方法以非结构化的方式来显示所有文档。
聚合
将多个文档中的值组合起来,对成组数据执行各种操作,返回单一的结果。它相当于 SQL 中的 count(*) 组合 group by。对于 MongoDB 中的聚合操作,应该使用aggregate()方法。
db.COLLECTION_NAME.aggregate(AGGREGATE_OPERATION)
副本集
MongoDB复制是将数据同步在多个服务器的过程。 复制提供了数据的冗余备份,并在多个服务器上存储数据副本,提高了数据的可用性, 并可以保证数据的安全性。 mongodb的复制至少需要两个节点。其中一个是主节点,负责处理客户端请求,其余的都是从节点,负责复制主节点上的数据。 mongodb各个节点常见的搭配方式为:一主一从、一主多从。 主节点记录在其上的所有操作oplog,从节点定期轮询主节点获取这些操作,然后对自己的数据副本执行这些操作,从而保证从节点的数据与主节点一致。
master或primary
副本集只能有一个主节点能够确认写入操作来接收所有写操作,并记录其操作日志中的数据集的所有更改(记录在oplog中)。在集群中,当主节点(master)失效,Secondary节点会变为master
分片的概念
分片是指将数据拆分,将其分散存在不同机器上的过程
HBase
描述
是一个分布式的、面向列的开源数据库
原理
架构
Client
包含访问hbase的接口,Client维护着一些cache来加快对hbase的访问,比如regione的位置信息.
Zookeeper
作用 保证任何时候,集群中只有一个master 存贮所有Region的寻址入口 实时监控Region Server的状态,将Region Server的上线和下线信息实时通知给Master
HMaster
为Region Server分配Region 负责Region Rerver的负载均衡 发现失效的Region Server并重新分配其上的Region HDFS上的垃圾文件回收 处理schema更新请求
总结:管理Region服务,分配Region,服务的负载均衡
HRegion Server
HRegion Server维护HMaster分配给它的Region,处理对这些Region的IO请求 HRegion Server负责切分在运行过程中变得过大的region 从图中可以看到,Client访问HBase上数据的过程并不需要HMaster参与(寻址访问Zookeeper和HRegion server,数据读写访问HRegione server) HMaster仅仅维护者table和HRegion的元数据信息,负载很低。
表模型
行键 Row Key
与nosql数据库一样,row key是用来检索记录的主键。访问hbase table中的行,只有三种方式: 1. 通过单个row key访问 2. 通过row key的range 3. 全表扫描
Hbase会对表中的数据按照rowkey排序(字典顺序)
列族 Column Family
HBase表中的每个列,都归属于某个列族。列族是表的schema的一部分(而列不是),必须在使用表之前定义。 列名都以列族作为前缀。例如 courses:history , courses:math 都属于 courses 这个列族。
访问控制、磁盘和内存的使用统计都是在列族层面进行的。列族越多,在取一行数据时所要参与IO、搜寻的文件就越多,所以,如果没有必要,不要设置太多的列族。
列 Column
列族下面的具体列,属于某一个ColumnFamily,类似于在mysql当中创建的具体的列。
时间戳 Timestamp
HBase中通过row和columns确定的为一个存贮单元称为cell。每个 cell都保存着同一份数据的多个版本。版本通过时间戳来索引。
单元 Cell
在HBase中,如果需要锁定唯一的一条数据,需要通过行键+列族名+列名+版本号/时间戳来锁定,这个结构称之为Cell 由{row key, column( =<family> + <label>), version} 唯一确定的单元。 cell中的数据是没有类型的,全部是字节码形式存贮。
由 {rowkey, column Family:column Qualifier, time Stamp} 唯一确定的单元。cell 中的数 据是没有类型的,全部是字节码形式存贮
版本号 VersionNum
数据的版本号,每条数据可以有多个版本号,默认值为系统时间戳,类型为Long。
namespace 名称空间
在HBase中,没有database的说法,取而代之的是namespace
Hase启动的时候,自带了两个名称空间:hbase和default。hbase空间下放的是HBase的元数据信息,所以hbase不要动!在建表的时候,如果不指定,表是放在default空间下
物理存储
整体结构
Table 中的所有行都按照 Row Key 的字典序排列。
Table 在行的方向上分割为多个 HRegion。
HRegion按大小分割的(默认10G),每个表一开始只有一 个HRegion,随着数据不断插入表,HRegion不断增大,当增大到一个阀值的时候,HRegion就会等分会两个新的HRegion。当Table 中的行不断增多,就会有越来越多的 HRegion。
HRegion 是 HBase 中分布式存储和负载均衡的最小单元。最小单元就表示不同的 HRegion 可以分布在不同的 HRegion Server 上。但一个 HRegion 是不会拆分到多个 Server 上的。
HRegion 虽然是负载均衡的最小单元,但并不是物理存储的最小单元。 事实上,HRegion 由一个或者多个 Store 组成,每个 Store 保存一个 Column Family。 每个 Strore 又由一个 MemStore 和0至多个 StoreFile 组成。
StoreFile 和 HFile 结构
StoreFile以HFile格式保存在HDFS上。
题
Hbase数据库
是一个分布式的、面向列的开源数据库
HBase不同于一般的关系数据库,它是一个适合于非结构化数据存储的数据库
另一个不同的是HBase基于列的而不是基于行的模式
(1) Hbase一个分布式的基于列式存储的数据库,基于Hadoop的hdfs存储,zookeeper进行管理。 (2) Hbase适合存储半结构化或非结构化数据,对于数据结构字段不够确定或者杂乱无章很难按一个概念去抽取的数据。 (3) Hbase为null的记录不会被存储. (4) 基于的表包含rowkey,时间戳,和列族。新写入数据时,时间戳更新,同时可以查询到以前的版本. (5) hbase是主从架构。hmaster作为主节点,hregionserver作为从节点。
总结:基于列式存储、非结构化数据、表包含rowkey,时间戳和列族,主从架构
特点
1)容量巨大:一个表可以有数十亿行,上百万列; 2)无模式:每行都有一个可排序的主键和任意多的列,列可以根据需要动态的增加,同一 张表中不同的行可以有截然不同的列; 3)面向列:面向列(族)的存储和权限控制,列(族)独立检索; 4)稀疏:空(null)列并不占用存储空间,表可以设计的非常稀疏; 5)数据多版本:每个单元中的数据可以有多个版本,默认情况下版本号自动分配,是单元 格插入时的时间戳; 6)数据类型单一:Hbase 中的数据都是字符串,没有类型
场景
半结构化或非结构化数据
对于数据结构字段不够确定或杂乱无章很难按一个概念去进行抽取的数据适合用HBase
多版本数据
根据Row key和Column key定位到的Value可以有任意数量的版本值
超大数据量
当数据量越来越大,采用HBase就简单了,只需要加机器即可,HBase会自动水平切分扩展,跟Hadoop的无缝集成保障了其数据可靠性(HDFS)和海量数据分析的高性能(MapReduce)。
rowKey 的概念和设计原则
RowKey是HBase中的唯一标识符,用于在逻辑上对表单元进行分组,以确保所有具有相似RowKey的单元都放在同一服务器上。但是,内部RowKey是字节数组。
Rowkey 长度原则
不要超过 16 个字节
原因:64 位系统,内存 8 字节对齐。控制在 16 个字节,8 字节 的整数倍利用操作系统的最佳特性。
Rowkey 唯一原则
必须在设计上保证其唯一性。
Rowkey 散列原则
如果Rowkey 是按时间戳的方式递增,不要将时间放在二进制码的前面,建议将Rowkey的高位作为散列字段,由程序循环生成,低位放时间字段,这样将提高数据均衡分布在每个Regionserver 实现负载均衡的几率。如果没有散列字段,首字段直接是时间信息将产生所有新数据都在一个 RegionServer 上堆积的热点现象,这样在做数据检索的时候负载将会集中在个别 RegionServer,降低查询效率。
总结:高位采用散列字段,低位可以放时间字段,例:hash_time
rowKey设计建议
使用散列函数
可以使用散列函数(如MD5、SHA等)对原始数据进行处理,以生成具有均匀散列分布的rowkey
避免顺序性
尽量避免在rowkey中使用顺序性强的值,如递增的数字或时间戳。这样的设计可能会导致连续的数据行被分配到同一个HRegion中,从而引发热点问题。
随机前缀
为rowkey添加随机前缀,可以打破数据的顺序性,并帮助将数据分散到不同的HRegion中。
考虑数据倾斜
在设计rowkey时,要考虑到数据倾斜的可能性。如果某些rowkey的值出现频率远高于其他值,那么这些值可能会导致某些HRegion中的数据量远大于其他HRegion。
scan和get功能
按指定 RowKey 获取唯一一条记录,get 方法(org.apache.hadoop.hbase.client.Get)Get 的方法
按指定的条件获取一批记录,scan 方法(org.apache.Hadoop.hbase.client.Scan)实现条件查询功能使用的就是 scan 方式。
compact用途和机制
在 hbase 中每当有 memstore 数据 flush 到磁盘之后,就形成一个 storefile,当 storeFile 的数量达到一定程度后,就需要将 storefile 文件来进行 compaction 操作。
架构
HBase遵守主从架构的技术,由一个主HMaster和若干个从HRegionServer组成。HBase中的一张表的数据由若干个HRegion组成,也就是说每一个HRegion负责管理一张表中的一段数据,HRegion都是分布式的存在于HRegionServer中,所以说HRegion是HBase表中数据分布式存储的单位。那么一个HRegion中又是由若干个column family的数据组成;在HRegion中每个column family数据由一个store管理,每个store包含了一个memory store和若干个HFile组成,HFile的数据最终会落地到HDFS文件中,所以说HBase依赖HDFS。 在HBase中还有一部分元数据信息,比如HMaster的状态信息、HRegionServer的状态信息以及HRegion的状态信息等,这些信息都是存储在zookeeper集群中
操作命令
1、进入到hbase shell hbase shell 2、查看当前版本 version 3、查看命名空间 list_namespace 4、查看命名空间下的表 list_namespace_tables 命名空间 5、创建namespace(命名空间) create_namespace 'test' 6、删除namespace(命名空间),删除表空间前,要先把表空间内的表全部删除 drop_namespace 'test' 7、查看命名空间下有多少表 list_namespace_tables 'test' 8、创建表 create 'test:student','baseInfo','schoolInfo' 9、删除表操作,删除表之前先禁用表,否则删除失败 disable 'test:student' drop 'test:student' 10、查询表结构 describe 'test:student' 11、插入数据 put 'test:student','rowkey1','baseInfo:name','zhangsan' 12、查询表数据 按列簇查:get 'test:student','rowkey1','baseInfo' 按rowkey查:scan 'test2:student',{STARTROW=>'rowkey',ENDROW=>'rowkey4'} 按条件:scan 'namespace:table',FILTER=>"ColumnPrefixFilter('字段条件') AND ValueFilter(=,'substring:内容条件')"
13、查看状态 status 14、帮助命令 help '命令' 15、判断表是否存在 exists 'test:student' 16、新增列簇 alter 'test:student','teacherInfo' 17、删除列簇 alter 'test:student','teacherInfo',, { NAME=>'teacherInfo',METHOD=>'delete' } 18、设置列簇记录三个版本 alter 'test:student', { NAME=>'baseInfo',VERSIONS=>3 } 19、全表扫描查询 scan 'test:student' 20、条件过滤查询 scan 'test:student', { COLUMN=>'baseInfo' } 21、删除指定列簇下的列 delete 'test:student','rowkey1','baseInfo:age'' 22、删除指定行 rowkey exists 'test:student'
操作命令类型
get:查询操作 put:插入操作 delete:删除操作 scan:扫描所有记录
集群中HRegionServer作用
1.维护分配到的region,处理对这些region的IO请求 2.负责切分达到阀值的region3.每个RegionServer各自保管自己的Hlog
热点问题
1、数据热点问题 产生数据热点问题的原因: (1)Hbase的数据是按照字典排序的,当大量连续的rowkey集中写到个别的region,各个region之间实际分布不均衡; (2)创建表时没有提前预分区,创建的表默认只有一个region,大量的数据写入当前region; (3)创建表已经提前预分区,但是设计的rowkey没有规律可循。 热点问题的解决方案: (1)随机数+业务主键,如果更好的让最近的数据get到,可以加上时间戳; (2)Rowkey设计越短越好,不要超过10-100个字节; (3)映射regionNo,这样既可以让数据均匀分布到各个region中,同时可以根据startkey和endkey可以个get到同一批数据
设计分享链
需求:商品分享链,一个商品经过多个用户依次分享后,得返利,谁帮我设计一个hbase的rowkey ,怎么能满足该商品分享链的前五位用户,这个hbase 的rowkey如何设计,给出方案
rowKey设计
1. 存储信息 - 商品ID:唯一标识每个商品的ID。 - 分享链位置:表示该用户在分享链中的位置。 - 用户ID:唯一标识每个用户的ID。
2. 保留前五位用户的信息,因此我们可以将用户ID与位置组合成一个有序字符串,并将其作为rowkey的一部分
3. 组成rowKey:商品ID + 分享链位置 + 用户ID
例:例如,假设商品ID为123456,分享链位置为3,用户ID为789,那么对应的rowkey可以为: `123456_3_789`
查询:当需要获取前五位用户的信息时,可以通过扫描rowkey前缀为商品ID的所有行,并按照分享链位置进行排序,从而获取前五位用户的相关信息
要查询商品ID为123456的分享链的前五位用户,可以执行以下命令: ``` scan '空间:表', {STARTROW => '123456_1', ENDROW => '123456_1_99999'} ```
设计的优点
1. 唯一性:通过将商品ID、分享链位置和用户ID组合而成的rowkey,能够确保每个分享链的rowkey是唯一的。这样可以避免重复数据的插入,并保证数据的准确性。
2. 由于rowkey中包含了分享链位置,数据在HBase中的存储是有序的。这样可以方便进行范围查询、排序等操作,例如获取前五位用户。
3. 可扩展性:该设计方案可以轻松地扩展到更大的分享链位置数量或更多的用户参与。只需根据需求将位置和用户ID添加到rowkey中即可。
高可用模式与机制
主HMaster
主HMaster在启动时会在ZooKeeper中/master节点注册 备用的HMaster在启动时会在ZooKeeper中backup-masters节点注册
RegionServer
当RegionServer挂了的时候,其上面的Region就会失效,HMaster会发现失效的Region并进行重新分配,但是可能会有部分数据在memstore上,如果RegionServer挂了,那么这部分数据将丢失,不过在写memstore之前数据已经写入了HLOG,所以丢失的数据可以通过HLOG进行恢复。 storefile的数据已经在HDFS上了,不会丢失
缺点
单一RowKey固有的局限性决定了它不可能有效地支持多条件查询[2] 不适合于大范围扫描查询 不直接支持 SQL 的语句查询
集群安装
① HBase需要HDFS的支持,因此安装HBase前确保Hadoop集群安装完成; ② HBase需要ZooKeeper集群的支持,因此安装HBase前确保ZooKeeper集群安装完成; ③ 注意HBase与Hadoop的版本兼容性; ④ 注意hbase-env.sh配置文件和hbasesite.xml配置文件的正确配置; ⑤ 注意regionservers配置文件的修改; ⑥ 注意集群中的各个节点的时间必须同步,否则启动HBase集群将会报错
Region预建分区
预分区的目的主要是在创建表的时候指定分区数,提前规划表有多个分区,以及每个分区的区间范围,这样在存储的时候rowkey按照分区的区间存储, 可以避免region热点问题。
通常有两种方案: 方案1:shell 方法 create ‘tb_splits’, { NAME => ‘cf’,VERSIONS=> 3 } , { SPLITS => [‘10’,‘20’,‘30’] }
方案2:JAVA程序控制 ① 取样,先随机生成一定数量的rowkey,将取样数据按升序排序放到一个集合里; ② 根据预分区的region个数,对整个集合平均分割,即是相关的splitKeys; ③ HBaseAdmin.createTable(HTableDescriptor tableDescriptor,byte[][]splitkeys)可以指定预分区的splitKey, 即是指定region间的rowkey临界值。
提供2种思路
hash 与 partition.
hash就是rowkey前面由一串随机字符串组成,随机字符串生成方式可以由SHA或者MD5等方式生成,只要region所管理的start-end keys范围比较随机,那么就可以解决写热点问题 假设rowKey原本是自增长的long型,可以将rowkey转为hash再转为bytes,加上本身id 转为bytes,组成rowkey,这样就生成随便的rowkey。那么对于这种方式的rowkey设计,如何去进行预分区呢? 1.取样,先随机生成一定数量的rowkey,将取样数据按升序排序放到一个集合里 2.根据预分区的region个数,对整个集合平均分割,即是相关的splitKeys. 3.HBaseAdmin.createTable(HTableDescriptor tableDescriptor,byte[][] splitkeys)可以指定预分区的splitKey,即是指定region间的rowkey临界值.
long currentId = 1L; byte [] rowkey = Bytes.add(MD5Hash.getMD5AsHex(Bytes.toBytes(currentId)).substring(0, 8).getBytes(), Bytes.toBytes(currentId));
子主题
Hbase写比读快
根本原因是hbase的存储引擎用的是LSM树,是一种面向磁盘的数据结构: Hbase底层的存储引擎为LSM-Tree(Log-Structured Merge-Tree)。LSM核心思想的核心就是放弃部分读能力,换取写入的最大化能力。LSM Tree它的核心思路其实非常简单,就是假定内存足够大,因此不需要每次有数据更新就必须将数据写入到磁盘中,而可以先将最新的数据驻留在内存中,等到积累到最后多之后,再使用归并排序的方式将内存内的数据合并追加到磁盘队尾(因为所有待排序的树都是有序的,可以通过合并排序的方式快速合并到一起)。另外,写入时候将随机写入转换成顺序写,数据写入速度也很稳定。 不过读取的时候稍微麻烦,需要合并磁盘中历史数据和内存中最近修改操作,所以写入性能大大提升,读取时可能需要先看是否命中内存,否则需要访问较多的磁盘文件。极端的说,基于LSM树实现的HBase的写性能比MySQL高了一个数量级,读性能低了一个数量级。 LSM树原理把一棵大树拆分成N棵小树,它首先写入内存中,随着小树越来越大,内存中的小树会flush到磁盘中,磁盘中的树定期可以做merge操作,合并成一棵大树,以优化读性能
多列族设计的优劣
优势: HBase中数据时按列进行存储的,那么查询某一列族的某一列时就不需要全盘扫描,只需要扫描某一列族,减少了读I/O; 其实多列族设计对减少的作用不是很明显,适用于读多写少的场景。 劣势: 降低了写的I/O性能。原因如下:数据写到store以后是先缓存在memstore中,同一个region中存在多个列族则存在多个store,每个store都一个memstore,当其实memstore进行flush时,属于同一个region 的store中的memstore都会进行flush,增加I/O开销
influxDB(时序数据库)
使用场景
监控场景,比如运维和 IOT(物联网)领域。这类数据库旨在存储时序数据并实时处理它们。
为什么不用关系数据库
mysql
关系型数据库会采用 B+树数据结构,在数据写入时,有可能会触发叶裂变,从而产生了对磁盘的随机读写,降低写入速度
时序数据库
时序数据库通常都是采用LSM Tree 的变种,顺序写磁盘来增强数据的写入能力。时序数据库都会保证在单点每秒数十万的写入能力
数据价值
只会使用近期一段时间的数据,比如我只查询某个设备最近 10 分钟的记录,10 分钟前的数据我就不再用了
理论与原理
内部数据结构(行协议)
measurement(测量名称) Tag Set(标签集) Field Set(字段集) Timestamp(时间戳)
(1)measurement(测量名称) --相当于表名
理解为关系型数据库中的一张表
(2)Tag Set(标签集) --相当于索引
标签应该用在一些值的范围有限的,不太会变动的属性上
(3)Field Set(字段集)
一个数据点上所有的字段键值对,键是字段名,值是数据点的值。
(4)Timestamp(时间戳)
数据点的Unix时间戳,每个数据点都可以指定自己的时间戳。也可以是默认的系统时间
子主题
子主题
(1)Point --相当于一行数据
Point由时间戳(time)、数据(field)、标签(tags)组成。
(2)Series --相同的数据存储在一起
Series 相当于是 InfluxDB 中一些数据的集合
在同一个 database 中,retention policy、measurement、tag sets 、field完全相同的数据同属于一个 series,同一个 series 的数据在物理上会按照时间顺序排列存储在一起。
(3)Shard --存储指定时间段的数据
它和 retention policy 相关联。每一个存储策略下会存在许多 shard,每一个 shard 存储一个指定时间段内的数据,并且不重复,例如 7点-8点 的数据落入 shard0 中,8点-9点的数据则落入 shard1 中。每一个 shard 都对应一个底层的 tsm 存储引擎,有独立的 cache、wal、tsm file。
目的
为了可以通过时间来快速定位到要查询数据的相关资源,加速查询的过程,并且也让之后的批量删除数据的操作变得非常简单且高效。
(4)Retention Policy --保留策略
保留策略包括设置数据保存的时间以及在集群中的副本个数。
数据模型
关系数据库中的表
+---------+---------+---------------------+--------------+ | park_id | planet | time | #_foodships | +---------+---------+---------------------+--------------+ | 1 | Earth | 1429185600000000000 | 0 | | 1 | Earth | 1429185601000000000 | 3 | | 1 | Earth | 1429185602000000000 | 15 | | 1 | Earth | 1429185603000000000 | 15 | | 2 | Saturn | 1429185600000000000 | 5 | | 2 | Saturn | 1429185601000000000 | 9 | | 2 | Saturn | 1429185602000000000 | 10 | | 2 | Saturn | 1429185603000000000 | 14 | | 3 | Jupiter | 1429185600000000000 | 20 | | 3 | Jupiter | 1429185601000000000 | 21 | | 3 | Jupiter | 1429185602000000000 | 21 | | 3 | Jupiter | 1429185603000000000 | 20 | | 4 | Saturn | 1429185600000000000 | 5 | | 4 | Saturn | 1429185601000000000 | 5 | | 4 | Saturn | 1429185602000000000 | 6 | | 4 | Saturn | 1429185603000000000 | 5 | +---------+---------+---------------------+--------------+
时序数据库中的表
name: foodships tags: park_id=1, planet=Earth time #_foodships ---- ------------ 2015-04-16T12:00:00Z 0 2015-04-16T12:00:01Z 3 2015-04-16T12:00:02Z 15 2015-04-16T12:00:03Z 15 name: foodships tags: park_id=2, planet=Saturn time #_foodships ---- ------------ 2015-04-16T12:00:00Z 5 2015-04-16T12:00:01Z 9 2015-04-16T12:00:02Z 10 2015-04-16T12:00:03Z 14 name: foodships tags: park_id=3, planet=Jupiter time #_foodships ---- ------------ 2015-04-16T12:00:00Z 20 2015-04-16T12:00:01Z 21 2015-04-16T12:00:02Z 21 2015-04-16T12:00:03Z 20 name: foodships tags: park_id=4, planet=Saturn time #_foodships ---- ------------ 2015-04-16T12:00:00Z 5 2015-04-16T12:00:01Z 5 2015-04-16T12:00:02Z 6 2015-04-16T12:00:03Z 5
InfluxDB 中的 measurement(foodships)相当于SQL(关系型)数据库中的表 InfluxDB 中的 tags(park_id 和planet)相当于 SQL(关系型)数据库中的索引列 InfluxDB 中的 fileds(在这里是#_foodships)相当于 SQL(关系型)数据库中的未建索引的列。 InfluxDB 中的数据点2015-04-16T12:00:00Z 5相当于SQL(关系型)数据库中的一行。
开源项目
dubbo
Dubbo是什么
Dubbo是一个分布式服务框架,致力于提供高性能和透明化的RPC远程服务调用方案,以及SOA服务治理方案。
Dubbo能做什么
1. 透明化的远程方法调用
就像调用本地方法一样调用远程方法,只需简单配置,没有任何API侵入。
2. 软负载均衡及容错
可在内网替代F5等硬件负载均衡器,降低成本,减少单点。
3. 服务自动注册与发现
不再需要写死服务提供方地址,注册中心基于接口名查询服务提供者的IP地址,并且能够平滑添加或删除服务提供者。
基础知识
SPI
Dubbo 为了更好地达到 OCP 原则(即“对扩展开放,对修改封闭”的原则),采用了“微内核+插件”的架构。 内核功能是比较稳定的,只负责管理插件的生命周期,不会因为系统功能的扩展而不断进行修改。功能上的扩展全部封装到插件之中,插件模块是独立存在的模块,包含特定的功能,能拓展内核系统的功能。 微内核架构中,内核通常采用 Factory、IoC、OSGi 等方式管理插件生命周期,Dubbo 最终决定采用 SPI 机制来加载插件,Dubbo SPI 参考 JDK 原生的 SPI 机制,进行了性能优化以及功能增强。
优势
更加灵活和可扩展
SPI机制允许在不修改框架源代码的情况下添加新的功能或扩展框架的能力。这使得系统更加灵活和可扩展,能够适应不断变化的需求。
降低耦合度
SPI将服务提供者和服务使用者解耦,降低了系统之间的依赖性和耦合度。这使得系统更加健壮和可维护,因为某个服务提供者的故障不会影响到整个系统的运行。
插件化开发
使用SPI机制可以实现插件化的开发方式,使得新的功能或修改可以通过添加新的插件来实现。这种开发方式使得系统更加易于管理和维护,因为插件可以独立地开发和部署。
JDK SPI机制
当服务的提供者提供了一种接口的实现之后,需要在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,此文件记录了该 jar 包提供的服务接口的具体实现类。当某个应用引入了该 jar 包且需要使用该服务时,JDK SPI 机制就可以通过查找这个 jar 包的 META-INF/services/ 中的配置文件来获得具体的实现类名,进行实现类的加载和实例化,最终使用该实现类完成业务功能。
public interface Log { void log(String info); }
public class Logback implements Log { @Override public void log(String info) { System.out.println("Logback:" + info); } } public class Log4j implements Log { @Override public void log(String info) { System.out.println("Log4j:" + info); } }
在项目的 resources/META-INF/services 目录下添加一个名为 com.xxx.Log 的文件,这是 JDK SPI 需要读取的配置文件 com.xxx.impl.Log4j com.xxx.impl.Logback
public class Main { public static void main(String[] args) { ServiceLoader<Log> serviceLoader = ServiceLoader.load(Log.class); Iterator<Log> iterator = serviceLoader.iterator(); while (iterator.hasNext()) { Log log = iterator.next(); log.log("JDK SPI"); } } } // 输出如下: // Log4j:JDK SPI // Logback:JDK SPI
/* 该方法主要负责查找 META-INF/services 目录下的 SPI 配置文件,并进行遍历 */ private static final String PREFIX = "META-INF/services/"; Enumeration<URL> configs = null; Iterator<String> pending = null; String nextName = null; private boolean hasNextService() { if (nextName != null) { return true; } if (configs == null) { // PREFIX前缀与服务接口的名称拼接起来,就是META-INF目录下定义的SPI配 // 置文件(即示例中的META-INF/services/com.xxx.Log) String fullName = PREFIX + service.getName(); // 加载配置文件 if (loader == null) configs = ClassLoader.getSystemResources(fullName); else configs = loader.getResources(fullName); } // 按行SPI遍历配置文件的内容 while ((pending == null) || !pending.hasNext()) { if (!configs.hasMoreElements()) { return false; } // 解析配置文件 pending = parse(service, configs.nextElement()); } nextName = pending.next(); // 更新 nextName字段 return true; }
/* LazyIterator.nextService() 方法,该方法负责实例化 hasNextService() 方法读取到的实现类,其中会将实例化的对象放到 providers 集合中缓存起来 */ private S nextService() { String cn = nextName; nextName = null; // 加载 nextName字段指定的类 Class<?> c = Class.forName(cn, false, loader); if (!service.isAssignableFrom(c)) { // 检测类型 fail(service, "Provider " + cn + " not a subtype"); } S p = service.cast(c.newInstance()); // 创建实现类的对象 providers.put(cn, p); // 将实现类名称以及相应实例对象添加到缓存 return p; }
Dubbo SPI
扩展点:通过 SPI 机制查找并加载实现的接口(又称“扩展接口”)。前文示例中介绍的 Log 接口、com.mysql.cj.jdbc.Driver 接口,都是扩展点。 扩展点实现:实现了扩展接口的实现类。
Dubbo SPI 不仅解决了上述资源浪费的问题,还对 SPI 配置文件扩展和修改。 首先,Dubbo 按照 SPI 配置文件的用途,将其分成了三类目录。 META-INF/services/ 目录:该目录下的 SPI 配置文件用来兼容 JDK SPI 。 META-INF/dubbo/ 目录:该目录用于存放用户自定义 SPI 配置文件。 META-INF/dubbo/internal/ 目录:该目录用于存放 Dubbo 内部使用的 SPI 配置文件。 然后,Dubbo 将 SPI 配置文件改成了 KV 格式,例如: dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol
1. @SPI 注解
Dubbo 中某个接口被 @SPI注解修饰时,就表示该接口是扩展接口,前文示例中的 org.apache.dubbo.rpc.Protocol 接口就是一个扩展接口:
Protocol protocol = ExtensionLoader .getExtensionLoader(Protocol.class).getExtension("dubbo");
2. @Adaptive 注解与适配器
Dubbo 中的 ExtensionFactory 接口有三个实现类,如下图所示,ExtensionFactory 接口上有 @SPI 注解,AdaptiveExtensionFactory 实现类上有 @Adaptive 注解。
AdaptiveExtensionFactory 不实现任何具体的功能,而是用来适配 ExtensionFactory 的 SpiExtensionFactory 和 SpringExtensionFactory 这两种实现。AdaptiveExtensionFactory 会根据运行时的一些状态来选择具体调用 ExtensionFactory 的哪个实现。
3. 自动包装特性
Dubbo 中的一个扩展接口可能有多个扩展实现类,这些扩展实现类可能会包含一些相同的逻辑,如果在每个实现类中都写一遍,那么这些重复代码就会变得很难维护。Dubbo 提供的自动包装特性,就可以解决这个问题。 Dubbo 将多个扩展实现类的公共逻辑,抽象到 Wrapper 类中,Wrapper 类与普通的扩展实现类一样,也实现了扩展接口,在获取真正的扩展实现对象时,在其外面包装一层 Wrapper 对象,你可以理解成一层装饰器。
private void loadClass(){ ... // 省略前面对@Adaptive注解的处理 } else if (isWrapperClass(clazz)) { // ---1 cacheWrapperClass(clazz); // ---2 } else ... // 省略其他分支 }
4. 自动装配特性
在 createExtension() 方法中我们看到,Dubbo SPI 在拿到扩展实现类的对象(以及 Wrapper 类的对象)之后,还会调用 injectExtension() 方法扫描其全部 setter 方法,并根据 setter 方法的名称以及参数的类型,加载相应的扩展实现,然后调用相应的 setter 方法填充属性,这就实现了 Dubbo SPI 的自动装配特性。自动装配属性就是在加载一个扩展点的时候,将其依赖的扩展点一并加载,并进行装配。
private T injectExtension(T instance) { if (objectFactory == null) { // 检测objectFactory字段 return instance; } for (Method method : instance.getClass().getMethods()) { ... // 如果不是setter方法,忽略该方法(略) if (method.getAnnotation(DisableInject.class) != null) { continue; // 如果方法上明确标注了@DisableInject注解,忽略该方法 } // 根据setter方法的参数,确定扩展接口 Class<?> pt = method.getParameterTypes()[0]; ... // 如果参数为简单类型,忽略该setter方法(略) // 根据setter方法的名称确定属性名称 String property = getSetterProperty(method); // 加载并实例化扩展实现类 Object object = objectFactory.getExtension(pt, property); if (object != null) { method.invoke(instance, object); // 调用setter方法进行装配 } } return instance; }
5. @Activate注解与自动激活特性
这里以 Dubbo 中的 Filter 为例说明自动激活特性的含义,org.apache.dubbo.rpc.Filter 接口有非常多的扩展实现类,在一个场景中可能需要某几个 Filter 扩展实现类协同工作,而另一个场景中可能需要另外几个实现类一起工作。这样,就需要一套配置来指定当前场景中哪些 Filter 实现是可用的,这就是 @Activate 注解要做的事情。
@Activate 注解标注在扩展实现类上,有 group、value 以及 order 三个属性。 group 属性:修饰的实现类是在 Provider 端被激活还是在 Consumer 端被激活。 value 属性:修饰的实现类只在 URL 参数中出现指定的 key 时才会被激活。 order 属性:用来确定扩展实现类的排序。
区别
JDK SPI
JDK 标准的 SPI 会一次性加载所有的扩展实现 ,如果有的扩展说实话很耗时 ,但也没用上, 很浪费资源。所以只希望加载某个的实现, 就不现实了
DUBBO SPI
1, 对 Dubbo 进行扩展, 不需要改动 Dubbo 的源码 2, 延迟加载, 可以一次只加载自己想要加载的扩展实现。 3, 增加了对扩展点 IOC 和 AOP 的支持, 一个扩展点可以直接 setter 注入其 它扩展点。 4, Dubbo 的扩展机制能很好的支持第三方 IoC 容器, 默认支持 Spring Bean
注册中心
Zookeeper注册中心
分布式应用所设计的高可用且一致性的开源协调服务
注册中心
注册监听器
服务通信
Dubbo Serialize 层:多种序列化算法,总有一款适合你
常见序列化算法
Java 序列化基础
第一步,被序列化的对象需要实现 Serializable 接口,示例代码如下:
public class Student implements Serializable { private static final long serialVersionUID = 1L; private String name; private int age; private transient StudentUtil studentUtil; }
可以看到transient 关键字,它的作用就是:在对象序列化过程中忽略被其修饰的成员属性变量。 第一步,被序列化的对象需要实现 Serializable 接口 第二步,生成一个序列号 serialVersionUID,这个序列号不是必需的,但还是建议你生成。serialVersionUID 的字面含义是序列化的版本号,只有序列化和反序列化的 serialVersionUID 都相同的情况下,才能够成功地反序列化。如果类中没有定义 serialVersionUID,那么 JDK 也会随机生成一个 serialVersionUID。如果在某些场景中,你希望不同版本的类序列化和反序列化相互兼容,那就需要定义相同的 serialVersionUID。 第三步,根据需求决定是否要重写 writeObject()/readObject() 方法,实现自定义序列化。 最后一步,调用 java.io.ObjectOutputStream 的 writeObject()/readObject() 进行序列化与反序列化。
Apache Avro
Apache Avro 是一种与编程语言无关的序列化格式。Avro 依赖于用户自定义的 Schema,在进行序列化数据的时候,无须多余的开销,就可以快速完成序列化,并且生成的序列化数据也较小。当进行反序列化的时候,需要获取到写入数据时用到的 Schema。在 Kafka、Hadoop 以及 Dubbo 中都可以使用 Avro 作为序列化方案。
FastJson 是阿里开源的 JSON 解析库,可以解析 JSON 格式的字符串。
它支持将 Java 对象序列化为 JSON 字符串,反过来从 JSON 字符串也可以反序列化为 Java 对象。FastJson 是 Java 程序员常用到的类库之一,正如其名,“快”是其主要卖点。从官方的测试结果来看,FastJson 确实是最快的,比 Jackson 快 20% 左右,但是近几年 FastJson 的安全漏洞比较多,所以你在选择版本的时候,还是需要谨慎一些。
Fst(全称是 fast-serialization)是一款高性能 Java 对象序列化工具包
100% 兼容 JDK 原生环境,序列化速度大概是JDK 原生序列化的 4~10 倍,序列化后的数据大小是 JDK 原生序列化大小的 1/3 左右。目前,Fst 已经更新到 3.x 版本,支持 JDK 14。
Kryo 是一个高效的 Java 序列化/反序列化库
目前 Twitter、Yahoo、Apache 等都在使用该序列化技术,特别是 Spark、Hive 等大数据领域用得较多。Kryo 提供了一套快速、高效和易用的序列化 API。无论是数据库存储,还是网络传输,都可以使用 Kryo 完成 Java 对象的序列化。Kryo 还可以执行自动深拷贝和浅拷贝,支持环形引用。Kryo 的特点是 API 代码简单,序列化速度快,并且序列化之后得到的数据比较小。另外,Kryo 还提供了 NIO 的网络通信库——KryoNet,你若感兴趣的话可以自行查询和了解一下。
Hessian2 序列化是一种支持动态类型、跨语言的序列化协议
Java 对象序列化的二进制流可以被其他语言使用。Hessian2 序列化之后的数据可以进行自描述,不会像 Avro 那样依赖外部的 Schema 描述文件或者接口定义。Hessian2 可以用一个字节表示常用的基础类型,这极大缩短了序列化之后的二进制流。需要注意的是,在 Dubbo 中使用的 Hessian2 序列化并不是原生的 Hessian2 序列化,而是阿里修改过的 Hessian Lite,它是 Dubbo 默认使用的序列化方式。其序列化之后的二进制流大小大约是 Java 序列化的 50%,序列化耗时大约是 Java 序列化的 30%,反序列化耗时大约是 Java 序列化的 20%。
Protobuf(Google Protocol Buffers)是 Google 公司开发的一套灵活、高效、自动化的、用于对结构化数据进行序列化的协议。
但相比于常用的 JSON 格式,Protobuf 有更高的转化效率,时间效率和空间效率都是 JSON 的 5 倍左右。Protobuf 可用于通信协议、数据存储等领域,它本身是语言无关、平台无关、可扩展的序列化结构数据格式。目前 Protobuf提供了 C++、Java、Python、Go 等多种语言的 API,gRPC 底层就是使用 Protobuf 实现的序列化。
dubbo-serialization
Dubbo 为了支持多种序列化算法,单独抽象了一层 Serialize 层,在整个 Dubbo 架构中处于最底层,对应的模块是 dubbo-serialization 模块。
dubbo-serialization-api 模块中定义了 Dubbo 序列化层的核心接口,其中最核心的是 Serialization 这个接口,它是一个扩展接口,被 @SPI 接口修饰,默认扩展实现是 Hessian2Serialization。Serialization 接口的具体实现如下: @SPI("hessian2") // 被@SPI注解修饰,默认是使用hessian2序列化算法 public interface Serialization { // 每一种序列化算法都对应一个ContentType,该方法用于获取ContentType String getContentType(); // 获取ContentType的ID值,是一个byte类型的值,唯一确定一个算法 byte getContentTypeId(); // 创建一个ObjectOutput对象,ObjectOutput负责实现序列化的功能,即将Java // 对象转化为字节序列 @Adaptive ObjectOutput serialize(URL url, OutputStream output) throws IOException; // 创建一个ObjectInput对象,ObjectInput负责实现反序列化的功能,即将 // 字节序列转换成Java对象 @Adaptive ObjectInput deserialize(URL url, InputStream input) throws IOException; }
Dubbo 提供了多个 Serialization 接口实现,用于接入各种各样的序列化算法,如下图所示:
public class Hessian2Serialization implements Serialization { public byte getContentTypeId() { return HESSIAN2_SERIALIZATION_ID; // hessian2的ContentType ID } public String getContentType() { // hessian2的ContentType return "x-application/hessian2"; } public ObjectOutput serialize(URL url, OutputStream out) throws IOException { // 创建ObjectOutput对象 return new Hessian2ObjectOutput(out); } public ObjectInput deserialize(URL url, InputStream is) throws IOException { // 创建ObjectInput对象 return new Hessian2ObjectInput(is); } }
public class Hessian2ObjectOutput implements ObjectOutput { private static ThreadLocal<Hessian2Output> OUTPUT_TL = ThreadLocal.withInitial(() -> { // 初始化Hessian2Output对象 Hessian2Output h2o = new Hessian2Output(null); h2o.setSerializerFactory(Hessian2SerializerFactory.SERIALIZER_FACTORY); h2o.setCloseStreamOnClose(true); return h2o; }); private final Hessian2Output mH2o; public Hessian2ObjectOutput(OutputStream os) { mH2o = OUTPUT_TL.get(); // 触发OUTPUT_TL的初始化 mH2o.init(os); } public void writeObject(Object obj) throws IOException { mH2o.writeObject(obj); } ... // 省略序列化其他类型数据的方法 }
Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计?
Remoting 层,其中包括了 Exchange、Transport和Serialize 三个子层次。
Dubbo 并没有自己实现一套完整的网络库,而是使用现有的、相对成熟的第三方网络库,例如,Netty、Mina 或是 Grizzly 等 NIO 框架。我们可以根据自己的实际场景和需求修改配置,选择底层使用的 NIO 框架。
dubbo-remoting-api 模块
Dubbo 的 dubbo-remoting-api 是其他 dubbo-remoting-* 模块的顶层抽象,其他 dubbo-remoting 子模块都是依赖第三方 NIO 库实现 dubbo-remoting-api 模块的
dubbo-remoting-api 模块中各个包的功能。 buffer 包:定义了缓冲区相关的接口、抽象类以及实现类。缓冲区在NIO框架中是一个不可或缺的角色,在各个 NIO 框架中都有自己的缓冲区实现。这里的 buffer 包在更高的层面,抽象了各个 NIO 框架的缓冲区,同时也提供了一些基础实现。 exchange 包:抽象了 Request 和 Response 两个概念,并为其添加很多特性。这是整个远程调用非常核心的部分。 transport 包:对网络传输层的抽象,但它只负责抽象单向消息的传输,即请求消息由 Client 端发出,Server 端接收;响应消息由 Server 端发出,Client端接收。有很多网络库可以实现网络传输的功能,例如 Netty、Grizzly 等, transport 包是在这些网络库上层的一层抽象。 其他接口:Endpoint、Channel、Transporter、Dispatcher 等顶层接口放到了org.apache.dubbo.remoting 这个包,这些接口是 Dubbo Remoting 的核心接口。
传输层核心接口
在 Dubbo 中会抽象出一个“端点(Endpoint)”的概念,我们可以通过一个 ip 和 port 唯一确定一个端点,两个端点之间会创建 TCP 连接,可以双向传输数据。Dubbo 将 Endpoint 之间的 TCP 连接抽象为通道(Channel),将发起请求的 Endpoint 抽象为客户端(Client),将接收请求的 Endpoint 抽象为服务端(Server)。
子主题
Client 和 Server 本身都是 Endpoint,只不过在语义上区分了请求和响应的职责,两者都具备发送的能力,所以都继承了 Endpoint 接口。Client 和 Server 的主要区别是 Client 只能关联一个 Channel,而 Server 可以接收多个 Client 发起的 Channel 连接
//Dubbo 在 Client 和 Server 之上又封装了一层Transporter 接口 @SPI("netty") public interface Transporter { @Adaptive({Constants.SERVER_KEY, Constants.TRANSPORTER_KEY}) RemotingServer bind(URL url, ChannelHandler handler) throws RemotingException; @Adaptive({Constants.CLIENT_KEY, Constants.TRANSPORTER_KEY}) Client connect(URL url, ChannelHandler handler) throws RemotingException; }
Transporter 接口上有 @SPI 注解,它是一个扩展接口,默认使用“netty”这个扩展名,@Adaptive 注解的出现表示动态生成适配器类,会先后根据“server”“transporter”的值确定 RemotingServer 的扩展实现类,先后根据“client”“transporter”的值确定 Client 接口的扩展实现。
针对每个支持的 NIO 库,都有一个 Transporter 接口实现,散落在各个 dubbo-remoting-* 实现模块中。 你应该已经发现 Transporter 这一层抽象出来的接口,与 Netty 的核心接口是非常相似的。那为什么要单独抽象出 Transporter层,而不是像简易版 RPC 框架那样,直接让上层使用 Netty 呢?
这些 Transporter 接口实现返回的 Client 和 RemotingServer 具体是什么呢?如下图所示,返回的是 NIO 库对应的 RemotingServer 实现和 Client 实现。
Netty、Mina、Grizzly 这个 NIO 库对外接口和使用方式不一样,如果在上层直接依赖了 Netty 或是 Grizzly,就依赖了具体的 NIO 库实现,而不是依赖一个有传输能力的抽象,后续要切换实现的话,就需要修改依赖和接入的相关代码,非常容易改出 Bug。这也不符合设计模式中的开放-封闭原则。 有了 Transporter 层之后,我们可以通过 Dubbo SPI 修改使用的具体 Transporter 扩展实现,从而切换到不同的 Client 和 RemotingServer 实现,达到底层 NIO 库切换的目的,而且无须修改任何代码。即使有更先进的 NIO 库出现,我们也只需要开发相应的 dubbo-remoting-* 实现模块提供 Transporter、Client、RemotingServer 等核心接口的实现,即可接入,完全符合开放-封闭原则。
子主题
//Transporters,它不是一个接口,而是门面类,其中封装了 Transporter 对象的创建(通过 Dubbo SPI)以及 ChannelHandler 的处理 public class Transporters { private Transporters() { // 省略bind()和connect()方法的重载 public static RemotingServer bind(URL url, ChannelHandler... handlers) throws RemotingException { ChannelHandler handler; if (handlers.length == 1) { handler = handlers[0]; } else { handler = new ChannelHandlerDispatcher(handlers); } return getTransporter().bind(url, handler); } public static Client connect(URL url, ChannelHandler... handlers) throws RemotingException { ChannelHandler handler; if (handlers == null || handlers.length == 0) { handler = new ChannelHandlerAdapter(); } else if (handlers.length == 1) { handler = handlers[0]; } else { // ChannelHandlerDispatcher handler = new ChannelHandlerDispatcher(handlers); } return getTransporter().connect(url, handler); } public static Transporter getTransporter() { // 自动生成Transporter适配器并加载 return ExtensionLoader.getExtensionLoader(Transporter.class) .getAdaptiveExtension(); } }
Endpoint 接口抽象了“端点”的概念,这是所有抽象接口的基础。 上层使用方会通过 Transporters 门面类获取到 Transporter 的具体扩展实现,然后通过 Transporter 拿到相应的 Client 和 RemotingServer 实现,就可以建立(或接收)Channel 与远端进行交互了。 无论是 Client 还是 RemotingServer,都会使用 ChannelHandler 处理 Channel 中传输的数据,其中负责编解码的 ChannelHandler 被抽象出为 Codec2 接口。
Transporter 层核心实现:编解码与线程模型一文打尽
AbstractPeer 抽象类
同时实现了 Endpoint 接口和 ChannelHandler 接口 bstractPeer 中有四个字段:一个是表示该端点自身的 URL 类型的字段,还有两个 Boolean 类型的字段(closing 和 closed)用来记录当前端点的状态,这三个字段都与 Endpoint 接口相关;第四个字段指向了一个 ChannelHandler 对象,AbstractPeer 对 ChannelHandler 接口的所有实现,都是委托给了这个 ChannelHandler 对象。从上面的继承关系图中,我们可以得出这样一个结论:AbstractChannel、AbstractServer、AbstractClient 都是要关联一个 ChannelHandler 对象的。
AbstractEndpoint 抽象类
AbstractEndpoint 继承了 AbstractPeer 这个抽象类。AbstractEndpoint 中维护了一个 Codec2 对象(codec 字段)和两个超时时间(timeout 字段和 connectTimeout 字段
Server 继承路线分析
子主题
子主题
Dubbo的架构和设计思路
1. 服务接口层(Service)
该层是与实际业务逻辑相关的,根据服务提供方和服务消费方的业务设计对应的接口和实现。
2. 配置层(Config)
对外配置接口,以ServiceConfig和ReferenceConfig为中心,可以直接new配置类,也可以通过spring解析配置生成配置类。
3. 服务代理层(Proxy)
服务接口透明代理,生成服务的客户端Stub和服务器端Skeleton,以ServiceProxy为中心,扩展接口为ProxyFactory。
4. 服务注册层(Registry)
封装服务地址的注册与发现,以服务URL为中心,扩展接口为RegistryFactory、Registry和RegistryService。可能没有服务注册中心,此时服务提供方直接暴露服务。
5. 集群层(Cluster)
封装多个提供者的路由及负载均衡,并桥接注册中心,以Invoker为中心,扩展接口为Cluster、Directory、Router和LoadBalance。将多个服务提供方组合为一个服务提供方,实现对服务消费方来透明,只需要与一个服务提供方进行交互。
6. 监控层(Monitor)
RPC调用次数和调用时间监控,以Statistics为中心,扩展接口为MonitorFactory、Monitor和MonitorService。
7. 远程调用层(Protocol)
封将RPC调用,以Invocation和Result为中心,扩展接口为Protocol、Invoker和Exporter。Protocol是服务域,它是Invoker暴露和引用的主功能入口,它负责Invoker的生命周期管理。Invoker是实体域,它是Dubbo的核心模型,其它模型都向它靠扰,或转换成它,它代表一个可执行体,可向它发起invoke调用,它有可能是一个本地的实现,也可能是一个远程的实现,也可能一个集群实现。
8. 信息交换层(Exchange)
封装请求响应模式,同步转异步,以Request和Response为中心,扩展接口为Exchanger、ExchangeChannel、ExchangeClient和ExchangeServer。
9. 网络传输层(Transport)
抽象mina和netty为统一接口,以Message为中心,扩展接口为Channel、Transporter、Client、Server和Codec。
10. 数据序列化层(Serialize)
可复用的一些工具,扩展接口为Serialization、 ObjectInput、ObjectOutput和ThreadPool。
Dubbo适用场景
RPC分布式服务
网站变大,不可避免的需要拆分应用进行服务化,以提高开发效率,调优性能,节省关键竞争资源等。
配置管理
当服务越来越多时,服务的URL地址信息就会爆炸式增长,配置管理变得非常困难,F5硬件负载均衡器的单点压力也越来越大。
服务依赖
当进一步发展,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,架构师都不能完整的描述应用的架构关系。
服务扩容
服务的调用量越来越大,服务的容量问题就暴露出来,这个服务需要多少机器支撑?什么时候该加机器?
Dubbo服务注册与发现的流程?
流程说明: 1. Provider(提供者)绑定指定端口并启动服务 2. 提供者连接注册中心,并发本机IP、端口、应用信息和提供服务信息发送至注册中心存储 3. Consumer(消费者),连接注册中心 ,并发送应用信息、所求服务信息至注册中心 4. 注册中心根据 消费 者所求服务信息匹配对应的提供者列表发送至Consumer 应用缓存。 5. Consumer 在发起远程调用时基于缓存的消费者列表择其一发起调用。 6. Provider 状态变更会实时通知注册中心、在由注册中心实时推送至Consumer
设计的原因
Consumer 与Provider 解偶,双方都可以横向增减节点数。 注册中心对本身可做对等集群,可动态增减节点,并且任意一台宕掉后,将自动切换到另一台 去中心化,双方不直接依懒注册中心,即使注册中心全部宕机短时间内也不会影响服务的调用 服务提供者无状态,任意一台宕掉后,不影响使用
Dubbo支持哪些协议,每种协议的应用场景,优缺点?
1. dubbo: 单一长连接和NIO异步通讯,适合大并发小数据量的服务调用,以及消费者远大于提供者。传输协议TCP,异步,Hessian序列化; 2. rmi: 采用JDK标准的rmi协议实现,传输参数和返回参数对象需要实现Serializable接口,使用java标准序列化机制,使用阻塞式短连接,传输数据包大小混合,消费者和提供者个数差不多,可传文件,传输协议TCP。 多个短连接,TCP协议传输,同步传输,适用常规的远程服务调用和rmi互操作。在依赖低版本的Common-Collections包,java序列化存在安全漏洞; 3. webservice: 基于WebService的远程调用协议,集成CXF实现,提供和原生WebService的互操作。多个短连接,基于HTTP传输,同步传输,适用系统集成和跨语言调用; 4. http: 基于Http表单提交的远程调用协议,使用Spring的HttpInvoke实现。多个短连接,传输协议HTTP,传入参数大小混合,提供者个数多于消费者,需要给应用程序和浏览器JS调用; 5. hessian: 集成Hessian服务,基于HTTP通讯,采用Servlet暴露服务,Dubbo内嵌Jetty作为服务器时默认实现,提供与Hession服务互操作。多个短连接,同步HTTP传输,Hessian序列化,传入参数较大,提供者大于消费者,提供者压力较大,可传文件; 6. memcache: 基于memcached实现的RPC协议 7. redis: 基于redis实现的RPC协议
Dubbo的服务治理
过多的服务URL配置困难 负载均衡分配节点压力过大的情况下也需要部署集群 服务依赖混乱,启动顺序不清晰 过多服务导致性能指标分析难度较大,需要监控
Dubbo集群提供了哪些负载均衡策略?
1. Random LoadBalance: 随机选取提供者策略,有利于动态调整提供者权重。截面碰撞率高, 2. RoundRobin LoadBalance: 轮循选取提供者策略,平均分布,但是存在请求累积的问题; 3. LeastActive LoadBalance: 最少活跃调用策略,解决慢提供者接收更少的请求; 4. ConstantHash LoadBalance: 一致性Hash策略,使相同参数请求总是发到同一提供者,一台机器宕机,可以基于虚拟节点,分摊至其他提供者,避免引起提供者的剧烈变动;
Dubbo的集群容错方案有哪些?
Failover Cluster 失败自动切换,当出现失败,重试其它服务器。通常用于读操作,但重试会带来更长延迟。 Failfast Cluster 快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。 Failsafe Cluster 失败安全,出现异常时,直接忽略。通常用于写入审计日志等操作。 Failback Cluster 失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。 Forking Cluster 并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks=”2″ 来设置最大并行数。 Broadcast Cluster 广播调用所有提供者,逐个调用,任意一台报错则报错 。通常用于通知所有提供者更新缓存或日志等本地资源信息。
Dubbo超时时间怎样设置?
Dubbo超时时间设置有两种方式: 1. 服务提供者端设置超时时间,在Dubbo的用户文档中,推荐如果能在服务端多配置就尽量多配置,因为服务提供者比消费者更清楚自己提供的服务特性。 2. 服务消费者端设置超时时间,如果在消费者端设置了超时时间,以消费者端为主,即优先级更高。因为服务调用方设置超时时间控制性更灵活。如果消费方超时,服务端线程不会定制,会产生警告。
Dubbo在安全机制方面是如何解决?
Dubbo通过Token令牌防止用户绕过注册中心直连,然后在注册中心上管理授权。Dubbo还提供服务黑白名单,来控制服务所允许的调用方。
Dubbo和Spring Cloud的关系?
Dubbo是 SOA 时代的产物,它的关注点主要在于服务的调用,流量分发、流量监控和熔断。而 Spring Cloud诞生于微服务架构时代,考虑的是微服务治理的方方面面,另外由于依托了 Spring、Spring Boot的优势之上,两个框架在开始目标就不一致,Dubbo 定位服务治理、Spirng Cloud 是一个生态。
Dubbo为什么设计代理层
主要是为了实现接口的透明代理,封装调用细节,让用户可以像调用本地方法一样调用远程方法。同时还可以通过代理实现一些其他的策略,比如 1.调用的负载均衡策略。 2.调用失败、超时、降级和容错机制。 3.做一些过滤操作,比如加入缓存、mock数据。 4.接口调用数据统计
实现一个RPC框架怎么设计?
1.首先需要一个服务注册中心(如zookeeper(CP)),这样consumer和provider才能去注册和订阅服务 2. 需要负载均衡的机制来决定consumer如何调用客户端,还要包含容错和重试的机制。 3. 需要通信协议(如hession2)和工具框架(netty),比如通过http或者rmi的协议通信,然后再根据协议选择使用什么框架和工具来进行通信,数据的传输序列化要考虑 4. 除了基本的要素之外,像一些监控、配置管理页面、日志是额外的优化考虑因素
spring
框架Spring
核心容器、数据访问/集成、WEB、AOP、工具、消息、测试模块
Container 负责实例化,配置和组装Bean,并将其注入到依赖调用者类中。Container 是管理 Spring 项目中 Bean 整个生命周期的管理者,包括 Bean 的创建、注册、存储、获取、销毁等等
ApplicationContext是实现容器的接口类, 其中 ClassPathXmlApplicationContext就是一个 Container 的具体实现,类似的还有 FileSystemXmlApplicationContext,这两个是都是解析 xml 格式配置的容器。
6个特征
核心技术:依赖注入、AOP、事件、资源、验证、数据绑定、类型转换、SpEL 测试:模拟对象、TestContext框架、SpringMVC测试、WebTestClient 数据访问:事务、DAO支持、JDBC、ORM、编组XML 集成:远程处理、JMS、JCA、JMX,电子邮件、任务、调度、缓存。 语言:Kotlin、Groovy、动态语言。
IOC
描述: 将原本在程序中手动创建对象的控制权,交由Spring框架来管理。IOC容器是Spring用来实现IOC的载体,IOC容器实际就是Map,Map中存放的是各种对象。 将对象之间的相互依赖关系交给IOC容器来管理,并由IOC容器完成对象的注入。IOC容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何创建出来的。
Spring 容器使用依赖注入来管理组成应用程序的 组件。容器通过读取提供的配置元数据来接收对象进行实例化,配置和组装的指 令。该元数据可以通过 XML,Java 注解或 Java 代码提供。 不是直接在代码 中将组件和服务连接在一起,而是描述配置文件中哪些组件需要哪些服务。由 IoC 容器将它们装配在一起。 那么简单来概括一下注入的核心其实就是解析 xml 文件的内容,找到 元素,然后经过一系列加工,最后把这些加工后的对象存到一个公共空间,供调用者获取使用。 而至于使用注解方式的 bean,比如使用 @Bean、@Service、@Component 等注解的,只是解析这一步不一样而已,剩下的操作基本都一致。
依赖注入
set方法注入
构造方法注入
自动注入(byName, byType)
方法注入(lookup-method)
IOC容器
1. BeanFactory - BeanFactory 就像一个包含 bean 集合的工厂类。它会在客户端要求时实例化 bean。 2. ApplicationContext - ApplicationContext 接口扩展了BeanFactory 接口。它在 BeanFactory 基础上提供了一些额外的功能。
BeanFactory 和 ApplicationContext 的关系
BeanFactory 是一个接口,ApplicationContext 也是一个接口,而且,BeanFactory 是 ApplicationContext的父接口,ApplicationContext 初始化时就实例化所有 Bean,而BeanFactory 用到时再实例化所用 Bean
ApplicationContext三个实现类
ClassPathXmlApplication:把上下文文件当成类路径资源。 FileSystemXmlApplication:从文件系统中的 XML 文件载入上下文定义信息。 XmlWebApplicationContext:从Web系统中的XML文件载入上下文定义信息。
初始化过程
子主题
Bean的构建
1.基于Class构建, 2.构造方法构建, 3.静态工厂方法创建, 4.FactoryBean创建
BeanDefinition
Bean注册器
id作为当前Bean存储key注册到了BeanDefinitionRegistry注册器中。 name作为别名key注册到了AliasRegistry注册中心。
从xml Bean到BeanDefinition然后注册到BeanDefinitionRegister整个过程
注册中心 读取器 资源读取器 装载构建Bean定义
BeanDefinition保存在哪
private final Map<String, BeanDefinition> beanDefinitionMap = new ConcurrentHashMap<>(256)
生命周期
生命周期
Spring 容器根据实例化策略对 Bean 进行实例化。 实例化完成后,如果该 bean 设置了一些属性的话,则利用 set 方法设置一些属性。 如果该 Bean 实现了 BeanNameAware 接口,则调用 setBeanName() 方法。 如果该 bean 实现了 BeanClassLoaderAware 接口,则调用 setBeanClassLoader() 方法。 如果该 bean 实现了 BeanFactoryAware接口,则调用 setBeanFactory() 方法。 如果该容器注册了 BeanPostProcessor,则会调用postProcessBeforeInitialization() 方法完成 bean 前置处理 如果该 bean 实现了 InitializingBean 接口,则调用 。afterPropertiesSet() 方法。 如果该 bean 配置了 init-method 方法,则调用 init-method 指定的方法。 初始化完成后,如果该容器注册了 BeanPostProcessor 则会调用 postProcessAfterInitialization() 方法完成 bean 的后置处理。 对象完成初始化,开始方法调用。 在容器进行关闭之前,如果该 bean 实现了 DisposableBean 接口,则调用 destroy() 方法。 在容器进行关闭之前,如果该 bean 配置了 destroy-mehod,则调用其指定的方法。 到这里一个 bean 也就完成了它的一生。
4个阶段
描述
在分析 Spring Bean 实例化过程中提到 Spring 并不是一启动容器就开启 bean 的实例化进程,只有当客户端通过显示或者隐式的方式调用 BeanFactory 的 getBean() 方法来请求某个实例对象的时候,它才会触发相应 bean 的实例化进程,当然也可以选择直接使用 ApplicationContext 容器,因为该容器启动的时候会立刻调用注册到该容器所有 bean 定义的实例化方法。当然对于 BeanFactory 容器而言并不是所有的 getBean() 方法都会触发实例化进程,比如 signleton 类型的 bean,该类型的 bean 只会在第一次调用 getBean() 的时候才会触发,而后续的调用则会直接返回容器缓存中的实例对象。
public class lifeCycleBean implements BeanNameAware,BeanFactoryAware,BeanClassLoaderAware,BeanPostProcessor, InitializingBean,DisposableBean { private String test; public String getTest() { return test; } public void setTest(String test) { System.out.println("属性注入...."); this.test = test; } public lifeCycleBean(){ System.out.println("构造函数调用..."); } public void display(){ System.out.println("方法调用..."); } @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { System.out.println("BeanFactoryAware 被调用..."); } @Override public void setBeanName(String name) { System.out.println("BeanNameAware 被调用..."); } @Override public void setBeanClassLoader(ClassLoader classLoader) { System.out.println("BeanClassLoaderAware 被调用..."); } @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { System.out.println("BeanPostProcessor postProcessBeforeInitialization 被调用..."); return bean; } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { System.out.println("BeanPostProcessor postProcessAfterInitialization 被调用..."); return bean; } @Override public void destroy() throws Exception { System.out.println("DisposableBean destroy 被调动..."); } @Override public void afterPropertiesSet() throws Exception { System.out.println("InitializingBean afterPropertiesSet 被调动..."); } public void initMethod(){ System.out.println("init-method 被调用..."); } public void destroyMethdo(){ System.out.println("destroy-method 被调用..."); } }
1.实例化
在实例化 bean 过程中,Spring 采用“策略模式”来决定采用哪种方式来实例化 bean,一般有反射和 CGLIB 动态字节码两种方式。
InstantiationStrategy 定义了 Bean 实例化策略的抽象接口,其子类 SimpleInstantiationStrategy 提供了基于反射来实例化对象的功能,但是不支持方法注入方式的对象实例化。CglibSubclassingInstantiationStrategy 继承 SimpleInstantiationStrategy,他除了拥有父类以反射实例化对象的功能外,还提供了通过 CGLIB 的动态字节码的功能进而支持方法注入所需的对象实例化需求。默认情况下,Spring 采用 CglibSubclassingInstantiationStrategy。
反射为Spring框架提供了强大的动态性和灵活性,使得Spring能够支持广泛的配置选项、实现依赖注入、支持AOP、易于扩展和插件化,以及实现容器化管理
2.属性赋值
bean 设置了一些属性的话,则利用 set 方法设置一些属性。
3.初始化
当 Spring 完成 bean 对象实例化并且设置完相关属性和依赖后,则会开始 bean 的初始化进程(initializeBean()),初始化第一个阶段是检查当前 bean 对象是否实现了一系列以 Aware 结尾的的接口。 Aware 接口为 Spring 容器的核心接口,是一个具有标识作用的超级接口,实现了该接口的 bean 是具有被 Spring 容器通知的能力,通知的方式是采用回调的方式。
第一阶段,检查bean实现Aware接口
BeanNameAware:对该 bean 对象定义的 beanName 设置到当前对象实例中 BeanClassLoaderAware:将当前 bean 对象相应的 ClassLoader 注入到当前对象实例中 BeanFactoryAware:BeanFactory 容器会将自身注入到当前对象实例中,这样当前对象就会拥有一个 BeanFactory 容器的引用。
一系列: LoadTimeWeaverAware:加载Spring Bean时织入第三方模块,如AspectJ BootstrapContextAware:资源适配器BootstrapContext,如JCA,CCI ResourceLoaderAware:底层访问资源的加载器 PortletConfigAware:PortletConfig PortletContextAware:PortletContext ServletConfigAware:ServletConfig ServletContextAware:ServletContext MessageSourceAware:国际化 ApplicationEventPublisherAware:应用事件 NotificationPublisherAware:JMX通知
第二阶段,增强处理,该阶段 BeanPostProcessor 会处理当前容器内所有符合条件的实例化后的 bean 对象。它主要是对 Spring 容器提供的 bean 实例对象进行有效的扩展,允许 Spring 在初始化 bean 阶段对其进行定制化修改,如处理标记接口或者为其提供代理实现。 BeanPostProcessor、InitializingBean、init-method
public interface BeanPostProcessor { @Nullable default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { return bean; } @Nullable default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { return bean; } }
InitializingBean 是一个接口,它为 Spring Bean 的初始化提供了一种方式,它有一个 afterPropertiesSet() 方法,在 bean 的初始化进程中会判断当前 bean 是否实现了 InitializingBean,如果实现了则调用 afterPropertiesSet() 进行初始化工作。然后再检查是否也指定了 init-method(),如果指定了则通过反射机制调用指定的 init-method()。
总结:上面两种方式都可以实现初始化定制化,但是更加推崇 init-method 方式,因为对于 InitializingBean 接口而言,他需要 bean 去实现接口,这样就会污染我们的应用程序,显得 Spring 具有一定的侵入性。但是由于 init-method 是采用反射的方式,所以执行效率上相对于 InitializingBean 接口回调的方式可能会低一些。
4.销毁
DisposableBean和 destroy-method 则用于对象的自定义销毁工作。
当一个 bean 对象经历了实例化、设置属性、初始化阶段,那么该 bean 对象就可以供容器使用了(调用的过程)。当完成调用后,如果是 singleton 类型的 bean ,则会看当前 bean 是否应实现了 DisposableBean 接口或者配置了 destroy-method 属性,如果是的话,则会为该实例注册一个用于对象销毁的回调方法,便于在这些 singleton 类型的 bean 对象销毁之前执行销毁逻辑。 但是,并不是对象完成调用后就会立刻执行销毁方法,因为这个时候 Spring 容器还处于运行阶段,只有当 Spring 容器关闭的时候才会去调用。但是, Spring 容器不会这么聪明会自动去调用这些销毁方法,而是需要我们主动去告知 Spring 容器。 对于 BeanFactory 容器而言,我们需要主动调用 destroySingletons() 通知 BeanFactory 容器去执行相应的销毁方法。 对于 ApplicationContext 容器而言调用 registerShutdownHook() 方法。
扩展点
aware接口
若 Spring 检测到 bean 实现了 Aware 接口,则会为其注入相应的依赖。所以通过让bean 实现 Aware 接口,则能在 bean 中获得相应的 Spring 容器资源。
BeanNameAware:注入当前 bean 对应 beanName; BeanClassLoaderAware:注入加载当前 bean 的 ClassLoader; BeanFactoryAware:注入 当前BeanFactory容器 的引用。
BeanPostProcessor
BeanPostProcessor 是 Spring 为修改 bean提供的强大扩展点,其可作用于容器中所有 bean
InitializingBean 和 init-method
InitializingBean 和 init-method 是 Spring 为 bean 初始化提供的扩展点。
DisposableBean 和 destory-method
spring容器
各个spring组件组装在一起,比如单例池(存放bean),BeanFactory、spring扫描器、spring读取器、spring后置处理器等
定制容器 这两个可以用户设置,共同与spring定制容器
扫描类-BeanDefinition-put-map-DefaultListableBeanFactory
BeanFactory、FactoryBean区别,普通Bean放在单例池中,FactoryBean
spring循环依赖
调用两次DefaultSingletonBeanRegistry#getSingleton方法
循环依赖其实就是循环引用,就是两个或者两个以上的 bean 互相引用对方,最终形成一个闭环,如 A 依赖 B,B 依赖 C,C 依赖 A
1. 构造器的循环依赖 2. field 属性的循环依赖
解决循环依赖
先从加载 bean 最初始的方法 doGetBean() 开始。 在 doGetBean() 中,首先会根据 beanName 从单例 bean 缓存中获取,如果不为空则直接返回。 从三个缓存中获取,分别是:singletonObjects、earlySingletonObjects、singletonFactories
protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) { synchronized (this.singletonObjects) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null && allowEarlyReference) { ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } return singletonObject; }
Spring 关于 singleton bean 循环依赖已经分析完毕了。所以我们基本上可以确定 Spring 解决循环依赖的方案了:Spring 在创建 bean 的时候并不是等它完全完成,而是在创建过程中将创建中的 bean 的 ObjectFactory 提前曝光(即加入到 singletonFactories 缓存中),这样一旦下一个 bean 创建的时候需要依赖 bean ,则直接使用 ObjectFactory 的 getObject() 获取了,也就是 getSingleton() 中的代码片段了。 到这里,关于 Spring 解决 bean 循环依赖就已经分析完毕了。
map存放在DefaultListableBeanFactory
Aop
描述
能够将那些与业务无关,却为业务模块共同调用的逻辑或责任(事务处理、日志管理、权限管理等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。 面向切面编程,是通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。通过AOP技术,实现一种通用逻辑的解耦,解决一些系统层面上的问题,如日志、事务、权限等,提高应用的可重用性和可维护性,和开发效率。
风格
AspectJ support
Schema-based AOP support xml
概念
1.切面(Aspect):@Aspect 放类上,通知(Advice)和切点(Pointcut)合起来的抽象 2.连接点(JoinPoint):具体的业务方法,表示方法的执行 3.通知(Advice):指一个切面在特定的连接点要做的事情。 4.切入点(Pointcut):切点是匹配连接点(Join point)的表达式的概念。表达式(pointcut(execution(* com...))),带有通知的连接点 5.AOP代理(Proxy)。 记忆口诀:通知 代理 厨师两点(连接点、切入点)切面包。
通知类型
1、前置通知 [ Before advice ] :在连接点前面执行,前置通知不会影响连接点的执行,除非此处抛出异常; 2、正常返回通知 [ After returning advice ] :在连接点正常执行完成后执行,如果连接点抛出异常,则不会执行; 3、异常返回通知 [ After throwing advice ] :在连接点抛出异常后执行; 4、返回通知 [ After (finally) advice ] :在连接点执行完成后执行,不管正常执行完成,还是抛出异常,都会执行返回通知中的内容; 5、环绕通知 [ Around advice ] :环绕通知围绕在连接点前后,比如一个方法调用的前后。这种通知是最强大的通知,能在方法调用前后自定义一些操作。
@Before 对应——>前置通知 [ Before advice ] @AfterReturning 对应——>正常返回通知 [ After returning advice ] @AfterThrowing 对应——>异常返回通知 [ After throwing advice ] @After 对应——>返回通知 [ After (finally) advice ] @Around 对应——>环绕通知 [ Around advice ]
AOP代理,是AOP框架如Spring AOP创建的对象,代理就是对目标对象进行增强,Spring AOP中的代理默认使用JDK动态代理,同时支持CGLIB代理,前者基于接口,后者基于子类。在Spring AOP中,其功能依然离不开IOC容器,代理的生成、管理以及其依赖关系都是由IOC容器负责,而根据目前的开发提倡“面向接口编程”,因此大多使用JDK动态代理。
底层技术
描述:代理对象在spring初始化时创建的代理对象,放在了并发Map中
JDK动态代理-基于接口
JDK 动态代理机制只能对接口进行代理,其原理是动态生成一个代理类,这个代理类实现了目标对象的接口,目标对象和代理类都实现了接口,但是目标对象和代理类的 Class 对象是不一样的,所以两者是没法相互赋值的。 JDK 使用反射机制调用目标类的方法 ------ 在运行时执行植入。 JDK底层extends Proxy,所以基于接口创建代理对象。 二进制文件如何变成类的?defineClass装入JVM(调本地方法)
JDK 动态代理是对接口进行的代理;代理类实现了接口,并继承了 Proxy 类;目标对象与代理对象没有什么直接关系,只是它们都实现了接口,并且代理对象执行方法时候内部最终是委托目标对象执行具体的方法。
生成的 proxy 对象实际就是 $Proxy0的一个实例,当调用 proxy.add 时候,实际是调用的代理类$Proxy0的 add 方法,后者内部则委托给 MyInvocationHandler 的 invoke 方法,invoke 方法内部有调用了目标对象 service 的 add 方法。
public class MyInvocationHandler implements InvocationHandler { /** * 目标对象 */ private Object target; public MyInvocationHandler(Object target) { super(); this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { //(1) System.out.println("-----------------begin " + method.getName() + "-----------------"); //(2) Object result = method.invoke(target, args); //(3) System.out.println("-----------------end " + method.getName() + "-----------------"); return result; } public Object getProxy() { //(4) return Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), target.getClass().getInterfaces(), this); } public static void main(String[] args) { //(5)打开这个开关,可以把生成的代理类保存到磁盘 System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true"); //(6)创建目标对象(被代理对象) UserServiceBo service = new UserServiceImpl(); //(7)创建一个InvocationHandler实例,并传递被代理对象 MyInvocationHandler handler = new MyInvocationHandler(service); //(8)生成代理类 UserServiceBo proxy = (UserServiceBo) handler.getProxy(); proxy.add(); } }
CGLIB代理-基于子类
CGLIB 是对目标对象本身进行代理,所以无论目标对象是否有接口,都可以对目标对象进行代理,其原理是使用字节码生成工具在内存生成一个继承目标对象的代理类,然后创建代理对象实例。由于代理类的父类是目标对象,所以代理类是可以赋值给目标对象的,自然如果目标对象有接口,代理对象也是可以赋值给接口的。 CGLIB 则使用类似索引的方式直接调用目标类方法 -------- 在编译时,类加载时,运行时完成
子主题
springAOP与AspectJ的关系
AspectJ
而 AspectJ 是编译时增强。AspectJ 基于字节码操作(Bytecode Manipulation)。
springAOP
Spring AOP 属于运行时增强, Spring AOP 基于代理(Proxying)
事务
事物特性
原子性: 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用; 一致性: 执行事务前后,数据保持一致; 隔离性: 并发访问数据库时,一个用户的事物不被其他事物所干扰,各并发事务之间数据库是独立的; 持久性: 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
隔离级别
并发事务带来的问题
1. 脏读(Dirty read): 读到未提交数据,就是脏数据 (未提交数据) 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。 2. 丢失修改(Lost to modify): 两个事务同时修改了同一个数据,则其中一个事务修改结果丢失 (修改) 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1,事务2也修改A=A-1,最终结果A=19,事务1的修改被丢失。 3. 不可重复读(Unrepeatableread): 一个事务中两次读取到的同一个数据不一致 (修改) 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。 4. 幻读(Phantom read): 一个事务读取取多行,再次读取发现多出一些行 (新增或删除) 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。
ISOLATION_DEFAULT: 使用后端数据库默认的隔离级别 ISOLATION_READ_UNCOMMITTED(读未提交): 允许读取未提交的数据变更,可能会导致脏读,幻读或不可重复读 ISOLATION_READ_COMMITTD(读已提交) : 允许读取为提交数据,可以阻止脏读,当时幻读或不可重复读仍可能发生 ISOLATION_REPEATABLE_READ(可重复读): 对统一字段多次读取结果是一致的,除非数据是被本事务自己修改.可以阻止脏读,不可重复读,但幻读可能发生 ISOLATION_SERIALIZABLE(串行) : 完全服从ACID,所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰
事务管理
所谓事务管理,其实就是“按照给定的事务规则来执行提交或者回滚操作”。
事务管理方式有2种
一种是传统的编程序事务管理,即通过代码来管理事务的开始、执行和异常以及回滚
一种是声明式管理,即通过配置文件的方式,原理是通过AOP技术实现,我们在实际开发过程中推荐使用声明式事务管理,效率会大大提升,因为只需要通过配置即可。
@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.DEFAULT, readOnly = false)
事务属性
事务失效
数据库引擎不支持事务
没有被 Spring 管理
// @Service public class OrderServiceImpl implements OrderService { @Transactional public void updateOrder(Order order) { // update order } }
方法不是 public 的
@Transactional 只能用于 public 的方法上,否则事务不会失效,如果要用在非 public 方法上,可以开启 AspectJ 代理模式。
自身调用问题
@Service public class OrderServiceImpl implements OrderService { public void update(Order order) { updateOrder(order); } @Transactional public void updateOrder(Order order) { // update order } }
@Service public class OrderServiceImpl implements OrderService { @Transactional public void update(Order order) { updateOrder(order); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateOrder(Order order) { // update order } }
这次在 update 方法上加了 @Transactional,updateOrder 加了 REQUIRES_NEW 新开启一个事务,那么新开的事务管用么? 这两个例子的答案是:不管用!
因为它们发生了自身调用,就调该类自己的方法,而没有经过 Spring 的代理类,默认只有在外部调用事务才会生效,这也是老生常谈的经典问题了。 这个的解决方案之一就是在的类中注入自己,用注入的对象再调用另外一个方法,这个不太优雅,另外一个可行的方案可以参考《Spring 如何在一个事务中开启另一个事务?》这篇文章。
数据源没有配置事务管理器
@Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }
不支持事务
@Service public class OrderServiceImpl implements OrderService { @Transactional public void update(Order order) { updateOrder(order); } @Transactional(propagation = Propagation.NOT_SUPPORTED) public void updateOrder(Order order) { // update order } }
Propagation.NOT_SUPPORTED: 表示不以事务运行,当前若存在事务则挂起
异常被吃了
// @Service public class OrderServiceImpl implements OrderService { @Transactional public void updateOrder(Order order) { try { // update order } catch { } } }
异常类型错误
// @Service public class OrderServiceImpl implements OrderService { @Transactional public void updateOrder(Order order) { try { // update order } catch { throw new Exception("更新错误"); } } }
这样事务也是不生效的,因为默认回滚的是:RuntimeException,如果你想触发其他异常的回滚,需要在注解上配置一下,如: @Transactional(rollbackFor = Exception.class) 这个配置仅限于 Throwable 异常类及其子类。
MVC
子主题
Controller
不加@ResponseBody一般使用在要返回视图的情况,属于前后端不分离的情况
RestController
返回JSON或XML形式数据,属于RESTful Web服务,前后端分离
注解式开发
spring 5新特性
扩展点
BeanFactoryPostProcessor(bean工厂后处理器)
源码中的优秀设计模式
工厂设计模式
Spring使用工厂模式可以通过 BeanFactory 或 ApplicationContext 创建 bean 对象。
单例设计模式
Spring 通过 ConcurrentHashMap 实现单例注册表的特殊方式实现单例模式。
对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销; 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。
例:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象
代理设计模式
AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。
模板方法
模板方法模式是一种行为设计模式,它定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。 模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤的实现方式。
Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。一般情况下,我们都是使用继承的方式来实现模板模式,但是 Spring 并没有使用这种方式,而是使用Callback 模式与模板方法模式配合,既达到了代码复用的效果,同时增加了灵活性。
观察者模式
观察者模式是一种对象行为型模式。它表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,这个对象所依赖的对象也会做出反应。Spring 事件驱动模型就是观察者模式很经典的一个应用。Spring 事件驱动模型非常有用,在很多场景都可以解耦我们的代码。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。
适配器模式
适配器模式(Adapter Pattern) 将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作,其别名为包装器(Wrapper)。
Spring MVC 中的 Controller 种类众多,不同类型的 Controller 通过不同的方法来对请求进行处理
装饰者模式
装饰者模式可以动态地给对象添加一些额外的属性或行为。相比于使用继承,装饰者模式更加灵活。简单点儿说就是当我们需要修改原有的功能,但我们又不愿直接去修改原有的代码时,设计一个Decorator套在原有代码外面
spring 中用到的包装器模式在类名上含有 Wrapper或者 Decorator。这些类基本上都是动态地给一个对象添加一些额外的职责
springboot
项目搭建
配置文件
file:./config/ (当前项目路径config目录下); file:./ (当前项目路径下); classpath:/config/ (类路径config目录下); classpath:/ (类路径config下).
启动流程
@SpringBootApplication注解由三个注解组合而成 @ComponentScan @EnableAutoConfiguration @SpringBootConfiguration 1. 加载启动类 当 SpringBoot 项目启动时,会在当前工作目录下寻找有@SpringBootApplication注解标识的类,并把这个类作为应用程序的入口点。如果找不到这样的主类,则会打印错误信息并退出。 2. 加载配置文件 SpringBoot会自动扫描当前项目的resources目录,并加载其中的application.properties或application.yml等配置文件。这些配置文件中包含了应用程序的各种参数,例如数据库连接信息、日志级别等等。 3. 初始化ApplicationContext SpringBoot会创建一个SpringApplication对象,并调用其run()方法来初始化ApplicationContext。这个过程中,SpringBoot会读取配置文件中的参数,并将它们转换成Environment对象中的属性。同时,它还会扫描当前项目中的所有Bean,并将它们注册到ApplicationContext中。 4. 启动内嵌的Web服务器 SpringBoot内置Tomcat和Jetty等Web服务器,当SpringBoot应用程序启动时,它会根据配置文件中的信息自动创建Tomcat或Jetty等Web容器,并将Spring容器注册到Web容器中,使得SpringBoot应用程序可以直接以Web应用程序的形式运行。 5. 启动应用程序 根据之前创建好的 Spring 容器以及Web服务器,启动相应的线程进行服务处理。 6. 监听应用程序的状态 SpringBoot会监听应用程序的状态,并根据需要进行相应的操作。例如,当应用程序退出时,它会打印出详细的日志信息,并将应用程序的上下文环境恢复到初始状态。
@SpringBootApplication注解 一个main()方法,里面调用SpringApplication.run()方法。
1. ComponentScan告诉Spring扫描哪个包下面类,加载符合条件的组件。 2. EnableAutoConfiguration关键在@Import注解,它会加载AutoConfigurationImportSelector类,然后就会触发这个类的selectImports()方法。根据返回的String数组(配置类的Class的名称)加载配置类。一直点下去,就可以找到最后的幕后英雄,就是SpringFactoriesLoader类,通过loadSpringFactories()方法加载META-INF/spring.factories中的配置类。这里使用了spring.factories文件的方式加载配置类,提供了很好的扩展性。 所以@EnableAutoConfiguration注解的作用其实就是开启自动配置,自动配置主要则依靠这种加载方式来实现。 3. SpringBootConfiguration继承自@Configuration,二者功能也一致,标注当前类是配置类, 并会将当前类内声明的一个或多个以@Bean注解标记的方法的实例纳入到spring容器中,并且实例名就是方法名。
SpringApplication类
实例化SpringApplication
创建了SpringApplication实例之后,就完成了SpringApplication类的初始化工作,这个实例里包括监听器、初始化器,项目应用类型,启动类集合,类加载器。
public ConfigurableApplicationContext run(String... args) { //创建计时器 StopWatch stopWatch = new StopWatch(); //开始计时 stopWatch.start(); //定义上下文对象 ConfigurableApplicationContext context = null; Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>(); //Headless模式设置 configureHeadlessProperty(); //加载SpringApplicationRunListeners监听器 SpringApplicationRunListeners listeners = getRunListeners(args); //发送ApplicationStartingEvent事件 listeners.starting(); try { //封装ApplicationArguments对象 ApplicationArguments applicationArguments = new DefaultApplicationArguments(args); //配置环境模块 ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments); //根据环境信息配置要忽略的bean信息 configureIgnoreBeanInfo(environment); //打印Banner标志 Banner printedBanner = printBanner(environment); //创建ApplicationContext应用上下文 context = createApplicationContext(); //加载SpringBootExceptionReporter exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class, new Class[] { ConfigurableApplicationContext.class }, context); //ApplicationContext基本属性配置 prepareContext(context, environment, listeners, applicationArguments, printedBanner); //刷新上下文 refreshContext(context); //刷新后的操作,由子类去扩展 afterRefresh(context, applicationArguments); //计时结束 stopWatch.stop(); //打印日志 if (this.logStartupInfo) { new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch); } //发送ApplicationStartedEvent事件,标志spring容器已经刷新,此时所有的bean实例都已经加载完毕 listeners.started(context); //查找容器中注册有CommandLineRunner或者ApplicationRunner的bean,遍历并执行run方法 callRunners(context, applicationArguments); } catch (Throwable ex) { //发送ApplicationFailedEvent事件,标志SpringBoot启动失败 handleRunFailure(context, ex, exceptionReporters, listeners); throw new IllegalStateException(ex); } try { //发送ApplicationReadyEvent事件,标志SpringApplication已经正在运行,即已经成功启动,可以接收服务请求。 listeners.running(context); } catch (Throwable ex) { //报告异常,但是不发送任何事件 handleRunFailure(context, ex, exceptionReporters, null); throw new IllegalStateException(ex); } return context; }
总结
表面启动类看起来就一个@SpringBootApplication注解,一个run()方法。其实是经过高度封装后的结果。我们可以从这个分析中学到很多东西。比如使用了spring.factories文件来完成自动配置,提高了扩展性。在启动时使用观察者模式,以事件发布的形式通知,降低耦合,易于扩展等等。
springboot与内嵌tomcat
Spring Boot 应用与内嵌 Tomcat 的对接以及处理 HTTP 请求的交互过程是一个相对复杂但高度集成的流程。下面我将详细解释这个过程,以便你能够深入理解。 1. 启动 Spring Boot 应用 当你运行 Spring Boot 应用的主类(带有 @SpringBootApplication 注解的类)时,Spring Boot 的自动配置机制开始工作。 2. 自动配置 Servlet 容器 由于你的项目依赖了 spring-boot-starter-web,Spring Boot 会自动检测并配置一个内嵌的 Servlet 容器。默认情况下,这个容器是 Tomcat,但也可以是 Jetty 或 Undertow。 3. Tomcat 初始化 Spring Boot 创建一个 TomcatServletWebServerFactory 的实例,该实例负责配置和创建 Tomcat 嵌入式 Servlet 容器。这包括设置端口、连接器、SSL 配置等。 4. 注册 Servlet 和 Filter Spring Boot 通过扫描 @WebServlet、@WebFilter 等注解以及 Spring MVC 的配置来注册 Servlet 和 Filter。这些组件随后被添加到 Tomcat 的 Servlet 上下文中,以便能够处理 HTTP 请求。 5. 启动 Tomcat 一旦 Tomcat 配置完成并且所有的 Servlet 和 Filter 都已注册,Spring Boot 就会启动 Tomcat。这涉及到初始化 Tomcat 的生命周期监听器、启动 Tomcat 的线程池、绑定到指定的端口等操作。 6. 处理 HTTP 请求 当 Tomcat 接收到一个 HTTP 请求时,它会根据请求的 URL 和配置的 Servlet 映射找到相应的 Servlet 实例。然后,Tomcat 将请求和响应对象传递给 Servlet 的 service 方法。 在 Spring Boot 应用中,通常不会直接实现 Servlet,而是使用 Spring MVC 的 @Controller 或 @RestController 注解来定义处理请求的方法。这些方法通过 Spring MVC 的 DispatcherServlet 来处理。DispatcherServlet 是 Spring MVC 的前端控制器,它负责接收请求、解析请求、调用相应的处理器方法,并生成响应。 7. 交互与集成 Spring Boot 应用与 Tomcat 之间的交互主要体现在以下几个方面: 请求分发:Tomcat 接收 HTTP 请求,并将其转发给 Spring MVC 的 DispatcherServlet。 处理器映射:DispatcherServlet 根据请求的 URL 找到相应的处理器方法(即 @Controller 或 @RestController 注解的方法)。 参数绑定:Spring MVC 将请求中的参数绑定到处理器方法的参数上。 异常处理:如果处理器方法抛出异常,Spring MVC 会调用配置的异常处理器来处理这些异常。 响应生成:处理器方法执行完毕后,Spring MVC 生成响应对象,并将其返回给 Tomcat。Tomcat 将响应发送回客户端。 8. 关闭与资源释放 当 Spring Boot 应用关闭时,它会优雅地停止 Tomcat,并释放相关的资源。这包括关闭 Tomcat 的线程池、停止监听端口、清理 Servlet 上下文等操作。 通过这个过程,Spring Boot 应用能够无缝地与内嵌 Tomcat 集成,使得开发者能够专注于实现业务逻辑,而无需关心底层的 Servlet 容器细节。
springboot池程池和tomcat线程池
线程分配与交互 Tomcat 的线程池和 Spring Boot 应用创建的线程池是独立管理的,它们之间没有直接的线程分配机制。Tomcat 的线程池专门用于处理 HTTP 请求,而 Spring Boot 应用的线程池用于执行其他类型的任务。 当一个 HTTP 请求到达时,Tomcat 会从自己的线程池中分配一个线程来处理该请求。如果请求中需要调用 Spring Boot 应用中的异步方法或执行后台任务,那么这些方法或任务会在 Spring Boot 应用配置的线程池中执行。这两个线程池之间是通过方法调用、消息传递或其他同步/异步机制进行交互的,而不是直接共享线程。 需要注意的是,虽然这两个线程池是独立的,但它们都运行在相同的 Java 虚拟机(JVM)进程中,因此它们可以共享相同的内存空间和资源。但是,由于线程是并发执行的,因此需要小心处理共享资源的访问,以避免竞态条件和数据不一致的问题。 总结来说,Spring Boot 应用和内嵌 Tomcat 应用各自有自己的线程池,用于处理不同类型的任务。它们之间通过方法调用和同步/异步机制进行交互,但线程本身是分开管理的。
是什么支撑springboot和tomcat一直在运行中
在 Spring Boot 应用与内嵌 Tomcat 启动后,它们能够持续运行在运行状态,主要依赖于以下几个关键机制,而不是简单地依赖于一个无限循环的线程。 1. 主线程与启动流程 当你运行 Spring Boot 应用的主类时,JVM 启动一个主线程来执行你的 main 方法。在这个方法中,Spring Boot 的启动流程开始执行,包括自动配置、初始化 Spring 容器、以及启动内嵌的 Tomcat。 2. Servlet 容器的生命周期管理 内嵌 Tomcat 作为一个 Servlet 容器,有自己的生命周期管理。当 Tomcat 启动时,它会初始化其组件,包括连接器、线程池等,并开始监听指定的端口。Tomcat 的启动通常是由 Spring Boot 框架自动触发的,作为 Spring 容器初始化的一部分。 一旦 Tomcat 启动,它就会进入一个等待状态,准备接收并处理 HTTP 请求。这个过程并不是通过一个无限循环来实现的,而是基于事件驱动和并发处理模型。Tomcat 使用 NIO(非阻塞 I/O)或 BIO(阻塞 I/O)等技术来高效地处理并发请求。 3. Spring 容器的生命周期管理 Spring 容器负责管理 Spring Boot 应用中的 Bean 生命周期。当 Spring 容器初始化时,它会创建并初始化所有的单例 Bean,包括服务、控制器、组件等。这些 Bean 一旦被创建并初始化,就会保持在内存中,直到 Spring 容器关闭。 Spring 容器本身并没有一个无限循环来保持其运行。相反,它依赖于其他机制来保持其活跃状态: 事件监听:Spring 容器支持事件监听机制,允许 Bean 监听特定的事件并作出响应。例如,当一个新的 HTTP 请求到达时,Tomcat 会触发一个事件,Spring MVC 的相关组件会监听这个事件并处理请求。 定时任务:如果 Spring Boot 应用中配置了定时任务(使用 @Scheduled 注解),Spring 容器会调度这些任务按照指定的时间间隔执行。这通常是通过一个后台线程池来实现的,而不是无限循环。 异步处理:对于使用 @Async 注解标记的异步方法,Spring 会使用一个线程池来执行这些方法。这些线程池中的线程会等待并执行异步任务,而不是进行无限循环。 4. 阻塞与等待 在大多数情况下,Spring Boot 应用和内嵌 Tomcat 并不包含显式的无限循环来保持运行状态。相反,它们依赖于阻塞调用和等待机制来处理外部事件(如 HTTP 请求)。例如,Tomcat 的连接器线程在接收到请求之前会处于阻塞状态,等待网络事件的发生。一旦请求到达,线程就会被唤醒并处理请求。 5. 应用关闭与资源释放 当 Spring Boot 应用需要关闭时(例如,由于接收到关闭信号或主线程退出),它会触发一个关闭流程。这个流程包括停止内嵌的 Tomcat、释放资源、关闭 Spring 容器等。这确保了应用能够优雅地退出,避免资源泄露和其他潜在问题。 总结来说,Spring Boot 应用和内嵌 Tomcat 能够持续运行在状态,主要是依赖于它们的生命周期管理、事件驱动机制、以及并发处理技术,而不是通过一个无限循环的线程来支撑整个应用的运行。
springCloud
什么是微服务
微服务架构是一种架构模式,它提倡将单一应用程序划分成一组小的服务。 服务之间互相协调、互相配合,为用户提供最终价值。每个服务运行在其独立的进程中,服务与服务间采用轻量级的通信机制互相沟通(通常是基于HTTP的RESTful API)。每个服务都围绕着具体业务进行构建,并且能够被独立地部署到生产环境、类生产环境等。另外,应尽量避免统一的、集中式的服务管理机制,对具体的一个服务而言,应根据业务上下文,选择合适的语言、工具对其进行构建。
SOA和微服务的区别
微服务架构强调的第一个重点就是业务系统需要彻底的组件化和服务化,原有的单个业务系统会拆分为多个可以独立开发,设计,运行和运维的小应用。这些小应用之间通过服务完成交互和集成。
从服务粒度看
既然是微,必然微服务更倡导服务的细粒度,重用组合,甚至是每个操作(或方法)都是独立开发的服务,足够小到不能再进行拆分。而SOA没有这么极致的要求,只需要接口契约的规范化,内部实现可以更粗粒度,微服务更多为了可扩充性、负载均衡以及提高吞吐量而去分解应用,但同时也引发了打破数据模型以及维护一致性的问题。
从部署方式看
这个是最大的不同,对比以往的JavaEE部署架构,通过展现层打包WARs,业务层划分到JARs最后部署为EAR一个大包,而微服务则把应用拆分成为一个一个的单个服务,应用Docker技术,不依赖任何服务器和数据模型,是一个全栈应用,可以通过自动化方式独立部署,每个服务运行在自己的进程。
微服务架构优势
1.粒度更细(可维护和效率) 在将应用分解,每一个微服务专注于单一功能,并通过定义良好的接口清晰表述服务边界。由于体积小、复杂度低,每个微服务可由一个小规模开发团队完全掌控,易于保持高可维护性和开发效率。 2.独立部署 由于微服务具备独立的运行进程,所以每个微服务也可以独立部署。 3.容错 在微服务架构下,故障会被隔离在单个服务中。若设计良好,其他服务可通过重试、平稳退化等机制实现应用层面的容错。 4.扩展 单块架构应用也可以实现横向扩展,就是将整个应用完整的复制到不同的节点。
什么是Spring Boot
SpringBoot 框架使用了特定的方式来进行应用系统的配置,从而使开发人员不再需要耗费大量精力去定义模板化的配置文件。
什么是Spring Cloud
Spring Cloud 是一个基于 Spring Boot 实现的云应用开发工具,它为基于 JVM 的云应用开发中的配置管理、服务发现、断路器、智能路由、微代理、控制总线、全局锁、决策竞选、分布式会话和集群状态管理等,是微服务的一种实现。
Spring Cloud的核心成员
1.Spring Cloud Netflix Spring Cloud Netflix 集成众多Netflix的开源软件:Eureka, Hystrix, Zuul, Archaius,组成了微服务的最重要的核心组件。 2.Netflix Eureka 服务中心,用于服务注册与发现,一个基于 REST 的服务,用于定位服务。 3.Netflix Hystrix 熔断器,容错管理工具,旨在通过熔断机制控制服务和第三方库的节点,从而对延迟和故障提供更强大的容错能力。 4.Netflix Zuul Zuul 是在云平台上提供动态路由,监控,弹性,安全等边缘服务的框架。 5.Netflix Archaius 配置管理API,包含一系列配置管理API,提供动态类型化属性、线程安全配置操作、轮询框架、回调机制等功能,可以实现动态获取配置。 6.Spring Cloud Config 配置中心,利用git集中管理程序的配置。 7.Spring Cloud Bus 事件、消息总线,用于在集群(例如,配置变化事件)中传播状态变化,可与Spring Cloud Config联合实现热部署。 8.Spring Cloud Ribbon Ribbon是Netflix发布的负载均衡器,它有助于控制HTTP和TCP的客户端的行为。为Ribbon配置服务提供者地址后,Ribbon就可基于某种负载均衡算法,自动地帮助服务消费者去请求。
Spring Cloud架构实现
1、请求统一通过API网关(Zuul)来访问内部服务. 2、网关接收到请求后,从注册中心(Eureka)获取可用服务 3、由Ribbon进行均衡负载后,分发到后端具体实例 4、微服务之间通过Feign进行通信处理业务 5、Hystrix负责处理服务超时熔断 6、Turbine监控服务间的调用和熔断相关指标
微服务、Spring Cloud、Spring Boot三者关系
微服务是一种架构的理念,提出了微服务的设计原则,从理论为具体的技术落地提供了指导思想。 SpringBoot专注于快速方便的开发单个个体微服务。 SpringCloud是关注全局的微服务协调整理治理框架,它将SpringBoot开发的一个个单体微服务整合并管理起来, 为各个服务之间提供,配置管理、服务发现、断路器、路由、微代理、事件总线、全局锁、精选决策、分布式会话等集成服务。 SpringBoot可以离开SpringCloud独立开发项目,但是SpringCloud离不开SpringBoot,属于依赖关系。 SpringBoot专注于快速、方便的开发单个微服务个体,SpringCloud关注全局的服务治理框架。
实战
应用篇
指定bean最先加载
1.
在实际的SpringBoot开发中,我们知道都会有一个启动类,如果希望某个类被优先加载,一个成本最低的简单实现,就是在启动类里添加上依赖。 我们希望在应用启动之前,demoBean就已经被加载了,那就让Application强制依赖它,所以再Application的bean初始化之前,肯定会优先实例化demoBean
@SpringBootApplication public class Application { public Application(DemoBean demoBean) { demoBean.print(); } public static void main(String[] args) { SpringApplication.run(Application.class); } }
2. InstantiationAwareBeanPostProcessor
public class ClientBeanProcessor extends InstantiationAwareBeanPostProcessorAdapter implements BeanFactoryAware { private ConfigurableListableBeanFactory beanFactory; @Override public void setBeanFactory(BeanFactory beanFactory) { if (!(beanFactory instanceof ConfigurableListableBeanFactory)) { throw new IllegalArgumentException( "AutowiredAnnotationBeanPostProcessor requires a ConfigurableListableBeanFactory: " + beanFactory); } this.beanFactory = (ConfigurableListableBeanFactory) beanFactory; // 通过主动调用beanFactory#getBean来显示实例化目标bean DatasourceLoader propertyLoader = this.beanFactory.getBean(DatasourceLoader.class); System.out.println(propertyLoader); } }
借助beanFactory#getBean来手动触发bean的实例,通过实现BeanFactoryAware接口来获取BeanFactory,因为实现InstantiationAwareBeanPostProcessor接口的类会优先于Bean被实例,以此来间接的达到我们的目的
生效
请注意上面的注解中,导入上面的自动配置类,和ClientBeanProcessor,所以上一节中的spring.factories文件可以不需要哦
@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @Import({ClientAutoConfiguration.class, ClientBeanProcessor.class}) public @interface EnableOrderClient { }
小结
两种让bean优先加载的方式,一个是在启动类的构造方法中添加依赖,一个是借助InstantiationAwareBeanPostProcessorAdapter在bean实例化之前被创建的特点,结合BeanFactory来手动触发目标bean的创建 最后通过@Import注解让我们的BeanPostProcessorAdapter生效
注解
@SpringBootApplication:申明让spring boot自动给程序进行必要的配置,这个配置等同于: @Configuration ,@EnableAutoConfiguration 和 @ComponentScan 三个配置。 @ResponseBody:表示该方法的返回结果直接写入HTTP response body中,一般在异步获取数据时使用,用于构建RESTful的api。在使用@RequestMapping后,返回值通常解析为跳转路径,加上@Responsebody后返回结果不会被解析为跳转路径,而是直接写入HTTP response body中。比如异步获取json数据,加上@Responsebody后,会直接返回json数据。该注解一般会配合@RequestMapping一起使用。 @Controller:用于定义控制器类,在spring项目中由控制器负责将用户发来的URL请求转发到对应的服务接口(service层),一般这个注解在类中,通常方法需要配合注解@RequestMapping。 @RestController:用于标注控制层组件(如struts中的action),@ResponseBody和@Controller的合集。 @RequestMapping:提供路由信息,负责URL到Controller中的具体函数的映射。 @EnableAutoConfiguration:SpringBoot自动配置(auto-configuration):尝试根据你添加的jar依赖自动配置你的Spring应用。例如,如果你的classpath下存在HSQLDB,并且你没有手动配置任何数据库连接beans,那么我们将自动配置一个内存型(in-memory)数据库”。你可以将@EnableAutoConfiguration或者@SpringBootApplication注解添加到一个@Configuration类上来选择自动配置。如果发现应用了你不想要的特定自动配置类,你可以使用@EnableAutoConfiguration注解的排除属性来禁用它们。 @ComponentScan:表示将该类自动发现扫描组件。个人理解相当于,如果扫描到有@Component、@Controller、@Service等这些注解的类,并注册为Bean,可以自动收集所有的Spring组件,包括@Configuration类。我们经常使用@ComponentScan注解搜索beans,并结合@Autowired注解导入。可以自动收集所有的Spring组件,包括@Configuration类。如果没有配置的话,Spring Boot会扫描启动类所在包下以及子包下的使用了@Service,@Repository等注解的类。 @Configuration:相当于传统的xml配置文件,如果有些第三方库需要用到xml文件,建议仍然通过@Configuration类作为项目的配置主类——可以使用@ImportResource注解加载xml配置文件。 @Import:用来导入其他配置类。 @ImportResource:用来加载xml配置文件。 @Autowired:自动导入依赖的bean @Service:一般用于修饰service层的组件 @Repository:使用@Repository注解可以确保DAO或者repositories提供异常转译,这个注解修饰的DAO或者repositories类会被ComponetScan发现并配置,同时也不需要为它们提供XML配置项。 @Bean:用@Bean标注方法等价于XML中配置的bean。 @Value:注入Spring boot application.properties配置的属性的值。示例代码: @Inject:等价于默认的@Autowired,只是没有required属性; @Component:泛指组件,当组件不好归类的时候,我们可以使用这个注解进行标注。 @Bean:相当于XML中的,放在方法的上面,而不是类,意思是产生一个bean,并交给spring管理。 @AutoWired:自动导入依赖的bean。byType方式。把配置好的Bean拿来用,完成属性、方法的组装,它可以对类成员变量、方法及构造函数进行标注,完成自动装配的工作。当加上(required=false)时,就算找不到bean也不报错。 @Qualifier:当有多个同一类型的Bean时,可以用@Qualifier(“name”)来指定。与@Autowired配合使用。@Qualifier限定描述符除了能根据名字进行注入,但能进行更细粒度的控制如何选择候选者,具体使用方式如下: @Resource(name=”name”,type=”type”):没有括号内内容的话,默认byName。与@Autowired干类似的事。 五、全局异常处理 @ControllerAdvice:包含@Component。可以被扫描到。统一处理异常。 @ExceptionHandler(Exception.class):用在方法上面表示遇到这个异常就执行以下方法。 六、项目中具体配置解析和使用环境 @MappedSuperclass: 1.@MappedSuperclass 注解使用在父类上面,是用来标识父类的 2.@MappedSuperclass 标识的类表示其不能映射到数据库表,因为其不是一个完整的实体类,但是它所拥有的属性能够映射在其子类对用的数据库表中 3.@MappedSuperclass 标识的类不能再有@Entity或@Table注解 @Column: 1.当实体的属性与其映射的数据库表的列不同名时需要使用@Column标注说明,该属性通常置于实体的属性声明语句之前,还可与 @Id 标注一起使用。 2.@Column 标注的常用属性是name,用于设置映射数据库表的列名。此外,该标注还包含其它多个属性,如:unique、nullable、length、precision等。具体如下: 1 name属性:name属性定义了被标注字段在数据库表中所对应字段的名称 2 unique属性:unique属性表示该字段是否为唯一标识,默认为false,如果表中有一个字段需要唯一标识,则既可以使用该标记,也可以使用@Table注解中的@UniqueConstraint 3 nullable属性:nullable属性表示该字段是否可以为null值,默认为true 4 insertable属性:insertable属性表示在使用”INSERT”语句插入数据时,是否需要插入该字段的值 5 updateable属性:updateable属性表示在使用”UPDATE”语句插入数据时,是否需要更新该字段的值 6 insertable和updateable属性:一般多用于只读的属性,例如主键和外键等,这些字段通常是自动生成的 7 columnDefinition属性:columnDefinition属性表示创建表时,该字段创建的SQL语句,一般用于通过Entity生成表定义时使用,如果数据库中表已经建好,该属性没有必要使用 8 table属性:table属性定义了包含当前字段的表名 9 length属性:length属性表示字段的长度,当字段的类型为varchar时,该属性才有效,默认为255个字符 10 precision属性和scale属性:precision属性和scale属性一起表示精度,当字段类型为double时,precision表示数值的总长度,scale表示小数点所占的位数 具体如下: 1.double类型将在数据库中映射为double类型,precision和scale属性无效 2.double类型若在columnDefinition属性中指定数字类型为decimal并指定精度,则最终以columnDefinition为准 3.BigDecimal类型在数据库中映射为decimal类型,precision和scale属性有效 4.precision和scale属性只在BigDecimal类型中有效 3.@Column 标注的columnDefinition属性: 表示该字段在数据库中的实际类型.通常 ORM 框架可以根据属性类型自动判断数据库中字段的类型,但是对于Date类型仍无法确定数据库中字段类型究竟是DATE,TIME还是TIMESTAMP.此外,String的默认映射类型为VARCHAR,如果要将 String 类型映射到特定数据库的 BLOB 或TEXT字段类型. 4.@Column标注也可置于属性的getter方法之前 @Getter和@Setter(Lombok) @Setter:注解在属性上;为属性提供 setting 方法 @Getter:注解在属性上;为属性提供 getting 方法 1 @Data:注解在类上;提供类所有属性的 getting 和 setting 方法,此外还提供了equals、canEqual、hashCode、toString 方法 2 3 @Setter:注解在属性上;为属性提供 setting 方法 4 5 @Getter:注解在属性上;为属性提供 getting 方法 6 7 @Log4j2 :注解在类上;为类提供一个 属性名为log 的 log4j 日志对象,和@Log4j注解类似 8 9 @NoArgsConstructor:注解在类上;为类提供一个无参的构造方法 10 11 @AllArgsConstructor:注解在类上;为类提供一个全参的构造方法 12 13 @EqualsAndHashCode:默认情况下,会使用所有非瞬态(non-transient)和非静态(non-static)字段来生成equals和hascode方法,也可以指定具体使用哪些属性。 14 15 @toString:生成toString方法,默认情况下,会输出类名、所有属性,属性会按照顺序输出,以逗号分割。 16 17 @NoArgsConstructor, @RequiredArgsConstructor and @AllArgsConstructor 18 无参构造器、部分参数构造器、全参构造器,当我们需要重载多个构造器的时候,只能自己手写了 19 20 @NonNull:注解在属性上,如果注解了,就必须不能为Null 21 22 @val:注解在属性上,如果注解了,就是设置为final类型,可查看源码的注释知道 当你在执行各种持久化方法的时候,实体的状态会随之改变,状态的改变会引发不同的生命周期事件。这些事件可以使用不同的注释符来指示发生时的回调函数。 @javax.persistence.PostLoad:加载后。 @javax.persistence.PrePersist:持久化前。 @javax.persistence.PostPersist:持久化后。 @javax.persistence.PreUpdate:更新前。 @javax.persistence.PostUpdate:更新后。 @javax.persistence.PreRemove:删除前。 @javax.persistence.PostRemove:删除后。 1)数据库查询 @PostLoad事件在下列情况下触发: 执行EntityManager.find()或getreference()方法载入一个实体后。 执行JPQL查询后。 EntityManager.refresh()方法被调用后。 2)数据库插入 @PrePersist和@PostPersist事件在实体对象插入到数据库的过程中发生: @PrePersist事件在调用persist()方法后立刻发生,此时的数据还没有真正插入进数据库。 @PostPersist事件在数据已经插入进数据库后发生。 3)数据库更新 @PreUpdate和@PostUpdate事件的触发由更新实体引起: @PreUpdate事件在实体的状态同步到数据库之前触发,此时的数据还没有真正更新到数据库。 @PostUpdate事件在实体的状态同步到数据库之后触发,同步在事务提交时发生。 4)数据库删除 @PreRemove和@PostRemove事件的触发由删除实体引起: @PreRemove事件在实体从数据库删除之前触发,即在调用remove()方法删除时发生,此时的数据还没有真正从数据库中删除。 @PostRemove事件在实体从数据库中删除后触发。
JPA注解
@Entity:@Table(name=”“):表明这是一个实体类。一般用于jpa这两个注解一般一块使用,但是如果表名和实体类名相同的话,@Table可以省略 @MappedSuperClass:用在确定是父类的entity上。父类的属性子类可以继承。 @NoRepositoryBean:一般用作父类的repository,有这个注解,spring不会去实例化该repository。 @Column:如果字段名与列名相同,则可以省略。 @Id:表示该属性为主键。 @GeneratedValue(strategy = GenerationType.SEQUENCE,generator = “repair_seq”):表示主键生成策略是sequence(可以为Auto、IDENTITY、native等,Auto表示可在多个数据库间切换),指定sequence的名字是repair_seq。 @SequenceGeneretor(name = “repair_seq”, sequenceName = “seq_repair”, allocationSize = 1):name为sequence的名称,以便使用,sequenceName为数据库的sequence名称,两个名称可以一致。 @Transient:表示该属性并非一个到数据库表的字段的映射,ORM框架将忽略该属性。如果一个属性并非数据库表的字段映射,就务必将其标示为@Transient,否则,ORM框架默认其注解为@Basic。@Basic(fetch=FetchType.LAZY):标记可以指定实体属性的加载方式 @JsonIgnore:作用是json序列化时将Java bean中的一些属性忽略掉,序列化和反序列化都受影响。 @JoinColumn(name=”loginId”):一对一:本表中指向另一个表的外键。一对多:另一个表指向本表的外键。 @OneToOne、@OneToMany、@ManyToOne:对应hibernate配置文件中的一对一,一对多,多对一。
SpringMVC相关注解
@RequestMapping:@RequestMapping(“/path”)表示该控制器处理所有“/path”的UR L请求。RequestMapping是一个用来处理请求地址映射的注解,可用于类或方法上。 用于类上,表示类中的所有响应请求的方法都是以该地址作为父路径。该注解有六个属性: params:指定request中必须包含某些参数值是,才让该方法处理。 headers:指定request中必须包含某些指定的header值,才能让该方法处理请求。 value:指定请求的实际地址,指定的地址可以是URI Template 模式 method:指定请求的method类型, GET、POST、PUT、DELETE等 consumes:指定处理请求的提交内容类型(Content-Type),如application/json,text/html; produces:指定返回的内容类型,仅当request请求头中的(Accept)类型中包含该指定类型才返回 @RequestParam:用在方法的参数前面。 @RequestParam String a =request.getParameter(“a”)。 @PathVariable:路径变量。如 参数与大括号里的名字一样要相同。
注解列表
@SpringBootApplication:包含了@ComponentScan、@Configuration和@EnableAutoConfiguration注解。其中 @ComponentScan:让spring Boot扫描到Configuration类并把它加入到程序上下文。 @Configuration :等同于spring的XML配置文件;使用Java代码可以检查类型安全。 @EnableAutoConfiguration :自动配置。 @ComponentScan :组件扫描,可自动发现和装配一些Bean。 @Component可配合CommandLineRunner使用,在程序启动后执行一些基础任务。 @RestController:注解是@Controller和@ResponseBody的合集,表示这是个控制器bean,并且是将函数的返回值直 接填入HTTP响应体中,是REST风格的控制器。 @Autowired:自动导入。 @PathVariable:获取参数。 @JsonBackReference:解决嵌套外链问题。 @RepositoryRestResourcepublic:配合spring-boot-starter-data-rest使用。
SpringCloud
@Controller 控制层,里面有多个连接 @Service 业务层,一般对于接口和实现 @Qualifier 如果一个接口有多个实现,那么注入时候加上唯一标示 @Repository 一般的dao层 @Autowired 自动注入依赖 @Resource bean的注入,同Autowired 有相同的功能。 说明: 共同点:@Resource和@Autowired都可以作为注入属性的修饰,在接口仅有单一实现类时,两个注解的修饰效果相同,可以互相替换,不影响使用。 不同点: @Resource是Java自己的注解,@Resource有两个属性是比较重要的,分是name和type;Spring将@Resource注解的name属性解析为bean的名字,而type属性则解析为bean的类型。所以如果使用name属性,则使用byName的自动注入策略,而使用type属性时则使用byType自动注入策略。如果既不指定name也不指定type属性,这时将通过反射机制使用byName自动注入策略。 @Autowired是spring的注解,是spring2.5版本引入的,Autowired只根据type进行注入,不会去匹配name。如果涉及到type无法辨别注入对象时,那需要依赖@Qualifier或@Primary注解一起来修饰。 @Component定义其它组件(比如访问外部服务的组件) @RequestMapping (value=’’,method={RequestMethod。GET或者POSt})绑定url @RequestParam (value=’’ required=false)绑定参数,将客户端请求中的参数值映射到相应方法的参数上; @ModelAttribute 一般用于controller层,呗注解的方法会在所以mapping执行之前执行,并且可以绑定参数到Model model里面。 @Transactional (readOnly=true)注解式事务 @TransactionalEventListener用于配置事务的回调方法,可以在事务提交前、提交后、完成后以及回滚后几个阶段接受回调事件。 @Value(“${}”)可以注入properties里面的配置项 @ControllerAdvice 是spring3提供的新注解 @ExceptionHandler 如果在controller方法遇到异常,就会调用含有此注解的方法。 @InitBinder 一般用于controller 可以将所以form 讲所有传递进来的string 进行html编码,防止xss攻击,比如可以将字符串类型的日期转换成date类型 @EnableCaching 注解自动化配置合适的缓存管理器。 @EnableWebSecurity 注解开启spring security的功能,集成websercrityconfigureadapter。 @SringBootApplication相当于@configuration,@EnableAutoConfiguation @ComponentScan三个注解合用。 @EnableDiscoveryclient 注册应用为Eureka客户端应用,以获得服务发现的能力 @EnableAdminServer 使用admin监控应用。 @EnableEurekaClient配置本应用将使用服务注册和服务发现,注意:注册和发现用这个注解。 @EnableEurekaServer 启动一个服务注册中心 @EnableHystrix表示启动断路器,断路器依赖于服务注册和发现。 @HystrixCommand注解方法失败后,系统将西东切换到fallbackMethod方法执行。指定回调方法 @EnableAutoConfiguration spring boot自动配置,尝试根据你添加的jar依赖自动配置你的spring应用。 @ComponentScan 表示将该类自动发现并注册bean 可以自动收集所有的spring组件 @Comfiguration 相当于传统的xml配置文件 @Import 导入其他配置类 @ImportResource用来 加载xml配置文件 @FeignClient注解中的fallbank属性指定回调类 @RestController 返回json字符串的数据,直接可以编写RESTFul的接口; @CrossOrigin 可以处理跨域请求,让你能访问不是一个域的文件; @ApiOperation 首先@ApiOperation注解不是Spring自带的,它是是swagger里的注解@ApiOperation是用来构建Api文档的@ApiOperation(value = “接口说明”, httpMethod = “接口请求方式”, response = “接口返回参数类型”, notes = “接口发布说明”; @SpringBootApplication 申明让spring boot自动给程序进行必要的配置,等价于以默认属性使用@Configuration,@EnableAutoConfiguration和@ComponentScan; @RefreshScope 如果代码中需要动态刷新配置,在需要的类上加上该注解就行。但某些复杂的注入场景下,这个注解使用不当,配置可能仍然不动态刷新; @FeignClient springboot调用外部接口:声明接口之后,在代码中通过@Resource注入之后即可使用。@FeignClient标签的常用属性如下:name:指定FeignClient的名称,如果项目使用了Ribbon,name属性会作为微服务的名称,用于服务发现 url: url一般用于调试,可以手动指定@FeignClient调用的地址decode404:当发生http 404错误时,如果该字段位true,会调用decoder进行解码,否则抛出FeignException configuration: Feign配置类,可以自定义Feign的Encoder、Decoder、LogLevel、Contractfallback: 定义容错的处理类,当调用远程接口失败或超时时,会调用对应接口的容错逻辑,fallback指定的类必须实现@FeignClient标记的接口 fallbackFactory: 工厂类,用于生成fallback类示例,通过这个属性我们可以实现每个接口通用的容错逻辑,减少重复的代码path: 定义当前FeignClient的统一前缀 @EnableFeignClients 开启Spring Cloud Feign的支持 @EnableCircuitBreaker 开启断路器功能 @LoadBalanced 开启客户端负载均衡 @WebAppConfiguration 开启Web 应用的配置,用于模拟ServletContext @RibbonClient,这个注解用来为负载均衡客户端做一些自定义的配置,可以进一步配置或自定义从哪里获取服务端列表、负载均衡策略、Ping也就是服务鉴活策略等等 @PathVariable 获取参数。 @JsonBackReference 解决嵌套外链问题。 @RepositoryRestResourcepublic 配合spring-boot-starter-data-rest使用
ORM框架MyBatis
1. 配置文件加载,如JDBC配置、包等 2. 解析配置,存入属性对象 3. 扫包并解析XML文件,将SQL等信息封装对象 4. 开启会话,设置配置对象、mapper代理对象、代理工厂等 5. 调用mapper方法,创建JDK动态代理 6. 调用mapper动态代理方法,由实现handler方法调用sqlSession对象,进行SQL相关封装,并用JDBC操作数据库,并封装返回结果
工作原理
第一种解释
(1)Mybatis是一个半ORM(对象关系映射)框架,它内部封装了JDBC,加载驱动、创建连接、创建statement等繁杂的过程,开发者开发时只需要关注如何编写SQL语句,可以严格控制sql执行性能,灵活度高。 (2)作为一个半ORM框架,MyBatis 可以使用 XML 或注解来配置和映射原生信息,将 POJO映射成数据库中的记录,避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。 称Mybatis是半自动ORM映射工具,是因为在查询关联对象或关联集合对象时,需要手动编写sql来完成。不像Hibernate这种全自动ORM映射工具,Hibernate查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取。 (3)通过xml 文件或注解的方式将要执行的各种 statement 配置起来,并通过java对象和 statement中sql的动态参数进行映射生成最终执行的sql语句,最后由mybatis框架执行sql并将结果映射为java对象并返回。(从执行sql到返回result的过程)。 (4)由于MyBatis专注于SQL本身,灵活度高,所以比较适合对性能的要求很高,或者需求变化较多的项目,如互联网项目。
第二种解释
1. 解析配置文件
(MyBatis-config.xml、Mapper.xml)
2. 创建会话工厂
SqlSessionFactoryBuilder创建会话工厂SqlSessionFactory。
SqlSessionFactory是单例模式
3. 创建会话
SqlSessionFactory创建会话SqlSession
4. 生成jdbc Statement对象
执行器将MappedStatement对象进行解析,sql参数转化、动态sql拼接,生成jdbc Statement对象,使用Paramterhandler填充参数,使用statementHandler绑定参数。
5. JDBC执行SQL
借助MappedStatement中的结果映射关系,使用ResultSetHandler将返回结果转化成HashMap、JavaBean等存储结构并返回。
模式
建造者、单例、工厂、代理
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder(); SqlSessionFactory factory = builder.build(is);
SqlSession session = factory.openSession();
UserDao dao = session.getMapper(UserDao.class);
一级缓存和二级缓存
什么是一级缓存 在日常开发过程中,经常会有相同的sql执行多次查询的情况,mybatis提供了一级缓存来优化这些查询,避免多次请求数据库。 一级缓存在mybatis中默认是开启的并且是session级别,它的作用域为一次sqlSession会话。 什么是二级缓存 相对于一级缓存,二级缓存的作用域更广泛,它不止局限于一个sqlSession,可以在多个sqlSession之间共享,事实上,它的作用域是namespace。 mybatis的二级缓存默认也是开启的,但由于他的作用域是namespace,所以还需要在mapper.xml中开启才能生效 缓存的优先级 通过mybatis发起的查询,作用顺序为:二级缓存->一级缓存->数据库 ,其中任何一个环节查到不为空的数据,都将直接返回结果 缓存失效 当在一个缓存作用域中发生了update、insert、delete 动作后,将会触发缓存失效,下一次查询将命中数据库,从而保证不会查到脏数据。
一级缓存
mybatis开启并使用了一级缓存,但是默认是不生效的,为什么? 一级缓存在mybatis中默认是开启的并且是session级别,它的作用域为一次sqlSession会话。
/** * 开启事务,测试一级缓存效果 * 缓存命中顺序:二级缓存---> 一级缓存---> 数据库 **/ @Test @Transactional(rollbackFor = Throwable.class) public void testFistCache(){ // 第一次查询,缓存到一级缓存 userMapper.selectById(1); // 第二次查询,直接读取一级缓存 userMapper.selectById(1); }
1、为什么开启事务 由于使用了数据库连接池,默认每次查询完之后自动commite,这就导致两次查询使用的不是同一个sqlSessioin,根据一级缓存的原理,它将永远不会生效。 当我们开启了事务,两次查询都在同一个sqlSession中,从而让第二次查询命中了一级缓存。读者可以自行关闭事务验证此结论。 2、两种一级缓存模式 一级缓存的作用域有两种:session(默认)和statment,可通过设置local-cache-scope 的值来切换,默认为session。 二者的区别在于session会将缓存作用于同一个sqlSesson,而statment仅针对一次查询,所以,local-cache-scope: statment可以理解为关闭一级缓存。
二级缓存
默认情况下,mybatis打开了二级缓存,但它并未生效,因为二级缓存的作用域是namespace,所以还需要在Mapper.xml文件中配置一下才能使二级缓存生效
#配置mapper mybatis: #开启Mybatis的二级缓存 configuration: cache-enabled: true
单表二级缓存 下面对userMapper.xml配置一下,让其二级缓存生效,只需加入cache标签即可 <cache></cache> userMapper.xml <cache-ref namespace="com.zhengxl.mybatiscache.mapper.UserOrderMapper"/>
/** * 测试多表联查的二级缓存效果 * 需要*Mapper.xml设定引用空间 **/ @Test public void testJoin(){ System.out.println(userMapper.selectJoin()); System.out.println(userMapper.selectJoin()); UserOrder userOrder = new UserOrder(); userOrder.setGoodName("myGoods"); userOrder.setUserId(1); userOrderMapper.saveOrder(userOrder); System.out.println(userMapper.selectJoin()); }
首先查询了两次user表,第二次命中二级缓存,然后更新user_order表,使缓存失效,第三次查询时命中数据库。
总结
mybatis默认的session级别一级缓存,由于springboot中默认使用了hikariCP,所以基本没用,需要开启事务才有用。但一级缓存作用域仅限同一sqlSession内,无法感知到其他sqlSession的增删改,所以极易产生脏数据 二级缓存可通过cache-ref让多个mapper.xml共享同一namespace,从而实现缓存共享,但多表联查时配置略微繁琐。 所以生产环境建议将一级缓存设置为statment级别(即关闭一级缓存),如果有必要,可以开启二级缓存
面视题
#{}和${}
${}是字符串替换,#{}是预处理;使用#{}可以有效的防止SQL注入,提高系统安全性。
MyBatis是否支持延迟加载?如果支持,它的实现原理是什么?
配置是否启用延迟加载lazyLoadingEnabled=true|false。
1. CGLIB代理:使用CGLIB创建目标对象的代理对象,当调用目标方法时, 2. 拦截器:进入拦截器方法,比如调用a.getB().getName(),拦截器invoke()方法发现a.getB()是null值, 3. 获取值:那么就会单独发送事先保存好的查询关联B对象的sql,把B查询上来,然后调用a.setB(b),于是a的对象b属性就有值了, 4. 接着调用:接着完成a.getB().getName()方法的调用。
一级、二级缓存
一级缓存
HashMap 本地缓存,其存储作用域为 Session。适用于事务中,因为多次查询用的是同一个session查询
二级缓存
作用域是namespace,需要在Mapper.xml文件中配置一下才能使二级缓存生效
半自动ORM映射工具
1. Hibernate 属于全自动 ORM 映射工具,使用 Hibernate 查询关联对象或者关联集合对象 时,可以根据对象关系模型直接获取,所以它是全自动的。 2. 而 Mybatis 在查询关联对象或关联集合对象时,需要手动编写 sql 来完成,所以,称之为半自动 ORM 映射工具。
MyBatis 中比如 UserMapper.java 是接口,为什么没有实 现类还能调用 ?
使用JDK动态代理+MapperProxy。本质上调用的是MapperProxy的invoke方法
自己设计一个ORM框架
1. 配置层
核心配置:数据库连接信息 映射配置:类与表之间映身关系(属性、字段)
2. 解析层
解析配置文件中的数据
3. 封装层
Mapper封装和存储 上面解析得到的映射数据
4. 功能层
生成SQL语句,通过对JDBC的封装,实现CRUD
四种拦截器
Executor(执行拦截器)
- 用途:拦截MyBatis执行器方法的执行。
- 使用:允许拦截和自定义MyBatis执行器的行为。例如,可以添加缓存、日志记录或审计功能到执行器中。这些拦截器可以在MyBatis执行的不同阶段扩展或修改其行为。您可以通过实现MyBatis提供的相应接口并在MyBatis配置文件中进行配置来实现这些拦截器。
StatementHandler(语句拦截器)
-- 用途:拦截SQL语句的执行。
- 使用:可以在SQL语句执行之前修改或增强它们。例如,可以向WHERE子句添加额外的条件或记录执行的语句。分页等
ParameterHandler(参数拦截器)
- 用途:拦截SQL语句的参数设置。
- 使用:允许在将参数设置到SQL语句之前修改或验证它们。例如,可以对作为参数传递的敏感信息进行加密或解密。
ResultHandler(结果集拦截器)
- 用途:拦截从SQL语句返回的结果集的处理。
- 使用:可以在将结果集返回给应用程序之前修改或分析它们。例如,可以对结果集数据进行转换或执行额外的计算。
流程
1. 加载配置并初始化
触发条件
加载配置文件
处理过程
将SQL的配置信息加载成为一个个MappedStatement对象(包括了1.传入参数映射配置、2.执行的SQL语句、3.结果映射配置),存储在内存中。
2. 接收调用请求
触发条件
调用Mybatis提供的API
传入参数
为SQL的ID和传入参数对象
处理过程
将请求传递给下层的请求处理层进行处理。
3. 处理操作请求
触发条件
API接口层传递请求过来
传入参数
为SQL的ID和传入参数对象
处理过程
(A) 找到对象
根据SQL的ID查找对应的MappedStatement对象。
(B) 解析成SQL
根据传入参数对象解析MappedStatement对象,得到最终要执行的SQL和执行传入参数。
(C) 执行SQL
获取数据库连接,根据得到的最终SQL语句和执行传入参数到数据库执行,并得到执行结果。
(D) 得到结果
根据MappedStatement对象中的结果映射配置对得到的执行结果进行转换处理,并得到最终的处理结果。
(E)释放连接资源。
释放连接资源。
功能架构
API接口层 数据处理层 基础支持层
(1)API接口层:提供给外部使用的接口API,开发人员通过这些本地API来操纵数据库。接口层一接收到调用请求就会调用数据处理层来完成具体的数据处理。 (2)数据处理层:负责具体的SQL查找、SQL解析、SQL执行和执行结果映射处理等。它主要的目的是根据调用的请求完成一次数据库操作。 (3)基础支撑层:负责最基础的功能支撑,包括连接管理、事务管理、配置加载和缓存处理,这些都是共用的东西,将他们抽取出来作为最基础的组件。为上层的数据处理层提供最基础的支撑。 [2]
架构讲解
(1)加载配置:配置来源于两个地方,一处是配置文件,一处是Java代码的注解,将SQL的配置信息加载成为一个个MappedStatement对象(包括了传入参数映射配置、执行的SQL语句、结果映射配置),存储在内存中。 (2)SQL解析:当API接口层接收到调用请求时,会接收到传入SQL的ID和传入对象(可以是Map、JavaBean或者基本数据类型),Mybatis会根据SQL的ID找到对应的MappedStatement,然后根据传入参数对象对MappedStatement进行解析,解析后可以得到最终要执行的SQL语句和参数。 (3)SQL执行:将最终得到的SQL和参数拿到数据库进行执行,得到操作数据库的结果。 (4)结果映射:将操作数据库的结果按照映射的配置进行转换,可以转换成HashMap、JavaBean或者基本数据类型,并将最终结果返回。
netty
优势
Netty基于NIO实现,基于事件模型实现
NIO与Netty
为什么不选NIO
需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等,客户端⾯临断连重连、⽹络闪断、半包读写、失败缓存、⽹络拥塞和异常码流的处理等问题,NIO编程的特点是功能开发相对容易,但是可靠性能⼒补⻬的⼯作量和难度都⾮常⼤。Selector空轮询,最终导致CPU 100%。官⽅声称在JDK 1.6版本的update18修复了该问题,但是直到JDK 1.7版本该问题仍旧存在,只不过该BUG发⽣概率降低了⼀些⽽已
Netty的高性能设计
BIO
客户端的并发数与后端的线程数成1:1的⽐例,线程的创建、销毁是⾮常消耗系统资源的,随着并发量增⼤,服务端性能将显著下降,甚⾄会发⽣线程堆栈溢出等错误。 当连接创建后,如果该线程没有操作时,会进⾏阻塞操作,这样极⼤的浪费了服务器资源。
子主题
子主题
NIO(当前Netty)
非阻塞IO
三⼤核⼼组件:Buffer(缓冲区)、Channel(通道)、Selector(选择器/多路复⽤器) 优点:多路复用器Selector实现一个线程处理多个通道,只有通道有事件时,才进行读写操作。
1. 在NIO中,所有的读写操作都是基于缓冲区完成的,底层是通过数组实现的,常⽤的缓冲区是ByteBuffer,每⼀种java基本类型都有对应的缓冲区对象(除了Boolean类型),如:CharBuffer、IntBuffer、LongBuffer等。 2. 在BIO中是基于Stream实现,⽽在NIO中是基于通道实现,与流不同的是,通道是双向的,既可以读也可以写。 3. Selector是多路复⽤器,它会不断的轮询注册在其上的Channel,如果某个Channel上发⽣读或写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey获取就绪Channel的集合,进⾏IO的读写操作。
子主题
Reactor线程模型
不属于java也不属于netty,是一种并发编程模型,是一种思想
三种角色
Reactor(相当于selector):负责监听和分配事件,将I/O事件分派给对应的Handler。新的事件包含连接建⽴就绪、读就绪、写就绪等。
Acceptor:处理客户端新连接,并分派请求到处理器链中。
Handler:将⾃身与事件绑定,执⾏⾮阻塞读/写任务,完成channel的读⼊,完成处理业务逻辑后,负责将结果写出channel。
三种Reactor线程模型
单线程模型
通过 1 个线程负责客户端连接、网络数据的读写、业务处理 ------ Reactor充当多路复⽤器⻆⾊,监听多路连接的请求,由单线程完成 Reactor收到客户端发来的请求时,如果是新建连接通过Acceptor完成,其他的请求由Handler完成。 Handler完成业务逻辑的处理,基本的流程是:Read --> 业务处理 --> Send 。
缺点
单线程,不能发挥多核CPU的性能。 大量客户端连接,加重Reactor线程负载,导致消息积压和处理超时。 可靠性差,可能线程会进入死循环或意外终止,单点故障问题
多线程模型
通过 1 个线程负责客户端的连接、网络数据的读写,将业务处理剥离出去,通过线程池来进行处理 ----- 在Reactor多线程模型相⽐较单线程模型⽽⾔,不同点在于,Handler不会处理业务逻辑,只是负责响应⽤户请求,真正的业务逻辑,在另外的线程中完成。 这样可以降低Reactor的性能开销,充分利⽤CPU资源,从⽽更专注的做事件分发⼯作了,提升整个应⽤的吞吐。
缺点
Reactor承担所有事件的监听和响应,只在主线程中运⾏,可能会存在性能问题。例如并发百万客户端连接
主从Reactor多线程模型
MainReactor负责监听server socket,⽤来处理⽹络IO连接建⽴操作,将建⽴的socketChannel指定注册给SubReactor。 SubReactor主要完成和建⽴起来的socket的数据交互和事件业务处理操作。
优点
1. 响应快,不必为单个同步事件所阻塞,虽然Reactor本身依然是同步的。 2. 可扩展性强,可以⽅便地通过增加SubReactor实例个数来充分利⽤CPU资源。 3. 可复⽤性⾼,Reactor模型本身与具体事件处理逻辑⽆关,具有很⾼的复⽤性。
Netty模型
在Netty模型中,负责处理新连接事件的是BossGroup,负责处理其他事件的是WorkGroup。Group就是线程池的概念。 NioEventLoop表示⼀个不断循环的执⾏处理任务的线程,⽤于监听绑定在其上的读/写事件。 通过Pipeline(管道)执⾏业务逻辑的处理,Pipeline中会有多个ChannelHandler,真正的业务逻辑是在ChannelHandler中完成的。
Netty实例
服务端
客户端
Netty核心组件
1. 双向通道 Channel(连接)
NioSocketChannel,NIO的客户端 TCP Socket 连接。 NioServerSocketChannel,NIO的服务器端 TCP Socket 连接。 NioDatagramChannel, UDP 连接。 NioSctpChannel,客户端 Sctp 连接。 NioSctpServerChannel,Sctp 服务器端连接,这些通道涵盖了 UDP 和 TCP ⽹络 IO 以及⽂件
2. 事件轮询 EventLoop(事件轮询,每个EventLoop只占一个线程)、 EventLoopGroup(可以有多个EventLoop)
描述
有了 Channel 连接服务,连接之间可以消息流动。如果服务器发出的消息称作“出站”消息,服务器接受的消息称作“⼊站”消息。那么消息的“出站”/“⼊站”就会产⽣事件(Event) 事件:连接已激活;数据读取;⽤户事件;异常事件;打开链接;关闭链接等等
事件监听和协调事件(EventLoop)
每个EventLoop只占一个线程,可以服务多个Channel
⼀个 EventLoopGroup 包含⼀个或者多个 EventLoop; ⼀个 EventLoop 在它的⽣命周期内只和⼀个 Thread 绑定; 所有由 EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理; ⼀个 Channel 在它的⽣命周期内只注册于⼀个 EventLoop; ⼀个 EventLoop 可能会被分配给⼀个或多个 Channel。
3. 通道处理器 ChannelHandler
描述
对于数据的⼊站和出站的业务逻辑的编写都是在ChannelHandler中完成的
数据的出站和入站
ChannelInboundHandler ⼊站事件处理器 ChannelOutBoundHandler 出站事件处理器
ChannelHandlerAdapter提供了⼀些⽅法的默认实现,可减少⽤户对于ChannelHandler的编写
4. 通道管道 ChannelPipeline
描述
在Channel的数据传递过程中,对应着有很多的业务逻辑需要处理,⽐如:编码解码处理、读写操作等,那么对于每种业务逻辑实现都需要有个ChannelHandler完成
顺序:ChannelHandler按照加⼊的顺序会组成⼀个双向链表,⼊站事件从链表的head往后传递到最后⼀个ChannelHandler,出站事件从链表的tail向前传递,直到最后⼀个ChannelHandler,两种类型的ChannelHandler相互不会影响。
5. 引导 Bootstrap
描述
作⽤是配置整个Netty程序,将各个组件都串起来,最后绑定端⼝、启动Netty服务。
2个引导类
⼀种⽤于客户端(Bootstrap),⽽另⼀种(ServerBootstrap)⽤于服务器。
区别
1. ServerBootstrap 将绑定到⼀个端⼝,因为服务器必须要监听连接。 Bootstrap 则是由想要连接到远程节点的客户端应⽤程序所使⽤的。 2. 引导⼀个客户端只需要⼀个EventLoopGroup。 ⼀个ServerBootstrap则需要两个。 第⼀组将只包含⼀个 ServerChannel,代表服务器⾃身的已绑定到某个本地端⼝的正在监听的套接字。 第⼆组将包含所有已创建的⽤来处理传⼊客户端连接。
与ServerChannel相关联的EventLoopGroup 将分配⼀个负责为传⼊连接请求创建 Channel 的EventLoop。⼀旦连接被接受,第⼆个 EventLoopGroup 就会给它的 Channel 分配⼀个 EventLoop。
6. 结果访问 Future
描述
Future提供了⼀种在操作完成时通知应⽤程序的⽅式。这个对象可以看作是⼀个异步操作的结果的占位符,它将在未来的某个时刻完成,并提供对其结果的访问。
特点
1. ChannelFuture提供了⼏种额外的⽅法,这些⽅法使得我们能够注册⼀个或者多个ChannelFutureListener实例。 2. 监听器的回调⽅法operationComplete(),将会在对应的 操作完成时被调⽤ 。然后监听器可以判断该操作是成功地完成了还是出错了。 3. 每个 Netty 的出站 I/O 操作都将返回⼀个 ChannelFuture,也就是说,它们都不会阻塞。 所以说,Netty完全是异步和事件驱动的。
小结
子主题
手动释放
ByteBuf如果采⽤的是堆缓冲区模式的话,可以由GC回收,但是如果采⽤的是直接缓冲区,就不受GC的管理,就得⼿动释放,否则会发⽣内存泄露。 关于ByteBuf的释放,分为⼿动释放与⾃动释放。
ReferenceCountUtil.release(byteBuf); 进⾏释放。
回顾学习
子主题
BIO
子主题
来一个请求,建一个线程来接收客户端请求
线程数太多了,可以用线程池
NIO
由中断信号通知CPU来数据了,线程开始处理数据
单线程处理所有客户端连接
Acceptor.java
子主题
TCPReactor
TCPReactor.java
Main.java
TCPHandler
TCPSubReactor
子主题
子主题
strace跟踪进程,如strace -ff -o ./ooxx java TestSocket netstat -natp 查看进程 nc
redis epoll nginx epoll nettty epoll
nginx等待请求,所以处于阻塞
redis
redis为单线程,除了接入处理任务,还要做LRU、LFU、RDB、AOF. 原子,串行化 redis的串行化:read--计算--write--read--计算--write
6.x多个线程读数据,计算还是串行
TCP粘包和拆包
粘包和拆包是TCP网络编程中不可避免的,无论是服务端还是客户端,当我们读取或者发送消息的时候,都需要考虑TCP底层的粘包/拆包机制。 TCP是个“流”协议,所谓流,就是没有界限的一串数据。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。
假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下4种情况。 服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包; 服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP粘包; 服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP拆包 服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余内容D1_2和D2包的整包。
TCP粘包和拆包产生的原因
数据从发送方到接收方需要经过操作系统的缓冲区,而造成粘包和拆包的主要原因就在这个缓冲区上。粘包可以理解为缓冲区数据堆积,导致多个请求数据粘在一起,而拆包可以理解为发送的数据大于缓冲区,进行拆分处理。
原因有三个
1. 应用程序write写入的字节大小大于套接口发送缓冲区大小 2. 进行MSS大小的TCP分段 3. 以太网帧的payload大于MTU进行IP分片。
粘包和拆包的解决方法
由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下。 1. 消息长度固定,累计读取到长度和为定长LEN的报文后,就认为读取到了一个完整的信息 2. 将回车换行符作为消息结束符 3. 将特殊的分隔符作为消息的结束标志,回车换行符就是一种特殊的结束分隔符 4. 通过在消息头中定义长度字段来标识消息的总长度
Netty中的粘包和拆包解决方案
Netty提供了4种解码器来解决,分别如下: 1. 固定长度的拆包器 FixedLengthFrameDecoder,每个应用层数据包的都拆分成都是固定长度的大小 2. 行拆包器 LineBasedFrameDecoder,每个应用层数据包,都以换行符作为分隔符,进行分割拆分 3. 分隔符拆包器 DelimiterBasedFrameDecoder,每个应用层数据包,都通过自定义的分隔符,进行分割拆分 4. 基于数据包长度的拆包器 LengthFieldBasedFrameDecoder,将应用层数据包的长度,作为接收端应用层数据包的拆分依据。按照应用层数据包的大小,拆包。这个拆包器,有一个要求,就是应用层协议中包含数据包的长度 以上解码器在使用时只需要添加到Netty的责任链中即可
常用的是通过指定接收数据的长度来解决,也就是LengthFieldBasedFrameDecoder()这个类。在消息头中定义长度字段,来标识消息的总长度
消息队列
什么是消息队列
一种应用间的通信方式,主要由三个部分组成。 1. 生产者 2. 代理,负责消息存储、投递、及各种队列附加功能的实现 3. 消费者
消息队列的应用场景
异步处理
应用于对实时性要求不太高的场景,如发送验证码、下单通知等
应用解耦
把相关耦合度不高的系统联系起来,解决了各个系统可以采用不同架构、语言来实现
流量削峰
在大流量入口且短时间内业务需求处理不完的服务中心,为了权衡高可用
为什么要选择MQ
1.由于我们系统的QPS压力比较大。 2.开发语言,由于我们的开发语言是java,主要是为了方便二次开发。 3.对于高并发的业务场景是必须的,所以需要支持分布式架构的设计。 4.功能全面,由于不同的业务场景,可能会用到顺序消息、事务消息等。
RabbitMQ
描述:rabbitMQ是基于AMQP协议(Advanced Message Queuing Protocol)的,通过使用通用协议就可以做到在不同语言之间传递。是一个异步消息传递所使用应用层协议规范,为面向消息中间件设计,基于此协议的客户端与消息中间件可以无视消息来源传递消息,不受客户端、消息中间件、不同的开发语言环境等条件的限制。
整体架构
RabbitMQ的broker由Exchange,Binding,queue组成
AMQP协议(高级消息队列协议)
server:又称broker,接受客户端连接,实现AMQP实体服务。 connection:连接和具体broker网络连接。 channel:网络信道,几乎所有操作都在channel中进行,channel是消息读写的通道。客户端可以建立多个channel,每个channel表示一个会话任务。 message:消息,服务器和应用程序之间传递的数据,由properties和body组成。properties可以对消息进行修饰,比如消息的优先级,延迟等高级特性;body是消息实体内容。 Virtual host:虚拟主机,用于逻辑隔离,最上层消息的路由。一个Virtual host可以若干个Exchange和Queue,同一个Virtual host不能有同名的Exchange或Queue。 Exchange:交换机,接受消息,根据路由键转发消息到绑定的队列上。 banding:Exchange和Queue之间的虚拟连接,binding中可以包括routing key routing key: 一个路由规则,虚拟机根据他来确定如何路由 一条消息。 Queue:消息队列,用来存放消息的队列。 Durability : 是否持久化。 Auto delete : 如选yes,代表当最后一个监听被移除之后, 该Queue会自动被删除
Exchange
交换机类型
direct-直连交换机
消息传递时, RoutingKey必须完全匹配才会被队列接收
第一个 X - Q1 就有一个 binding key,名字为 orange; X - Q2 就有 2 个 binding key,名字为 black 和 green。当消息中的 路由键 和 这个 binding key 对应上的时候,那么就知道了该消息去到哪一个队列中
Direct Exchange:根据key全文匹配去寻找队列。 所有发送到Direct Exchange的消息被转发到RouteKey 中指定的Queue,Direct Exchange是默认的Exchange 默认的Exchange会绑定所有的队列,所以Direct可以直接使用Queue名(作为routing key )绑定。或者消费者和生产者的routing key完全匹配
topic-主题交换机
队列通过路由键绑定到交换机上,然后交换机根据路由值,将消息路由给一个或多个绑定队列(模糊匹配)‘#’:匹配一个或多个词,‘*’:匹配一个词
Toptic Exchange:转发消息主要是根据通配符。 是指发送到Topic Exchange的消息被转发到所有关心的Routing key中指定topic的Queue上。 Exchange 将routing key和某Topic进行模糊匹配,此时队列需要绑定一个topic。 所谓模糊匹配就是可以使用通配符,“#”可以匹配一个或多个词,“*”只匹配一个词。 比如“log.#”可以匹配“log.info.test”, "log.*"就只能匹配log.error。
fanout-扇型交换机 [fænaut]
Fanout转发是最快的
Fanout Exchange 消息广播的模式,不管路由键或者是路由模式,会把消息发给绑定给它的全部队列,如果配置了 routing_key 会被忽略。
headers-头交换机
headers 也是根据规则匹配, 相较于 direct 和 topic 固定地使用 routing_key , headers 则是一个自定义匹配规则的类型. 在队列与交换器绑定时, 会设定一组键值对规则, 消息中也包括一组键值对( headers 属性), 当这些键值对有一对, 或全部匹配时, 消息被投送到对应队列.
durability-持久化 [ˌdʊrəˈbɪləti]
Durability-持久化
是否需要持久化, true为持久化
Auto Delete
当最后一个绑定到Exchange上的队列删除后, 自动删除该Exchange
Internal
当前Exchange是否用于RabbitMQ内部使用, 默认为False, 这个属性很少会用到
Arguments
5种工作模式
点对点的队列
不需要交换机 一个生产者,一个消费者
工作队列(公平性)
在workers之间分配任务
一个生产者,多个消费者,但是一个消息只会发送给一个队列(竞争的消费者模式) 默认是轮询,即会将消息轮流发给多个消费者。 但这样对消费得比较慢的消费者不公平 可采用公平分配,即能者多劳channel.basicQos(1);// 限定:发送一条信息给消费者A,消费者A未反馈处理结果之前,不会再次发送信息给消费者Aboolean autoAck = false;// 取消自动反馈 channel.basicConsume(QUEUE_NAME, autoAck, consumer);// 接收信息channel.basicAck(envelope.getDeliveryTag(), false);// 反馈消息处理完毕
发布/订阅
该模式就是Fanout Exchange(扇型交换机)将消息路由给绑定到它身上的所有队列
一个生产者,多个消费者 每一个消费者都有自己的一个队列 生产者没有直接发消息到队列中,而是发送到交换机 每个消费者的队列都绑定到交换机上 消息通过交换机到达每个消费者的队列
路由
该模式采用Direct exchange(直连交换机)
生产者发送消息到交换机并指定一个路由key,消费者队列绑定到交换机时要制定路由key(key匹配就能接受消息,key不匹配就不能接受消息)
主题(通配符)
该模式采用Topic exchange(主题交换机)
此模式实在路由key模式的基础上,使用了通配符来管理消费者接收消息。生产者P发送消息到交换机X,交换机根据绑定队列的routing key的值进行通配符匹配 符号#:匹配一个或者多个词lazy.# 可以匹配lazy.irs或者lazy.irs.cor 符号*:只能匹配一个词lazy.* 可以匹配lazy.irs或者lazy.cor 该模式采用Topic exchange(主题交换机)
AMQP事务机制
txSelect 将当前channel设置为transaction模式 txCommit 提交当前事务 txRollback 事务回滚
Confirm模式
消息的确认, 是指生产者投递消息后, 如果Broker收到消息, 则会给我们产生一个应答 生产者进行接收应答, 用来确定这条消息是否正常发送到Broker, 这种方式也是消息的可靠性投递的核心保障 在channel上开启确认模式 : channel.confirmSelect() 在channel上添加监听 : addConfirmListener, 监听成功和失败的返回结果, 根据具体的结果对消息进行重新发送, 或记录日志等后续处理
Return消息机制
Return Listener用于处理一些不可路由的消息 正常情况下消息生产者通过指定一个Exchange和RoutingKey, 把消息送到某一个队列中去, 然后消费者监听队列, 进行消费,但在某些情况下, 如果在发送消息的时候, 当前的exchange不存在或者指定的路由key路由不到,这个时候如果我们需要监听这种不可达的消息, 就要使用Return Listener。 在基础API中有一个关键的配置项Mandatory : 如果为true, 则监听器会接收到路由不可达的消息, 然后进行后续处理(补偿或人工处理), 如果为false, 那么broker端自动删除该消息。
如何保障消息可靠传递
保障消息的成功发出 保障MQ节点的成功接收 发送端收到MQ节点(Broker)的确认应答 完善的消息补偿机制
方案
消息落库, 对消息状态进行标记
step1:消息入库 step2:消息发送 step3:MQ消息确认 step4:更新库中消息状态为已确认 step5:定时任务读取数据库中未确认的消息 step6:未收到确认结果的消息重新发送 step7:如果重试几次之后仍然失败, 则将消息状态更改为投递失败的终态, 后面需要人工介入
消息的延迟投递, 做二次确认, 回调检查
step1 : 第一次消息发送, 必须业务数据落库之后才能进行消息发送 step2 : 第二次消息延迟发送, 设定延迟一段时间发送第二次check消息 step3 : 消费端监听Broker, 进行消息消费 step4 : 消费成功之后, 发送确认消息到确认消息队列 step5 : Callback Service监听step4中的确认消息队列, 维护消息状态, 是否消费成功等状态 step6 : Callback Service监听step2发送的Delay Check的消息队列, 检测内部的消息状态, 如果消息是发送成功状态, 则流程结束, 如果消息是失败状态, 或者查不到当前消息状态时, 会通知生产者, 进行消息重发, 重新上述步骤
重试机制和幂等性保障(消费者端)
重试机制
消费者在消费消息的时候,如果消费者业务逻辑出现程序异常,会使用消息重试机制。 情况1: 消费者获取到消息后,调用第三方接口,但接口暂时无法访问,是否需要重试? (需要重试机制) 情况2: 消费者获取到消息后,抛出数据转换异常,是否需要重试?(不需要重试机制)需要发布进行解决。 对于情况2,如果消费者代码抛出异常是需要发布新版本才能解决的问题,那么不需要重试,重试也无济于事。应该采用日志记录+定时任务job健康检查+人工进行补偿
重试机制实现
在SpringBoot中,@RabbitListener(queue="")用于消费者监听队列。底层使用Aop进行拦截,如果程序没有抛出异常,则自动提交事务。如果抛出异常,该消息会缓存到RabbitMQ服务器,自动实施重试机制,一直到成功为止。可以配置重试间隔时间和重试的次数。
幂等性
多次执行, 结果保持一致 网络延迟传输中,消费出现异常或者是消费延迟消费,会造成MQ进行重试补偿,在重试过程中,可能会造成重复消费。
解决方案
1. 唯一ID+指纹码机制唯一ID + 指纹码机制,利用数据库主键去重SELECT COUNT(1) FROM T_ORDER WHERE ID = 唯一ID +指纹码好处:实现简单坏处:高并发下有数据库写入的性能瓶颈解决方案:跟进ID进行分库分表进行算法路由 2. 利用Redis的原子性去实现在接收到消息后将消息ID作为key执行 setnx 命令,如果执行成功就表示没有处理过这条消息,可以进行消费了,执行失败表示消息已经被消费了。
消费端限流
消息队列中囤积了大量的消息, 或者某些时刻生产的消息远远大于消费者处理能力的时候, 这个时候如果消费者一次取出大量的消息, 但是客户端又无法处理, 就会出现问题, 甚至可能导致服务崩溃, 所以需要对消费端进行限流 RabbitMQ提供了一种qos(服务质量保证)功能, 即在非自动确认消息的前提下, 如果一定数目的消息(通过consumer或者channel设置qos的值)未被确认前, 不进行消费新的消息
Kafka
原理
分区加副本 生产者发送过来的消息,会先存到 Leader Partition 里面,然后再把消息复制到 Follower Partition,这样设计的好处就是一旦Leader Partition 所在的节点挂了,可以重新从剩余的 Partition 副本里面选举出新的 Leader。然后消费者可以继续从新的 Leader Partition 里面获取未消费的数据。
•Producer :消息生产者,就是向 kafka broker 发消息的客户端。 •Consumer :消息消费者,向 kafka broker 取消息的客户端。 •Topic :可以理解为一个队列,一个 Topic 又分为一个或多个分区, •Consumer Group:这是 kafka 用来实现一个 topic 消息的广播(发给所有的 consumer)和单播(发给任意一个 consumer)的手段。一个 topic 可以有多个 Consumer Group。 •Broker :一台 kafka 服务器就是一个 broker。一个集群由多个 broker 组成。一个 broker 可以容纳多个 topic。 •Partition:为了实现扩展性,一个非常大的 topic 可以分布到多个 broker上,每个 partition 是一个有序的队列。partition 中的每条消息都会被分配一个有序的id(offset)。将消息发给 consumer,kafka 只保证按一个 partition 中的消息的顺序,不保证一个 topic 的整体(多个 partition 间)的顺序。 •Offset:kafka 的存储文件都是按照 offset.kafka 来命名,用 offset 做名字的好处是方便查找。例如你想找位于 2049 的位置,只要找到 2048.kafka 的文件即可。当然 the first offset 就是 00000000000.kafka。
零拷贝原理
实际应用中,我们需要把磁盘中的文件内容发送到远程服务器,要经过4次拷贝
1. 从磁盘中读文件内容拷贝到内核缓冲区 2. CPU把内核缓冲区数据拷贝到用户空间缓冲区中 3. 应用程序,调用write方法,将用户空间缓冲区数据拷贝到内核下的socket buffer中 4. 把内核模式下的socketBuffer中的数据拷贝到网卡缓冲区
kafka实际是直接把磁盘中的数据从内核中直接传输给Socket,而不需要再经过应用程序所在的用户空间。减少了2次拷贝。 所谓零拷贝,并不是完全没有数据赋值,只是相对于用户空间来说,不再需要进行数据拷贝。对于前面说的整个流程来说,零拷贝只是减少了不必要的拷贝次数而已。
保证消息不丢失
三方面
producer端
a.把异步发送改成同步发送,这样producer 就能实时知道消息发送的结果。 b.添加异步回调函数来监听消息发送的结果,如果发送失败,可以在回调中重试。
Broker端
消息持久化到磁盘,采用异步批量刷磁盘的实现机制。 通过分区副本机制和acks机制(-1表示Broker的主分区收到消息,且从分区同步完毕,再给Producer返回确认)
Consumer
根据offset的值进行消费,批量消费并手动提交
Kafka 怎么避免重复消费?
1. 中间件角度:每消费一批数据,Kafka Broker 就会更新OffSet 的值,避免重复消费。 2. 从业务角度:采用幂等性字段,从业务角度来处理,比如加缓存,数据库唯一键等
如何保证消费的消息全部处理完成,后提交offset
采用计数器
每处理完一条消息,计数器加一,记录下异常的数据,用定时任务重新处理。
在同一个方法或函数处理
消费和处理逻辑封装在一个方法或函数中 确保能够正确处理可能出现的异常,并在处理后才返回
异常处理
处理消息,发生异常,要保存下来,由定时任务处理
避免重复消费
加入幂等性字段
代码层面
由kafka自行管理
properties.setProperty(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG,"true"); properties.setProperty(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG,"50");
由flink自行管理
StreamExecutionEnvironment env = StreamExecutionEnvironment .getExecutionEnvironment(); env.enableCheckpointing(60000); //flink开启checkpoint FlinkKafkaConsumer<String> consumer = new FlinkKafkaConsumer<String>(topics, new SimpleStringSchema(),properties); consumer.setStartFromGroupOffsets(); //从上次提交的offset的位置继续消费 consumer.setCommitOffsetsOnCheckpoints(true);//checkpoint的时候提交offset
可能会发生的问题: 在flink执行checkpoint的间隔内,从kafka中拉取到的数据还没有处理完成,导致offset没办法提交,而下一次的checkpoint已经开始了,这样flink会跳过对offset的提交。
Kafka 如何保证消息消费的顺序性
自定义消息分区路由算法,把指定的key都发送到同一个Partition中
同一个消费者如果多线程处理,也可能有无序问题。 解决办法就是在消费者这边使用一个阻塞队列,再处理
注:设置消费者的 max.poll.records 参数,确保每次拉取的消息数量合适,以避免因一次拉取的消息过多而导致处理速度过慢。
Kafka 消息队列怎么保证 exactlyOnce,怎么实现顺序消费?
在MQ 的消息投递的语义有三种
At Most Once: 消息投递至多一次,可能会丢但不会出现重复。 At Least Once: 消息投递至少一次,可能会出现重复但不会丢。 Exactly Once: 消息投递正好一次,不会出现重复也不会丢。
注:不管中间件如何保证消息,我们在业务层总要加入幂等性机制,来避免重试带来的重复消费问题
kafka 为啥是拉取消息而不是推送消息
kafka是分布式流数据平台,高吞吐量、低延迟的消息系统。 kafka集群中拉取消息具有更高的灵活性和可控性。消费者根据自身的处理能力和需求主动拉取消息
消息拉取的工作流程
订阅主题
通过指定主题(消息的容器)来获取相应的消息
拉取消息的偏移量
就是消息在主题中的位置信息,有两种管理方式:手动管理,自动管理
拉取消息
指定偏移量,就可以拉取消息了。可以按时间戳、偏移量范围等方式进行消息拉取
处理消息
获取到消息后,进行相应处理
提交偏移量
在消息处理完成后,消费者需求提交偏移量,分为手动提交或自动提交
消息拉取的优势和适应场景
优势
灵活性高:消费者可以根据自身的处理能力和需求主动拉取消息,灵活控制消息的获取速度和频率。 节约资源:由于消费者主动拉取消息,可以避免消息的重复推送,节约了网络带宽和系统资源。 异步处理:消费者可以将消息拉取和消息处理过程进行解耦,实现异步处理,提高系统的响应速度和吞吐量。
场景
实时数据处理:Kafka可以实时地处理大规模的数据流,通过消息拉取机制,消费者可以根据实际需求灵活地获取消息,进行实时的数据处理和分析。 .分布式系统集成:Kafka作为一种分布式流数据平台,广泛应用于分布式系统中。通过消息拉取机制,各个节点可以实时地获取和处理消息,实现分布式系统之间的数据交换和集成。
高性能原因
生产者
顺序写入
磁盘喜欢顺序IO,所以kafka就是使用顺序IO
把所有数据都保存下来
Memory Mapped Files
利用操作系统的Page来实现文件到物理内存的直接映射。在适当的时候同步到磁盘上(其实就是异步存储)
消费者
零拷贝
传统read/write方式进行网络文件传输的方式,文件数据实际上是经过了四次copy操作: 硬盘—>内核buf—>用户buf—>socket相关缓冲区—>协议引擎 kafka基于sendfile实现Zero Copy,直接从内核空间(DMA的)到内核空间(Socket的),然后发送网卡。
批量压缩
Kafka支持多种压缩协议,包括Gzip和Snappy压缩协议。
Kafka与RabbitMQ的区别
交互方式不同
RabbitMQ 采用push的方式 kafka采用pull的方式
负载均衡
rabbitMQ的负载均衡需要单独的loadbalancer进行支持。 kafka采用zookeeper对集群中的broker、consumer进行管理
场景
rabbitMQ支持对消息的可靠的传递,支持事务,不支持批量的操作;基于存储的可靠性的要求存储可以采用内存或者硬盘。 金融场景中经常使用 kafka具有高的吞吐量,内部采用消息的批量处理,zero-copy机制,数据的存储和获取是本地磁盘顺序批量操作,具有O(1)的复杂度(与分区上的存储大小无关),消息处理的效率很高。(大数据)
rabbitMQ:事务、消息可靠传递、队列单点、只消息主队列、不支持批量处理 kafka:分布式分区、高吞吐、批量处理、零copy
架构
RabbitMQ架构
队列是单节点
1:broker:每个节点运行的服务程序,功能为维护该节点的队列的增删以及转发队列操作请求。 2:master queue:每个队列都分为一个主队列和若干个镜像队列。 3:mirror queue:镜像队列,作为master queue的备份。在master queue所在节点挂掉之后,系统把mirror queue提升为master queue,负责处理客户端队列操作请求。注意,mirror queue只做镜像,设计目的不是为了承担客户端读写压力。
队列消费
只消费master queue数据,mirror queue只是做为master queue的备份 注:mirror queue要同步master中的数据
队列生产
原理和消费一样,如果连接到非 master queue 节点,则路由过去。
缺点
由于master queue单节点,导致性能瓶颈,吞吐量受限。虽然为了提高性能,内部使用了Erlang这个语言实现,但是终究摆脱不了架构设计上的致命缺陷。
RabbitMQ的broker由exchange,bingding,queue组成
生产者:客户端Producer通过连接channel和server进行通信,
消费者:Consumer从queue获取消息进行消费(长连接,queue有消息会推送到consumer端,consumer循环从输入流读取数据)
Kafka
队列单一master变成多个master
把一个队列的流量均匀分散在多台机器上(每台机器有一个master),多个master之间的数据没有交集,每个master queue在Kafka中叫做Partition(一个分片)。 一个队列有多个主分片,每个主分片又有若干副分片做备份,同步机制类似于RabbitMQ。
kafka遵从一般的MQ结构,producer,broker,consumer
以consumer为中心,消息的消费信息保存的客户端consumer上,consumer根据消费的点,从broker上批量pull数据;无消息确认机制。
吞吐量
内部采用消息的批量处理,zero-copy机制,数据的存储和获取是本地磁盘顺序批量操作,具有O(1)的复杂度,消息处理的效率很高。
rabbitMQ支持对消息的可靠的传递,支持事务,不支持批量的操作;基于存储的可靠性的要求存储可以采用内存或者硬盘。
领域建模
描述
开发出一款基于业务的高质量软件产品,一直是架构师与软件开发工程师为之努力的目标,之所以说是努力的目标,是因为这其中存在众多的难题。
如何将业务需求准确的转化为软件设计?如何能让团队人员在开发时能专注于业务,而不是技术本身?如何解决跨部门间语言沟通问题,如开发与业务之间的沟通障碍?如何解决软件系统不会随着时间推移而慢慢腐烂的问题等等……
理解Domain Driven
一、在设计时,压根就不要去考虑技术实现,只考虑业务,只考虑domain。 二、DDD不纯粹是一个软件开发方法,强调Domain,强调领域就要有沟通,一定要有协作,很多人之所以做DDD比较困难,甚至于做的不好,就是没有业务人员参与,基于业务的沟通机制是DDD最核心的。
价值
企业有2个方面的价值
一是能帮助企业识别自身核心领域,决定企业资源的运用。 二是能帮助企业优化团队组织结构与文化。
从价值导向看,DDD对于企业而言,是一种策略,能帮助企业识别自身核心领域,决定了企业资源怎样去运用,来支撑核心竞争力。比如核心领域,需要花大精力去做,非核心领域,可以采用更简单、更快速的方式去做,甚至直接采购成熟的软件套件。
DDD对团队组织结构和文化的价值进行了阐述,他表示,很多时候并不是技术上的问题,而是团队组织结构和文化方面的问题。当然这不纯粹是DDD能推动的,还可以通过敏捷或者精益的方式。
架构师而言
DDD对架构师落地战略设计是会有帮助的,周宇刚说。因为,DDD解决跨部门间语言沟通问题,在业务、产品、开发之间建立领域通用语言以提高沟通效率。而对于开发者,DDD的作用更实际,能帮助开发者正确地开发所负责项目的代码。
DDD能帮助架构师确认系统、领域边界,边界确认好,即使里边有问题,只要边界是稳定的,那么,系统就不会受到太大影响。
边界控制好,整体架构就会很清晰,把控也会更直观,这样的架构就不容易腐烂,现在的软件系统最大的问题,是随着时间推移它就会慢慢腐烂。而且把边界控制好,代码落到实现层面上也就相对容易。
开发者层面
一、能够帮助开发者补充知识,不想当架构师的开发者不是一个好的开发者,开发者不能只满足于架构师分配的一亩三分地,也必须了解DDD知识。开发者如果只知道架构师要干嘛,却不知道架构师为什么要这么干,是很难提高的,这也不利于开发者去实现出来。 二、一体化的知识结构,从DDD落地上说,因为DDD更偏向于业务,更希望从业务的角度去看,所以,DDD能更好地和开发者所掌握的一些最基本设计原则匹配起来。
简单业务采用事务脚本,复杂业务采用领域建模
例子
银行转账
实现
脚本实现
两个账号之间转账的领域业务逻辑写在一个类里
子主题
领域模型实现
用DDD的方式实现
账号
Account实体除了账号属性之外,还包含了行为和业务逻辑,比如debit( )和credit( )方法。
账号类 加入两个方法,来处理逻辑
透支策略
透支策略OverdraftPolicy也不仅仅是一个Enum了,而是被抽象成包含了业务规则并采用了策略模式的对象。
透支策略
Domain Service
而Domain Service只需要调用Domain Entity对象完成业务逻辑即可。通过上面的DDD重构后,原来在事务脚本中的逻辑,被分散到Domain Service,Domain Entity和OverdraftPolicy三个满足SOLID的对象中,在继续阅读之前,我建议可以自己先体会一下DDD的好处。
子主题
好处
面向对象
封装:Account的相关操作都封装在Account Entity上,提高了内聚性和可重用性。 多态:采用策略模式的OverdraftPolicy(多态的典型应用)提高了代码的可扩展性。
业务语义显性化
通用语言
“一个团队,一种语言”,将模型作为语言的支柱。
确保团队在内部的所有交流中,代码中,画图,写东西,特别是讲话的时候都要使用这种语言。例如账号,转账,透支策略,这些都是非常重要的领域概念,如果这些命名都和我们日常讨论以及PRD中的描述保持一致,将会极大提升代码的可读性,减少认知成本。
显性化
就是将隐式的业务逻辑从一推if-else里面抽取出来,用通用语言去命名、去写代码、去扩展,让其变成显示概念,比如“透支策略”这个重要的业务概念,按照事务脚本的写法,其含义完全淹没在代码逻辑中没有突显出来,看代码的人自然也是一脸懵逼,而领域模型里面将其用策略模式抽象出来,不仅提高了代码的可读性,可扩展性也好了很多。
如何建模
极简建模方法
2步
1. 首先从User Story找名词和动词 2. 然后用UML类图画出领域模型
例
设计一个中介系统,一个典型的User Story可能是“小明去找工作,中介说你留个电话,有工作机会我会通知你”
分析
领域对象:关键名词就是我们需要的
小明
求职者
电话
求职者的属性
中介
包含了中介公司、中介员工
通知
动词暗示我们可以用观察者模式
工作机会
也是关键领域对象
领域对象之间关系
求职者 n:n 工作机会
中介公司 1:n 员工
总结
并不是所有的名词都是领域对象,也可能是属性。 也不是所有动词都是方式也可能是领域对象。 具体问题具体对待,这个对待的过程需要我们有很好的业务理解力,抽象能力以及建模经验。
例子
价格和库存只是订单和商品的一个属性,但是在阿里系电商业务场景下,价格计算和库存扣减的复杂程度可以让你怀疑人生,因此作为电商中台,把价格和库存单独当成一个域(Domain)去对待是很必要的。另外,建模不是一个一次性的工作,往往随着业务的变化以及我们对业务的理解越来越深入才能看清系统的全貌,所以迭代重构是免不了的,也就是要Agile Modelling。
模型统一和模型演化
模型统一
建模的过程很像盲人摸象,不同的人,看同一个东西,理解也是不一样的。事实上他们需要一个新的抽象,这个抽象需要把蛇“活动的特性”与“消防水管”的“喷水功能”合并到一起,而这个抽象还应该排除先前两个模型中一些不确切的含义和属性
世界上唯一不变的就是变化,模型和代码一样也需要不断的重构和精化,每一次的精化之后,开发人员应该对领域知识有了更加清晰的认识。这使得理解上的突破成为可能,之后,一系列快速的改变得到了更符合用户需要并更加切合实际的模型。其功能性及说明性急速增强,而复杂性却随之消失。
领域服务
什么是领域服务
有些领域中的动作,它们是一些动词,看上去却不属于任何对象。它们代表了领域中的一个重要的行为,所以不能忽略它们或者简单地把它们合并到某个实体或者值对象中。当这样的行为从领域中被识别出来时,最佳实践是将它声明成一个服务。
这样的对象不再拥有内置的状态。它的作用仅仅是为领域提供相应的功能。Service往往是以一个活动来命名,而不是Entity来命名。例如开篇转账的例子,转账(transfer)这个行为是一个非常重要的领域概念,但是它是发生在两个账号之间的,归属于账号Entity并不合适,因为一个账号Entity没有必要去关联他需要转账的账号Entity,这种情况下,使用MoneyTransferDomainService就比较合适了。
识别领域服务,主要看它是否满足以下三个特征: 服务执行的操作代表了一个领域概念,这个领域概念无法自然地隶属于一个实体或者值对象。 被执行的操作涉及到领域中的其他的对象。 操作是无状态的。
应用服务和领域服务如何划分
领域建模三个大的层次
应用层-Application Layer
如果所执行的操作概念上属于应用层,那么服务就应该放到这个层
划分建议
转账app服务: 1. 输入(如消化。XML请求), 2. 向域服务发送消息以实现, 3.监听确认, 4. 决定使用基础设施服务发送通知。
领域层-Domain Layer
如果操作是关于领域对象的,而且确实是与领域有关的、为领域的需要服务,那么它就应该属于领域层
划分建议
资金转账领域服务: 与必要的帐户和分类账对象进行沟通,制定适当的借方和贷方,并对结果进行确认。
基础实施层-Infrastructure Layer
划分建议
发送通知服务: 根据申请的要求发送电子邮件、信件等。
业务可视化和可配置化
好的领域建模可以降低应用的复杂性,而可视化和可配置化主要是帮助大家(主要是非技术人员,比如产品,业务和客户)直观地了解系统和配置系统。要注意的是可视化和可配置化难免会给系统增加额外的复杂度,必须慎之又慎,最好是能使可视化和配置化的逻辑与业务逻辑尽量少的耦合,否则破坏了原有的架构,把事情搞的更复杂就得不偿失了。
子主题
GIT
初始设置
> 1、git config --global user.name "<用户名>" 设置用户名 > 2、git config --global user.email "<电子邮件>" 设置电子邮件
本地操作
> 1、git add [-i] 保存更新,-i为逐个确认。 > 2、git status 检查更新。 > 3、git commit [-a] -m "<更新说明>" 提交更新,-a为包含内容修改和增删, -m为说明信息,也可以使用 -am。
远端操作
> 1、git clone <git地址> 克隆到本地。 > 2、git fetch 远端抓取。 > 3、git merge 与本地当前分支合并。 > 4、git pull [<远端别名>] [<远端branch>] 抓取并合并,相当于第2、3步 > 5、git push [-f] [<远端别名>] [<远端branch>] 推送到远端,-f为强制覆盖 > 6、git remote add <别名> <git地址> 设置远端别名 > 7、git remote [-v] 列出远端,-v为详细信息 > 8、git remote show <远端别名> 查看远端信息 > 9、git remote rename <远端别名> <新远端别名> 重命名远端 > 10、git remote rm <远端别名> 删除远端 > 11、git remote update [<远端别名>] 更新分支列表
git fetch
head就是本地分支,remotes是跟踪的远程分支。 命令:git fetch+git merge
使用git fetch更新代码,本地的库中master的commitID不变,还是等于1。但是与git上面关联的那个orign/master的commit ID变成了2。这时候我们本地相当于存储了两个代码的版本号,我们还要通过merge去合并这两个不同的代码版本,如果这两个版本都修改了同一处的代码,这时候merge就会出现冲突,然后我们解决冲突之后就生成了一个新的代码版本。
git pull
使用git pull的会将本地的代码更新至远程仓库里面最新的代码版本。 git pull看起来像git fetch+get merge
分支
分支相关
> 1、git branch [-r] [-a] 列出分支,-r远端 ,-a全部 > 2、git branch <分支名> 新建分支 > 3、git checkout <分支名> 切换到分支 > 4、git checkout -b <本地branch> [-t <远端别名>/<远端分支>] -b新建本地分支并切换到分支, -t绑定远端分支
基于现有master创建新分支
1.切换到被copy的分支(master),并且从远端拉取最新版本 $git checkout master $git pull 2.从当前分支拉copy开发分支 $git checkout -b dev Switched to a new branch 'dev' 3.把新建的分支push到远端 $git push origin dev 4.关联 $git branch --set-upstream-to=origin/dev 5.再次拉取验证 $git pull
协同流程(针对Github)
> 1、首先fork远程项目 > 2、把fork过去的项目也就是你的项目clone到你的本地 > 3、运行 git remote add <远端别名> <别人的远端分支> 把别人的库添加为远端库 > 4、运行 git pull <远端别名> <远端分支> 拉取并合并到本地 > 5、编辑内容 > 6、commit后push到自己的库(git push <自己的远端别名> <自己的远端分支>) > 7、登陆Github在你首页可以看到一个 pull request 按钮,点击它,填写一些说明信息,然后提交即可。 1~3是初始化操作,执行一次即可。在本地编辑内容前必须执行第4步同步别人的远端库(这样避免冲突),然后执行5~7既可。
链路追踪
大型分布式微服务系统中,一个系统被拆分成N多个模块,这些模块负责不同的功能,组合成一套系统,最终可以提供丰富的功能。在这种分布式架构中,一次请求往往需要涉及到多个服务。 为了能够在分布式架构中快速定位问题,分布式链路追踪应运而生。将一次分布式请求还原成调用链路,进行日志记录,性能监控并将一次分布式请求的调用情况集中展示。比如各个服务节点上的耗时、请求具体到达哪台机器上、每个服务节点的请求状态等等。
Spring Cloud Sleuth是什么?
Spring Cloud Sleuth实现了一种分布式的服务链路跟踪解决方案,通过使用Sleuth可以让我们快速定位某个服务的问题。简单来说,Sleuth相当于调用链监控工具的客户端,集成在各个微服务上,负责产生调用链监控数据。
Spring Cloud Sleuth只负责产生监控数据,通过日志的方式展示出来,并没有提供可视化的UI界面。
几个概念
Span:基本的工作单元,相当于链表中的一个节点,通过一个唯一ID标记它的开始、具体过程和结束。我们可以通过其中存储的开始和结束的时间戳来统计服务调用的耗时。除此之外还可以获取事件的名称、请求信息等。 Trace:一系列的Span串联形成的一个树状结构,当请求到达系统的入口时就会创建一个唯一ID(traceId),唯一标识一条链路。这个traceId始终在服务之间传递,直到请求的返回,那么就可以使用这个traceId将整个请求串联起来,形成一条完整的链路。 Annotation:一些核心注解用来标注微服务调用之间的事件,重要的几个注解如下: cs(Client Send):客户端发出请求,开始一个请求的生命周期 sr(Server Received):服务端接受请求并处理;sr-cs = 网络延迟 = 服务调用的时间 ss(Server Send):服务端处理完毕准备发送到客户端;ss - sr = 服务器上的请求处理时间 cr(Client Reveived):客户端接受到服务端的响应,请求结束;cr - sr = 请求的总时间
什么是ZipKin
子主题
4个核心组件
Collector:收集器组件,它主要用于处理从外部系统发送过来的跟踪信息,将这些信息转换为Zipkin内部处理的 Span 格式,以支持后续的存储、分析、展示等功能。 Storage:存储组件,它主要对处理收集器接收到的跟踪信息,默认会将这些信息存储在内存中,我们也可以修改此存储策略,通过使用其他存储组件将跟踪信息存储到数据库中 RESTful API:API 组件,它主要用来提供外部访问接口。比如给客户端展示跟踪信息,或是外接系统访问以实现监控等。 UI:基于API组件实现的上层应用。通过UI组件用户可以方便而有直观地查询和分析跟踪信息
分为服务端和客户端
服务端主要用来收集跟踪数据并且展示,客户端主要功能是发送给服务端,微服务的应用也就是客户端,这样一旦发生调用,就会触发监听器将sleuth日志数据传输给服务端。
zipkin服务端搭建
Skywalking架构
上面的Agent:负责收集日志数据,并且传递给中间的OAP服务器 中间的OAP:负责接收 Agent 发送的 Tracing 和Metric的数据信息,然后进行分析(Analysis Core) ,存储到外部存储器( Storage ),最终提供查询( Query )功能。 左面的UI:负责提供web控制台,查看链路,查看各种指标,性能等等。 右面Storage:负责数据的存储,支持多种存储类型。
看了架构图之后,思路很清晰了,Agent负责收集日志传输数据,通过GRPC的方式传递给OAP进行分析并且存储到数据库中,最终通过UI界面将分析的统计报表、服务依赖、拓扑关系图展示出来。
https://mp.weixin.qq.com/s?__biz=MzU3MDAzNDg1MA==&mid=2247501079&idx=1&sn=438ef3a3d65fb4919b61cf6972827bec&chksm=fcf71adacb8093ccc4c3d6dfb8860ca07aefbc761c1ed5126eb4a4e87548441841198db1f8e3&cur_album_id=2042874937312346114&scene=189#wechat_redirect
Auth
OAuth 2.0
为什么需要OAuth2.0
举个例子
小区的业主点了一份外卖,但是小区的门禁系统不给外卖人员进入,此时想要外卖员进入只能业主下来开门或者告知门禁的密码。
授权机制: --有没有一种方案:既能不泄露密码,也能让外卖小哥进入呢?
于是此时就想到了一个授权机制,分为以下几个步骤: 门禁系统中新增一个授权按钮,外卖小哥只需要点击授权按钮呼叫对应业主 业主收到小哥的呼叫,知道小哥正在要求授权,于是做出了应答授权 此时门禁系统弹出一个密码(类似于access_token),有效期30分钟,在30分钟内,小哥可以凭借这个密码进入小区。 小哥输入密码进入小区 另外这个授权的密码不仅可以通过门禁,还可以通过楼下的门禁,这就非常类似于网关和微服务了。
令牌和密码的区别
时效不同
令牌一般都是存在过期时间的,比如30分钟后失效,这个是无法修改的,除非重新申请授权;而密码一般都是永久的,除非主人去修改
权限不同
令牌的权限是有限的,比如上述例子中,小哥获取了令牌,能够打开小区的门禁、业主所在的楼下门禁,但是可能无法打开其它幢的门禁;
令牌可以撤销
业主可以撤销这个令牌的授权,一旦撤销了,这个令牌也就失效了,无法使用;但是密码一般不允许撤销。
为什么是OAuth2
OAuth 是一个开放标准,该标准允许用户让第三方应用访问该用户在某一网站上存储的私密资源(如头像、照片、视频等),而在这个过程中无需将用户名和密码提供给第三方应用。实现这一功能是通过提供一个令牌(token),而不是用户名和密码来访问他们存放在特定服务提供者的数据。
解决问题
传统的 Web 开发登录认证一般都是基于 session 的,但是在前后端分离的架构中继续使用 session 就会有许多不便,因为移动端(Android、iOS、微信小程序等)要么不支持 cookie(微信小程序),要么使用非常不便,对于这些问题,使用 OAuth2 认证都能解决。
为第三方应用颁发一个具有时效性的Token令牌,使其他服务或第三方应用能够通过令牌获取相关资源。
角色
resource owner --资源所有者(用户)
资源所有者,具备访问该资源的实体, 如果是某个人, 被称为end-user。
user-agent --用户代理(APP/浏览器)
user-agent: 用户代理, 作为资源所有者与客户端沟通的工具, 比如APP, 浏览器等。
client --客户端(百度)
客户端,这并不是指用户, 而是对资源服务器发起请求的应用程序,比如前后分离项目, 前端服务访问管理接口, 访问后台业务功能接口。
authorization server --授权服务器
authorization server: 授权服务器, 能够给客户端颁发令牌, 这个就是我们上面所讲的统一认证授权服务器。
resources server --资源服务器
资源服务器,受保护的资源服务器, 具备提供资源能力, 如订单服务, 商品服务等。
协议流程
子主题
Resource Owner 与 Client 之间 , 资源所有者向Client发起认证请求, Client再返回认证授权信息。 Client 收到 Resource Owner 的认证请求后, 会去Authorization Server 申请访问令牌, Authorization Server会让Client 进行认证, 通过之后会返回Access Token。 Client 拿到 Authorization Server 的 Acceess Token , 访问Resource Server,Resource Server 验证之后, 返回被保护的资源信息。 Resource Server 可以通过JWT在本地进行验证, 也可以访问 Authorization Server, 对Client 的请求的合法性进行验证。
授权类型
1. Authorization Code(授权码模式):授权码模式, 先通过认证获取授权码, 然后申请获取token,进行资源访问。 2. Implicit(简化/隐式模式):用于简单应用,比如问卷调查等,用户认证通过之后, 认证服务器直接向应用服务返回token,这种模式比授权码模式少了授权码code获取环节, 简化交互, 但存在token过期与暴露问题(因为不能获取refresh_token)。 3. Resource Owner Password Credentials(密码模式):资源所有者和客户端之间具有高度信任时(例如,客户端是设备的操作系统的一部分,或者是一个高度特权应用程序, 比如APP, 自研终端等),因为client可能存储用户密码。 4. Client Credentials(客户端模式):该模式直接根据client端的id和密钥即可获取token, 不需要用户参与, 适合内部的API应用服务使用。
4种授权模式 --图片是介绍角色
授权码模式流程
常见的第三方平台登录功能基本都是使用这种模式。 1. 资源拥有者(用户)通过代理(WEB浏览器)访问客户端程序,发起授权码模式认证。 2. 客户端(Client,比如CSDN论坛)向认证服务器(Auth Server,QQ账号认证服务)发起请求, 此时客户端携带了客户端标识(client_id, 标识来源是CSDN)和重定向地址(redirect_uri, 一般是CSDN的地址)。 3. 用户确认授权,客户端(Client)接收到code。 4. 在重定向的过程中,客户端拿到 code 与 client_id、client_secret 去授权服务器请求令牌,整个过程,用户代理是不会拿到令牌 token 的。 5. 客户端(Client)拿到令牌 token 后就可以向第三方的资源服务器请求资源了, 比如获取QQ基本资料, 头像等信息 --图的解释:客户端负责拿令牌,认证中心负责发放令牌。
提醒 不是所有客户端(像百度)都有权限请求令牌的,需要事先在认证中心申请,比如微信并不是所有网站都能直接接入,而是要去微信后台开通这个权限。 要提前向认证中心申请几个参数 client_id:客户端唯一id,认证中心颁发的唯一标识 client_secret:客户端的秘钥,相当于密码 scope:客户端的权限 redirect_uri:授权码模式使用的跳转uri,需要事先告知认证中心。
两次请求: 1. 先获取授权码 2. 再获取token
1. 请求授权码 --/oauth/authorize
客户端需要向认证中心拿到授权码,比如第三方登录使用微信,扫一扫登录那一步就是向微信的认证中心获取授权码。 url: /oauth/authorize?client_id=&response_type=code&scope=&redirect_uri=
几个参数
response_type=code // 必选项 &client_id={客户端的ID} // 必选项 &redirect_uri={重定向URI} // 可选项 &scope={申请的权限范围} // 可选项 &state={任意值} // 可选项
client_id:客户端的id,这个由认证中心分配,并不是所有的客户端都能随意接入认证中心 response_type:固定值为code,表示要求返回授权码。 scope:表示要求的授权范围,客户端的权限 redirect_uri:跳转的uri,认证中心同意或者拒绝授权跳转的地址,如果同意会在uri后面携带一个code=xxx,这就是授权码
2. 返回授权码 --code=NMoj5y
第1步请求之后,认证中心会要求登录、是否同意授权,用户同意授权之后直接跳转到redirect_uri(这个需要事先在认证中心申请配置),授权码会携带在这个地址后面,如下: http://xxxx?code=NMoj5y
授权响应参数
code={授权码} // 必填 &state={任意文字} // 如果授权请求中包含 state的话那就是必填
3. 请求令牌 --/oauth/token
客户端拿到授权码之后,直接携带授权码发送请求给认证中心获取令牌,请求的url如下: /oauth/token? client_id=& client_secret=& grant_type=authorization_code& --授权类型 code=NMoj5y& --授权码 redirect_uri=
令牌请求
grant_type=authorization_code // 必填 &code={授权码} // 必填 必须是认证服务器响应给的授权码 &redirect_uri={重定向URI} // 如果授权请求中包含 redirect_uri 那就是必填 &code_verifier={验证码} // 如果授权请求中包含 code_challenge 那就是必填
grant_type:授权类型,授权码固定的值为authorization_code code:这个就是上一步获取的授权码
4. 返回令牌
认证中心收到令牌请求之后,通过之后,会返回一段JSON数据,其中包含了令牌access_token,如下: { "access_token":"ACCESS_TOKEN", "token_type":"bearer", "expires_in":2592000, "refresh_token":"REFRESH_TOKEN", "scope":"read", "uid":100101 }
令牌响应
"access_token":"{访问令牌}", // 必填 "token_type":"{令牌类型}", // 必填 "expires_in":{过期时间}, // 任意 "refresh_token":"{刷新令牌}", // 任意 "scope":"{授权范围}" // 如果请求和响应的授权范围不一致就必填
access_token则是颁发的令牌,refresh_token是刷新令牌,一旦令牌失效则携带这个令牌进行刷新。
1. 申请授权接口 2. 申请token
密码模式
密码模式也很简单,直接通过用户名、密码获取令牌 密码模式是用户把用户名密码直接告诉客户端,客户端使用说这些信息向授权服务器申请令牌(token)。这需要用户对客户端高度信任,例如客户端应用和服务提供商就是同一家公司,自己做前后端分离登录就可以采用这种模式。 1. 资源拥有者直接通过客户端发起认证请求。 2. 客户端提供用户名和密码, 向认证服务器发起请求认证。 3. 认证服务器通过之后, 客户端(Client)拿到令牌 token 后就可以向第三方的资源服务器请求资源了。 此模式简化相关步骤, 直接通过用户和密码等隐私信息进行请求认证, 认证服务器直接返回token, 这需要整个环境具有较高的安全性。
+----------+ | Resource | | Owner | | | +----------+ v | Resource Owner (A) Password Credentials | v +---------+ +---------------+ | |>--(B)---- Resource Owner ------->| | | | Password Credentials | Authorization | | Client | | Server | | |<--(C)---- Access Token ---------<| | | | (w/ Optional Refresh Token) | | +---------+ +---------------+
1. 请求令牌
认证中心要求客户端输入用户名、密码,认证成功则颁发令牌,请求的url如下: /oauth/token? grant_type=password& username=& password=& client_id=& client_secret=
令牌请求
grant_type=password // 必填 &username={用户ID} // 必填 &password={密码} // 必填 &scope={授权范围} // 任意
grant_type:授权类型,密码模式固定值为password username:用户名 password:密码 client_id:客户端id client_secret:客户端的秘钥
2. 返回令牌
上述认证通过,直接返回JSON数据,不需要跳转,如下: { "access_token":"ACCESS_TOKEN", "token_type":"bearer", "expires_in":2592000, "refresh_token":"REFRESH_TOKEN", "scope":"read", "uid":100101 } access_token则是颁发的令牌,refresh_token是刷新令牌,一旦令牌失效则携带这个令牌进行刷新。
令牌响应
"access_token":"{访问令牌}", // 必填 "token_type":"{令牌类型}", // 必填 "expires_in":"{过期时间}", // 任意 "refresh_token":"{刷新令牌}", // 任意 "scope":"{授权范围}" // 如果请求和响应的授权范围不一致就必填
简化模式
这种模式不常用,主要针对那些无后台的系统,直接通过web跳转授权 这种方式把令牌直接传给前端,是很不安全的。因此,只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了。 简化模式是不需要客户端服务器参与,直接在浏览器中向授权服务器申请令牌(token),一般如果网站是纯静态页面则可以采用这种方式。 资源拥有者(用户)通过代理(WEB浏览器)访问客户端程序,发起简化模式认证。 客户端(Client)向认证服务器(Auth Server)发起请求, 此时客户端携带了客户端标识(client_id)和重定向地址(redirect_uri)。 客户端(Client)拿到令牌 token 后就可以向第三方的资源服务器请求资源了。
+----------+ | Resource | | Owner | | | +----------+ ^ | (B) +----|-----+ Client Identifier +---------------+ | -+----(A)-- & Redirection URI --->| | | User- | | Authorization | | Agent -|----(B)-- User authenticates -->| Server | | | | | | |<---(C)--- Redirection URI ----<| | | | with Access Token +---------------+ | | in Fragment | | +---------------+ | |----(D)--- Redirection URI ---->| Web-Hosted | | | without Fragment | Client | | | | Resource | | (F) |<---(E)------- Script ---------<| | | | +---------------+ +-|--------+ | | (A) (G) Access Token | | ^ v +---------+ | | | Client | | | +---------+
1. 请求令牌
客户端直接请求令牌,请求的url如下: /oauth/authorize? response_type=token& client_id=CLIENT_ID& redirect_uri=CALLBACK_URL& scope=
授权请求
response_type=token // 必选项 &client_id={客户端的ID} // 必选项 &redirect_uri={重定向URI} // 可选项 &scope={申请的权限范围} // 可选项 &state={任意值} // 可选项
client_id:客户端的唯一Id response_type:简化模式的固定值为token scope:客户端的权限 redirect_uri:跳转的uri,这里后面携带的直接是令牌,不是授权码了。
2. 返回令牌
认证中心认证通过后,会跳转到redirect_uri,并且后面携带着令牌,链接如下: https://xxxx#token=NPmdj5 #token=NPmdj5这一段后面携带的就是认证中心携带的,令牌为NPmdj5。
授权响应参数
&access_token={令牌信息} // 必填 &expires_in={过期时间} // 任意 &state={任意文字} // 如果授权请求中包含 state 那就是必填 &scope={授权范围} // 如果请求和响应的授权范围不一致就必填
问题
为什么要有授权码和简化模式?看完这两种模式, 可能会有些疑问, 为什么要这么麻烦, 直接一次请求返回TOKEN不就可以吗?
我们可以看出, 两者主要差别, 是少了code验证环节, 直接返回token了, code验证是客户端与认证服务器在后台进行请求获取, 代理是获取不到TOKEN的, 如果缺少这个环节, 直接返回TOKEN, 相当于直接暴露给所有参与者, 存在安全隐患, 所以简化模式,一般用于信赖度较高的环境中使用。
客户端模式
适用于没有前端的命令行应用,即在命令行下请求令牌。 这种方式给出的令牌,是针对第三方应用的,而不是针对用户的,即有可能多个用户共享同一个令牌。 客户端模式是指客户端使用自己的名义而不是用户的名义向服务提供者申请授权,严格来说,客户端模式并不能算作 OAuth 协议要解决的问题的一种解决方案,但是,对于开发者而言,在一些前后端分离应用或者为移动端提供的认证授权服务器上使用这种模式还是非常方便的 1. 此模式最为简单直接, 由客户端直接发起请求。 2. 客户端与服务器信赖度较高, 服务端根据请求直接认证返回token信息。 3. 客户端(Client)拿到令牌 token 后就可以向第三方的资源服务器请求资源了。 这种模式一般在内部服务之间应用, 授权一次, 长期可用, 不用刷新token。
+---------+ +---------------+ | | | | | |>--(A)- Client Authentication --->| Authorization | | Client | | Server | | |<--(B)---- Access Token ---------<| | | | | | +---------+ +---------------+
1. 请求令牌
请求的url为如下: /oauth/token? grant_type=client_credentials& client_id=& client_secret=
令牌请求
grant_type=client_credentials // 必填 client_id={客户端的ID} // 必填 client_secret={客户端的密钥} // 必填 &scope={授权范围} // 任意
grant_type:授权类型,客户端模式固定值为client_credentials client_id:客户端id client_secret:客户端秘钥
2. 返回令牌
认证成功后直接返回令牌,格式为JSON数据,如下: { "access_token": "ACCESS_TOKEN", "token_type": "bearer", "expires_in": 7200, "scope": "all" }
令牌响应
"access_token":"{访问令牌}", // 必填 "token_type":"{令牌类型}", // 必填 "expires_in":"{过期时间}", // 任意 "scope":"{授权范围}" // 如果请求和响应的授权范围不一致就必填
OAuth设计
子主题
JWT
什么是JWT
令牌分为两类, 分别是透明令牌、不透明令牌。
不透明令牌
不透明令牌则是令牌本身不存储任何信息,比如一串UUID,上篇文章中使用的InMemoryTokenStore就类似这种。
透明令牌
透明令牌本身就存储这部分用户信息,比如JWT,资源服务可以调用自身的服务对该令牌进行校验解析,不必调用认证服务的接口去校验令牌。
资源服务拿到这个令牌必须调调用认证授权服务的接口进行令牌的校验,高并发的情况下延迟很高,性能很低,正如上篇文章中资源服务器中配置的校验
JWT分三部分
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYzODYwNTcxOCwiYXV0aG9yaXRpZXMiOlsiUk9MRV91c2VyIl0sImp0aSI6ImRkNTVkMjEzLThkMDYtNGY4MC1iMGRmLTdkN2E0YWE2MmZlOSIsImNsaWVudF9pZCI6Im15anN6bCJ9. koup5-wzGfcSVnaaNfILwAgw2VaTLvRgq2JVnIHYe_Q
头部
定义了JWT基本信息,如类型和签名算法。
载荷
包含了一些基本信息(签发时间、过期时间.....),另外还可以添加一些自定义的信息,比如用户的部分信息。
签名
将前两个字符串用 . 连接后,使用头部定义的加密算法,利用密钥进行签名,并将签名信息附在最后。
测试
使用密码模式获取令牌
http://localhost:2007/auth-server/oauth/token
携带令牌调用资源服务
直接拿着获取的access_token调用资源服务的接口,请求如下: http://localhost:2006/auth-resource/hello
源码追踪
1、获取令牌
获取令牌就比较简简单了,当然从接口 /oauth/token入手了,这个接口在TokenEndpoint#postAccessToken()方法中
2. 校验令牌
校验令牌的更加简单了,入口就在OAuth2AuthenticationProcessingFilter这个过滤器,内部会调用OAuth2AuthenticationManager中的authenticate()方法进行验证令牌。
异常自定义
子主题
认证服务自定义异常信息
1、用户名,密码错误异常、授权类型异常
1、定制提示信息、响应码
自定义一个异常翻译器
自定义WebResponseExceptionTranslator
认证服务配置文件中的配置
需要将自定义的异常翻译器OAuthServerWebResponseExceptionTranslator在配置文件中配置,很简单,一行代码的事。 //设置异常WebResponseExceptionTranslator,用于处理用户名,密码错误、授权类型不正确的异常 .exceptionTranslator(new OAuthServerWebResponseExceptionTranslator())
实战
认证中心搭建
认证授权中心
oauth2-auth-server-in-memory模块作为认证中心
资源服务搭建
授权码模式测试
1. 获取授权码
1. 请求授权服务接口
http://localhost:2003/auth-server/oauth/authorize? client_id=myjszl& response_type=code& scope=all& redirect_uri=http://www.baidu.com
2. 进入登录页面
输入用户名密码
3. 进入OAuth批准页面 --是否同意授权访问资源
http://localhost:2003/auth-server/oauth/authorize? client_id=myjszl& response_type=code& scope=all& redirect_uri=http://www.baidu.com
4. 重定向到百度页面 --获得授权码code
https://www.baidu.com/?code=4AiXpk
2. 获取token
post方式请求token接口
http://localhost:2003/auth-server/oauth/token? code=jvMH5U& client_id=myjszl& client_secret=123& redirect_uri=http://www.baidu.com& grant_type=authorization_code
3. 访问资源服务
get方式请求资源接口
http://localhost:2004/auth-resource/hello 请求头需要添加Authorization,并且值为Bearer+" "+access_token的形式。
密码模式测试
密码模式比较简单,不用先获取授权码,直接使用用户名、密码获取token。
简化模式测试
简化模式就很简单了,拿着客户端id就可以获取token 需要登录
http://localhost:2003/auth-server/oauth/authorize? response_type=token& client_id=myjszl& redirect_uri=http://www.baidu.com&scope=all
客户端模式
http://localhost:2003/auth-server/oauth/token?client_id=myjszl&client_secret=123&grant_type=client_credentials
刷新令牌
OAuth2.0提供了令牌刷新机制,一旦access_token过期,客户端可以拿着refresh_token去请求认证中心进行令牌的续期。
http://localhost:2003/auth-server/oauth/token?client_id=myjszl&client_secret=123&grant_type=refresh_token&refresh_token=
校验令牌
http://localhost:2003/auth-server/oauth/check_token?toke= post请求
Flink 注: 1. JobClient、JobManager、TaskManager 2. 算子(一个算子可拆成多个SubTask,也可合成一个Opeator Chain,如source->map,融合成一个task) 3. 算子有source、transformation、sink 4. 并行度:指的是程序的并行度,如设置为2,则表示有2个并行的source->transformation->sink 5. slot:指TM中要设置多少个slot,用于执行task任务 6. slot内存:一个TM中设置3个slot,为TM设置3G总内存,则一个slot内存就是1G大小 7. slot中task数:一个slot可以运行一个或多个task任务 8. task:由算子的并行度来制定多少个task 9. slotShardingGroup:不同的组占用的slot是互斥的(分布式的计算,所以不同组分开到不同的slot) 10. slot数计算:同一组,就是应用的最大并行度。不同组,就是所有组的个自组最大并行度之和。 ------ flink运行时跑的Job,job中跑的Operator(算子),算子需要设置Parallelism并行度,每个任务跑的是subtask(线程),多个subtask也可以合成Task组成Chain,多个subtask跑在一个slot中取决于SlotShardingGroup,这时由JobManager提交到TaskManager中,由TM中的Slots来启动运行Task任务
工作原理
一、JobClient --提交执行计划
obClient是Flink程序和JobManager交互的桥梁,主要负责接收程序、解析程序的执行计划、优化程序的执行计划,然后提交执行计划到JobManager。
二、JobClient程序解析
Flink会将程序中每一个算子解析成Operator,然后按照算子之间的关系,将operator组合起来,形成一个Operator组合成的Graph
三类Operator 1. Source Operator ,顾名思义这类操作一般是数据来源操作,比如文件、socket、kafka等,一般存在于程序的最开始 2. Transformation Operator 这类操作主要负责数据转换,map,flatMap,reduce等算子都属于Transformation Operator, 3. Sink Operator,意思是下沉操作,这类操作一般是数据落地,数据存储的过程,放在Job最后,比如数据落地到Hdfs、Mysql、Kafka等等。
三、JobClient执行计划优化 注:一个算子,可能会拆成多个SubTask,如:source->map
解析形成执行计划之后,JobClient还负责执行计划的优化,这里执行的主要优化是将相邻的Operator融合,形成OperatorChain。 因为Flink是分布式运行的,程序中每一个算子,在实际执行中被分隔为多个SubTask,数据流在算子之间的流动,就对应到SubTask之间的数据传递,SubTask之间进行数据传递模式有两种: 1. one-to-one,数据不需要重新分布,也就是数据不需要经过IO,节点本地就能完成,比如上图中的source到map; 2. re-distributed,数据需要通过shuffle过程重新分区,需要经过IO,比如上图中的map到keyBy。
Flink为了提高性能,将one-to-one关系的前后两类subtask,融合形成一个task。而TaskManager中一个task运行一个独立的线程中,同一个线程中的SubTask进行数据传递,不需要经过IO,不需要经过序列化,直接发送数据对象到下一个SubTask,
执行计划
优化结果 1. source->map融合成一个task 2. 一个task运行在一个独立线程中 3. 不同task之间需要IO
四、JobManager接着解析 注:由算子并发度,划分task,申请资源
JobManager是一个进程,主要负责申请资源,协调以及控制整个job的执行过程,具体包括,调度任务、处理checkpoint、容错等等。 1. 收到JobClient提交的执行计划后,继续解析,因为JobClient只是形成一个operaor层面的执行计划。 2. 继续解析执行计划(根据算子的并发度,划分task),形成一个可以被实际调度的由task组成的拓扑图 3. 向集群申请资源,一旦资源就绪,就调度task到TaskManager。
五、TaskManager 注:是一个JVM
TaskManager是一个进程,及一个JVM(Flink用java实现)。主要作用是接收并执行JobManager发送的task,并且与JobManager通信,反馈任务状态信息。 TaskManager就是worker主要用来执行任务。在TaskManager内可以运行多个task。
六、Slot
Slot是TaskManager资源粒度的划分,每个Slot都有自己独立的内存。所有Slot平均分配TaskManger的内存,比如TaskManager分配给Solt的内存为8G,两个Slot,每个Slot的内存为4G,四个Slot,每个Slot的内存为2G。 Slot仅划分内存,不涉及cpu的划分。同时Slot是Flink中的任务执行器(类似Storm中Executor),每个Slot可以运行多个task,而且一个task会以单独的线程来运行。
七、共享Slot
在flink中允许task共享Slot提升资源利用率,但是如果一个Slot中容纳过多task反而会造成资源低下(比如极端情况下所有task都分布在一个Slot内),在Flink中task 按照如下规则共享Slot: 同一个job中,同一个group中不同operator的task可以共享一个Slot Flink是按照拓扑顺序从Source依次调度到Sink的
提交实时任务
1. 本地模式
开发使用
2. standalone
提交是在flink集群的master节点上。 $flink_dir/bin/flink run -p 12 -c mogo.yycp.service.ServiceApplication -d $APP_JAR
-c,--class <classname> : 需要指定的main方法的类 -C,--classpath <url> : 向每个用户代码添加url,他是通过UrlClassLoader加载。url需要指定文件的schema如(file://) -d,--detached : 在后台运行 -p,--parallelism <parallelism> : job需要指定env的并行度,这个一般都需要设置。 -q,--sysoutLogging : 禁止logging输出作为标准输出。 -s,--fromSavepoint <savepointPath> : 基于savepoint保存下来的路径,进行恢复。 -sas,--shutdownOnAttachedExit : 如果是前台的方式提交,当客户端中断,集群执行的job任务也会shutdown。
JobManager高可用(HA)
jobManager协调每个flink任务部署。它负责任务调度和资源管理。
集群部署JM
使用JobManager HA,集群可以从JobManager故障中恢复,从而避免SPOF(单点故障) 。 用户可以在 standalone或 YARN集群 模式下,配置集群高可用。 思想:任何时候都有一个 Master JobManager ,并且多个Standby JobManagers 。 Standby JobManagers可以在Master JobManager 挂掉的情况下接管集群成为Master JobManager。 这样保证了没有单点故障,一旦某一个Standby JobManager 接管集群,程序就可以继续运行。
3. flink on yarn
第一种是yarn-session
3步
第一步:分配资源,生成application_id yarn-session -jm 1024m -nm flinkOnYarnTest -s 1 -tm 1024m -d 第二步:yarn-session依附application_id yarn-session -id/--applicationId <application_id> 第三步:flink 提交job flink run -c classname <jar> <arguments>
一种是flink run -m yarn-cluster
flink run -m yarn-cluster 实际上和上面提交standalone的方式一样。不过对于yarn的资源的命令参数有区别, # -m/--jobmanager : yarn-cluster集群 # -yd/--yarndetached : 后台 # -yjm/--yarnjobManager : jobmanager的内存 # -ytm/--yarntaskManager : taskmanager的内存 # -yid/--yarnapplicationId : job依附的applicationId # -ynm/--yarnname : application的名称 # -ys/--yarnslots : 分配的slots个数 flink run -m yarn-cluster -yd -yjm 1024m -ytm 1024m -ynm <name> -ys 1 <jar> <arguments>
怎么做压力测试和监控
描述
利用真实数据38万帧/分钟,5台任务处理机器,每台只能处理3.8万帧/分钟
1. 用真实数据
1. 使用真实数据,逐渐增加kafka的生产速率(比如消费1个分区,增加到2个),同时监控flink的消费和处理速度
2. 监控和度量
用flink的web ui来监控任务
kafka自带监控工具和第三方工具prometheus
3. 资源分配和调优
flink并行度和task slot数量
并行度设置方式
描述
将操作划分成多少个并行的子任务来执行,如果你的并行度设置为3,那么每个算子都会有3个并行的实例来处理数据。
算子层次(代码)
针对单独的某个Operator设置并行度,一个算子、数据源和sink的并行度可以通过setParallelism()方法来单独指定
DataStream<String> sum=dataStreamSource.rescale().map(new MapFunction<String,String>(){ @Override public String map(String value) throws Exception{ return value; } }).setParallelism(4).timeWindowAll(Time.seconds(10)).sum(0);//每10秒的数据计算一次
执行环境层次(代码)
执行层次的默认并行度可以通过调用env.setParallesm()方法来指定,这样设置并行度是针对Job的,假设我们想以并行度等于3来执行所有算子,数据源和sink,可以通过下面方式设置
//获取Flink的运行环境 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1);//并行度等于3
客户端层次(flink run)
在客户端提交Flink job的时候指定任务的并行度,对CLI客户端,我们可以通过-p来指定并行度
flink run -p 10 WordCount.jar
系统层次(flink-conf.yaml)
在系统级可以通过修改flink-conf.yaml文件中的parallelism.default参数来设置全局的默认并行度,即所有执行环境的默认并行度
parallelism.default:2
Slot
Flink集群是由JobManager(JM)、TaskManager(TM)两大组件组成的,每个JM/TM都是运行在一个独立的JVM进程中。JM相当于Master,是集群的管理节点,TM相当于Worker,是集群的工作节点,每个TM最少持有1个Slot,Slot是Flink执行Job时的最小资源分配单位,在Slot中运行着具体的Task任务。
Slot数
官方建议taskmanager.numberOfTaskSlots配置的Slot数量和CPU相等或成比例。
Slot内存
taskmanager.heap.size用来配置TM的Memory,如果一个TM有N个Slot,则每个Slot分配到的Memory大小为整个TM Memory的1/N,同一个TM内的Slots只有Memory隔离,CPU是共享的。
注
对Job而言:一个Job所需的Slot数量大于等于Operator配置的最大Parallelism数,在保持所有Operator的slotSharingGroup一致的前提下Job所需的Slot数量与Job中Operator配置的最大Parallelism相等。
Flink Job运行图
Flink Job运行图,图中有两个TM,各自有3个Slot,2个Slot内有Task在执行,1个Slot空闲。若这两个TM在不同Container或容器上,则其占用的资源是互相隔离的。在TM内多个Slot间是各自拥有 1/3 TM的Memory,共享TM的CPU、网络(Tcp:ZK、 Akka、Netty服务等)、心跳信息、Flink结构化的数据集等。
Task Slot的内部结构图
Slot内运行着具体的Task,它是在线程中执行的Runable对象(每个虚线框代表一个线程),这些Task实例在源码中对应的类是org.apache.flink.runtime.taskmanager.Task。每个Task都是由一组Operators Chaining在一起的工作集合,Flink Job的执行过程可看作一张DAG图,Task是DAG图上的顶点(Vertex),顶点之间通过数据传递方式相互链接构成整个Job的Execution Graph。
Operator Chain
Operator Chain是指将Job中的Operators按照一定策略(例如:single output operator可以chain在一起)链接起来并放置在一个Task线程中执行
Flink的Operator Chain将多个Operator链接到一起执行,减少了数据传递/线程切换等环节,降低系统开销的同时增加了资源利用率和Job性能
一个Job Runtime期的实际状态,Job最大的并行度为2,有5个SubTask(即5个执行线程)。若没有Operator Chain,则Source()和Map()分属不同的Thread,Task线程数会增加到7,线程切换和数据传递开销等较之前有所增加,处理延迟和性能会较之前差。补充:在slotSharingGroup用默认或相同组名时,当前Job运行需2个Slot(与Job最大Parallelism相等)。
Slot Sharding
每个slot能运行一个或多个task,为了拓扑更高效地运行,Flink提出了Chaining,尽可能地将operators chain在一起作为一个task来处理,为了资源的更充分利用,flink又提出了SlotShardingGroup,尽可能地让多个task共享一个slot
包含source-map[6 parallelism]、keyBy/window/apply[6 parallelism]、sink[1 parallelism]三种Task,总计占用了6个Slot;由左向右开始第一个slot内部运行着3个SubTask[3 Thread],持有Job的一条完整pipeline;剩下5个Slot内分别运行着2个SubTask[2 Thread],数据最终通过网络传递给Sink完成数据处理。
4. 瓶颈分析
查看flink处理速度,网络传输速度 根据瓶颈点进行优化,比如增加kafka分区数
5. 容量规划
基于性能测试,确定每台机器能够处理的最大数据量
1. 产生的数据流速度过快,下游的算子消费不过来,会产生背压问题
监控可以使用Flink Web UI来可视化监控
背压问题的产生可能是由于sink这个操作没有优化好,做一下优化,比如批量存储等
设置水位线的最大延迟参数
合理调整延迟时间
滑动窗口长度
过长或滑动距离很短,flink的性能会下降的很厉害
尽量使用滚动窗口
业务逻辑编写是重要的
算子中业务逻辑要尽可能简单
评估部署多少台主机
1. 执行压测
通过压测模拟真实的业务场景,观察Flink的性能表现,包括:容错、吞吐量、延迟等指标。
2. 按数据量规划
根据数据规模来规划,以数据量100GB ~ 200GB为一个节点规模。
3.按计算量规划
根据计算复杂度和计算任务类型,规划任务执行的并行度和slot数,并根据每个slot的配置规划节点规模。
4. 按用户并发度规划
根据服务的并发请求量,规划节点规模,一般情况下,每个节点可以承载一定数量的并发请求
为什么使用Flink替代Spark
1. Flink是真正的流处理,延迟在毫秒级,Spark延迟在秒级
2. Flink可以处理事件时间,而Spark Streaming只能处理机器时间
3. Flink的检查点算法比Spark Streaming更加灵活,性能更高。Spark Streaming的检查点算法是在每个stage结束以后,才会保存检查点。
Flink的checkPoint存在哪里?
状态后端。内存,文件系统,或者RocksDB。
说一下Flink的状态机制。
Flink通过状态后端来管理状态和checkpoint的存储,状态后端可以有不同的配置选择。
分布式
分布式基础理论
分布式基础
CAP
分布式系统技术就是用来解决集中式架构的性能瓶颈问题,来适应快速发展的业务规模,一般来说,分布式系统是建立在网络之上的硬件或者软件系统,彼此之间通过消息等方式进行通信和协调。 分布式系统的核心是可扩展性,通过对服务、存储的扩展,来提高系统的处理能力,通过对多台服务器协同工作,来完成单台服务器无法处理的任务,尤其是高并发或者大数据量的任务。
CAP
一致性
Consistency [kənˈsɪstənsi]
强调集群节点中数据一致。在分布式中一致性又包括强一致性和弱一致性,强一致性就是指在任何时刻任何节点看到的数据都是一样的; 弱一致性一般实现是最终一致性,即刚开始可能存在差异,但随着时间的推移,最终数据保持一致。
如何实现一致性
1.写入主数据库后要将数据同步到从数据库。 2.写入主数据库后,在向从数据库同步期间要将从数据库锁定,待同步完成后再释放锁,以免在新数据写入成功 后,向从数据库查询到旧的数据。
分布式系统一致性的特点
1.由于存在数据同步的过程,写操作的响应会有一定的延迟。 2.为了保证数据一致性会对资源暂时锁定,待数据同步完成释放锁定资源。 3.如果请求数据同步失败的结点则会返回错误信息,一定不会返回旧数据。
可用性
Availability [əˌveɪləˈbɪləti]
任何时候,读写都是成功的,即服务一直可用 强调集群在任何时间内都正常使用 -分析发现在满足P的前提下C和A存在矛盾性
如何实现可用性?
1.写入主数据库后要将数据同步到从数据库。 2.由于要保证从数据库的可用性,不可将从数据库中的资源进行锁定。 3.即时数据还没有同步过来,从数据库也要返回要查询的数据,哪怕是旧数据,如果连旧数据也没有则可以按照约定返回一个默认信息,但不能返回错误或响应超时。
分布式系统可用性的特点
所有请求都有响应,且不会出现响应超时或响应错误。
分区容忍性
Partition Tolerance [ˈtɑːlərəns]
当部分节点出现消息丢失或者分区故障的时候,分布式系统仍然能够继续运行。 指的是多节点/多服务器系统中,某一个节点出问题后不影响其他节点,其他节点仍能够对外提供一致性或者可用性的服务。
如何实现分区容忍性
1.尽量使用异步取代同步操作,例如使用异步方式将数据从主数据库同步到从数据,这样结点之间能有效的实现松耦合。 2.添加从数据库结点,其中一个从结点挂掉其它从结点提供服务。
分布式分区容忍性的特点
分区容忍性是分布式系统具备的基本能力。
总结
一般来说分布式集群都会保证P(分区容错性)优先,即集群部分节点坏死不影响整个集群的使用,然后再去追求C(一致性)和A(可用性)。因为如果放弃P——分区可用性,那不如就直接使用多个传统数据库了。事实上,很多微服务分库分表就是这个道理。 如果追求强一致性,那么势必会导致可用性下降。比如在Master-Slave的场景中,Master负责数据写入,然后分发给各个节点,所有节点都写入成功,才算写入,这样保证了强一致性,但是延迟也会随之增加,导致可用性降低。 因此在可用性和一致性之间,就出现了各种解决方案,如时序一致性、最终一致性等等。
应用
建议: 架构设计中,不要把精力浪费在如何设计能满足三者的完美分布式系统上,而要合理进行取舍。
组合方式
1)AP:放弃一致性,追求分区容忍性和可用性。这是很多分布式系统设计时的选择。
例如:商品管理,完全可以实现AP,前提是只要用户可以接受所查询的到数据在一定时间内不是最新的即可。 通常实现AP都会保证最终一致性,后面讲的BASE理论就是根据AP来扩展的,一些业务场景 比如:订单退款,今日退款成功,明日账户到账,只要用户可以接受在一定时间内到账即可。
2)CP:放弃可用性,追求一致性和分区容错性,我们的zookeeper其实就是追求的强一致,又比如跨行转账,一次转账请求要等待双方银行系统都完成整个事务才算完成。
zookeeper
C-最终一致性、P-分区容忍性
3)CA:放弃分区容忍性,即不进行分区,不考虑由于网络不通或结点挂掉的问题,则可以实现一致性和可用性。那么系统将不是一个标准的分布式系统,我们最常用的关系型数据就满足了CA。
BASE
BASE理论是对CAP理论的延伸,思想是既使无法做到强一致性但可以采用适当的弱一致性,即最终一致性
基本可用
分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。如,电商网站交易付款出现问题了,商品依然可以正常浏览。
软状态
由于不要求强一致性,所以BASE允许系统中存在中间状态(也叫软状态),这个状态不影响系统可用性,如订单的"支付中"、“数据同步中”等状态,待数据最终一致后状态改为“成功”状态。
最终一致性
最终一致是指经过一段时间后,所有节点数据都将会达到一致。如订单的"支付中"状态,最终会变为“支付成功”或者"支付失败",使订单状态与实际交易结果达成一致,但需要一定时间的延迟、等待。
分布式中间件
Zookeeper
描述: 解决分布式应用中经常遇到的一些数据管理问题 开放源码分布式协调服务,集群的管理者,监视着集群中各个节点的状态,根据节点提交的反馈进行下一步合理操作。
统一命名服务 状态同步服务 集群管理 分布式应用配置项的管理等
设计目的: 最终一致性、可靠性、实时性、原子性、顺序性
顺序性
包括全局有序和偏序两种: 全局有序是指如果在一台服务器上消息a在消息b前发布,则在所有Server上消息a都将在消息b前被发布; 偏序是指如果一个消息b在消息a后被同一个发送者发布,a必将排在b前面。
场景
描述: 基于Zookeeper实现数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master选举、分布式锁和分布式队列等
配置管理
这个好理解,分布式系统都有好多机器,比如我在搭建hadoop的HDFS的时候,需要在一个主机器上(Master节点)配置好HDFS需要的各种配置文件,然后通过scp命令把这些配置文件拷贝到其他节点上,这样各个机器拿到的配置信息是一致的,才能成功运行起来HDFS服务。 Zookeeper提供了这样的一种服务:一种集中管理配置的方法,我们在这个集中的地方修改了配置,所有对这个配置感兴趣的都可以获得变更。这样就省去手动拷贝配置了,还保证了可靠和一致性。
集群管理
在分布式的集群中,经常会由于各种原因,比如硬件故障,软件故障,网络问题,有些节点会进进出出。有新的节点加入进来,也有老的节点退出集群。这个时候,集群中有些机器(比如Master节点)需要感知到这种变化,然后根据这种变化做出对应的决策。我已经知道HDFS中namenode是通过datanode的心跳机制来实现上述感知的,那么我们可以先假设Zookeeper其实也是实现了类似心跳机制的功能吧!
分布式锁
碰到分布二字貌似就难理解了,其实很简单。单机程序的各个进程需要对互斥资源进行访问时需要加锁,那分布式程序分布在各个主机上的进程对互斥资源进行访问时也需要加锁。很多分布式系统有多个可服务的窗口,但是在某个时刻只让一个服务去干活,当这台服务出问题的时候锁释放,立即fail over到另外的服务。这在很多分布式系统中都是这么做,这种设计有一个更好听的名字叫Leader Election(leader选举)。举个通俗点的例子,比如银行取钱,有多个窗口,但是呢对你来说,只能有一个窗口对你服务,如果正在对你服务的窗口的柜员突然有急事走了,那咋办?找大堂经理(zookeeper)!大堂经理指定另外的一个窗口继续为你服务!
提供了什么
文件系统、通知机制
文件系统只有文件节点可以存放数据,而目录节点不行
在内存中维护了树状的目录节构,这种特性使得Zookeeper不能用于存放大量的数据,每个节点的存放数据上限为1M。
Server工作状态
4种状态
looking、following、leading、observing
· LOOKING:当前Server不知道leader是谁,正在搜寻 · LEADING:当前Server即为选举出来的leader · FOLLOWING:leader已经选举出来,当前Server与之同步
一致性
分布式一致性特性
顺序一致性、 原子性、 单一视图、 可靠性、 实时性(最终一致性)
顺序一致性
zookeeper采用了递增的事务id号(zxid)来标识事务。所有的提议(proposal[prəˈpoʊzl])都在被提出的时候加上了zxid。实现中zxid是一个64位的数字,它高32位是epoch用来标识leader关系是否改变,每次一个leader被选出来,它都会有一个新的epoch,标识当前属于那个leader的统治时期。低32位用于递增计数。
ZAB协议
话说在分布式系统中一般都要使用主从系统架构模型,指的是一台leader服务器负责外部客户端的写请求。然后其他的都是follower服务器负责读。leader服务器将客户端的写操作数据同步到所有的follower节点中。
问题
(1)leader服务器是如何把数据更新到所有的Follower的。 (2)Leader服务器突然间失效了,怎么办?
解决
(1)消息广播模式:把数据更新到所有的Follower (2)崩溃恢复模式:Leader发生崩溃时,如何恢复
描述: 一种支持崩溃恢复的原子广播协议 zk实现了一种主从模式的系统架构来保持集群中各个副本之间的数据一致性
2种模式
广播模式
(1)Leader将客户端的request转化成一个Proposal(提议) (2)Leader为每一个Follower准备了一个FIFO队列,并把Proposal发送到队列上。 (3)leader若收到follower的半数以上ACK反馈 (4)Leader向所有的follower发送commit。
我是领导,我要向各位传达指令,不过传达之前我先问一下大家支不支持我,若有一半以上的人支持我,那我就向各位传达指令了。
(1)leader首先把proposal发送到FIFO队列里 (2)FIFO取出队头proposal给Follower (3)Follower反馈一个ACK给队列 (4)队列把ACK交给leader (5)leader收到半数以上ACK,就会发送commit指令给FIFO队列 (6)FIFO队列把commit给Follower。
写请求
1. Leader生成日志全局唯一事务zxid,发送日志到从节点 2. 从节点返回确认信息(第一阶段) 3. 主节点发现超过一半服务同意,则主节点commit,并通知从节点。(第二阶段) 注:主节点一直在轮询等待从节点返回数据
二阶段提交
恢复模式
描述: 当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。 leader就是一个领导,既然领导挂了,整个组织肯定不会散架,毕竟离开谁都能活下去是不是,这时候我们只需要选举一个新的领导即可,而且还要把前leader还未完成的工作做完,也就是说不仅要进行leader服务器选取,而且还要进行崩溃恢复。我们一个一个来解决。
Leader服务器选取
平时server有三种状态
· LOOKING:当前Server不知道leader是谁,正在搜寻 · LEADING:当前Server即为选举出来的leader · FOLLOWING:leader已经选举出来,当前Server与之同步
选举过程见-原理与架构中的选主流程
整个选举的过程。并且每服务的选举,都代表了一个事件,为了保证分布式系统的时间有序性,因此给每一个事件都分配了一个Zxid。相当于编了一个号。低32位是按照数字递增,即每次客户端发起一个proposal,低32位的数字简单加1。高32位是leader周期的epoch[ˈepək]编号。 每当选举出一个新的leader时,新的leader就从本地事物日志中取出ZXID,然后解析出高32位的epoch编号,进行加1,再将低32位的全部设置为0。这样就保证了每次新选举的leader后,保证了ZXID的唯一性而且是保证递增的。 注:ZXID是在Leader上进行计算
崩溃恢复
第一:确保已经被leader提交的proposal必须最终被所有的follower服务器提交。 第二:确保丢弃已经被leader出的但是没有被提交的proposal。
第一步:选取当前取出最大的ZXID,代表当前的事件是最新的。 第二步:新leader把这个事件proposal提交给其他的follower节点 第三步:follower节点会根据leader的消息进行回退或者是数据同步操作。最终目的要保证集群中所有节点的数据副本保持一致。 这就是整个恢复的过程,其实就是相当于有个日志一样的东西,记录每一次操作,然后把出事前的最新操作恢复,然后进行同步即可。
数据一致性和paxos算法
zab协议: leader--选举过程 2pc 过半机制--验证是不是超过一半 同步过程
占用资源--预提交 ack--等待 执行--提交
解决的就是保证每个节点执行相同的操作序列。好吧,这还不简单,master维护一个全局写队列,所有写操作都必须放入这个队列编号,那么无论我们写多少个节点,只要写操作是按编号来的,就能保证一致性。没错,就是这样,可是如果master挂了呢。 • Paxos算法通过投票来对写操作进行全局编号,同一时刻,只有一个写操作被批准,同时并发的写操作要去争取选票, 只有获得过半数选票的写操作才会被 批准(所以永远只会有一个写操作得到批准),其他的写操作竞争失败只好再发起一 轮投票,就这样,在日复一日年复一年的投票中,所有写操作都被严格编号排 序。编号严格递增,当一个节点接受了一个 编号为100的写操作,之后又接受到编号为99的写操作(因为网络延迟等很多不可预见原因),它马上能意识到自己 数据 不一致了,自动停止对外服务并重启同步过程。任何一个节点挂掉都不会影响整个集群的数据一致性(总2n+1台,除非挂掉大于n台)。
四种类型的数据节点Znode
persistent-持久节点
手动删除,否则节点一直存在。
ephemeral-临时节点
临时节点的生命周期与客户端会话绑定,一旦会话失效、则客户端创建的所有临时节点都会被移除。
persistent_sequential-持久顺序节点
在持久节点的上加了顺序属性,节点名后边会追加一个由父节点维护的自增整型数字
ephemeral_sequential-临时顺序节点
在临时节点的上加了顺序属性,节点名后边会追加一个由父节点维护的自增整型数字
Watcher机制--数据变更通知
客户端向服务端的某个Znode注册一个Watcher监听,当服务端的一些指定事件触发了这个Watcher,服务端会向指定客户端发送一个事件通知来实现分布式的通知功能,然后客户端根据Watcher通知状态和事件类型做出业务上的改变。
工作机制: 客户端注册watcher 服务端处理watcher 客户端回调watcher
Watcher特性总结
一次性
一旦一个Watcher被触发,Zookeeper都会将其相应的存储中移除。减轻了服务器压力。
客户端串行执行
客户端Watcher回调的过程是一个串行同步的过程
轻量
1. Watcher通知简单,只会通知客户端发生了事件,而不会说明事件的具体内容。 2. 客户端向服务端注册Watcher的时候,并不会把客户端真实的Watcher对象实体传递到服务端,仅仅是在客户端请求中使用boolean类型属性进行了标记。
最终一致性
watcher event异步发送watcher的通知事件从server发送到client是异步的。存在一个问题,不同客户端与服务器通过socket进行通信,由于网络延迟或其他原因导致客户端在不同时刻监听到事件,由于Zookeeper本身提供了ordering gurarantee([ˌɡærənˈtiː]保证),即客户端监听事件后,才会感知它所监视znode发生了变化。所以使用zookeeper不能期望能够监控到节点每次的变化。只能保证最终一致性。
读写机制
» Zookeeper是一个由多个server组成的集群 » 一个leader,多个follower » 每个server保存一份数据副本 » 全局数据一致 » 分布式读写 » 更新请求转发,由leader实施
数据同步
整个集群完成Leader选举之后,Learner会向Leader服务器进行注册。注册完成后,进入数据同步环节。
数据同步流程
数据同步分4类
1. 直接差异化同步 2. 先回滚再差异化同步 3. 仅回滚同步 4. 全量同步
原理和架构
系统模型
Zookeeper的角色
Leader-领导者
负责进行投票的发起和决议,更新系统状态。
事物请求的唯一调度和处理者,保证集群事务处理的顺序性。
Learner-学习者
包括跟随者(follower)和观察者(observer),follower用于接受客户端请求并向客户端返回结果,在选主过程中参与投票。
处理客户端的非事务请求,转发事务请求给Leader服务器
Observer
可以接受客户端连接,将写请求转发给leader,但observer不参加投票过程,只同步leader的状态,observer的目的是为了扩展系统,提高读取速度
客户端
请求发起方
工作原理
Zookeeper的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。Zab协议有两种模式,它们分别是恢复模式(选主)和广播模式(同步)。 当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和 leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。 为了保证事务的顺序一致性,zookeeper采用了递增的事务id号(zxid)来标识事务。所有的提议(proposal)都在被提出的时候加 上了zxid。实现中zxid是一个64位的数字,它高32位是epoch用来标识leader关系是否改变,每次一个leader被选出来,它都会有一 个新的epoch,标识当前属于那个leader的统治时期。低32位用于递增计数。
选主流程
三台节点
Leader选举流程
0x01 投票轮次递增 用于标识当前投票的轮次,ZooKeeper规定了同一轮次的投票才是有效的。所以如果要进行新一轮的投票,需要对投票轮次进行递增。 0x02 初始化选票 在投票之前每台服务器都会初始化自己的选票,选票里面最重要的两个值一个是本机的myid的值,一个是本机的ZXID的值。 0x03 发送初始化选票 完成选票的初始化以后,服务器就会发起第一次投票,第一次投票都是推荐自己为Leader,也就是都投自己。 0x04 判断服务状态 ZooKeeper的服务有四个状态:LOOKING, FOLLOWING, LEADING, OBSERVING LOOKING:寻找Leader的状态。 FOLLOWING:跟随者状态。 LEADING:领导者状态。 OBSERVING:观察者状态。 如果当前服务的状态是LOOKING,那么说明还没选举出来Leader,继续下面的步骤,正常情况下一开始都是LOOKING的状态。 0x05 接受外部选票 每台服务器会不断的从某个队列里面获取外部的选票。如果发现服务器无法获取外部的任何选票,那么就会立即确认自己是否和集群中的其他服务器保持着有效连接。如果发现没有建立连接,那么立马建立连接。如果已经建立连接了,那么再次发送自己的投票。
0x06 判断选举轮次 发送完初始化选票以后,接下来就要处理外部的选票了,处理选票的时候会根据不同轮次的选票进行不同的处理。 6.1 外部投票的选举轮次大于自己的轮次 如果发现自己的投票轮次小于外部的轮次,那么立即更新自己的轮次,然后清空已经接收到的选票。然后使用初始化的选票来PK刚刚的外部投票以确定是否变更自己的投票(PK的规则跟概述里描述一致)。 6.2 外部投票的选举轮次小于自己的轮次 如果接收到的选票的选举轮次落后与服务器本身的轮次,那么直接忽略该外部选票,不做任何处理。 6.3 外部投票的选举轮次等于自己的轮次 大多数属于这个情况,如果外部的选票的轮次跟自己的选票轮次一致的话,就进行选票PK。 0x07 选票PK (a)优先对比ZXID,ZXID大的优先作为Leader。 (b)如果ZXID一样的话,那么就比较选票里的myid,myid大的选为Leader。
0x08 变更选票 通过选票PK以后,如果确定了别的服务的选票优于自己的选票,那么就进行选票变更,把自己的选票信息变更为更适合作为Leader的服务器的选票信息。然后再把这些选票发送出去。不过无论是否发生了选票变更,当前的服务都会将刚刚接收到的选票进行归档并按照服务器的myid来区分,如:{(0,vote1),(1,vote2),....} 0x09 统计投票 完成了选票归档之后,就可以统计选票了,说白了就是看是否有过半的服务器认为某台服务器适合做Leader,如果确定了则终止投票。否者返回步骤(4)。 0x0A 更新服务状态 统计投票后,如果已经确定出来Leader就终止投票,接下来就变更服务的状态,首先看一下自己是不是Leader,如果自己是Leader那么就更新自己的状态为Leader,如果不是Leader,那么就根据配置文件,要么切换为Follower,要么切换为Observer。 以上10个步骤就是ZooKeeper选举的核心算法,其中4-9会经过几轮循环直到Leader产生。 最后 ZooKeeper是我们学习架构的过程中必不可少的一个技术,今天主要跟大家讲解了ZooKeeper的Leader选举算法,后面会陆续剖析ZooKeeper的ZAB协议算法,数据快照机制,数据清理机制,会话机制,长连接机制等。欢迎大家持续关注。
选举流程
(1)我们首先启动myid为0的服务,但是目前只有一台ZooKeeper服务,所以是无法完成Leader选举的,ZooKeeper集群要求Leader进行投票选举条件是至少有2台服务才行,不然都没法进行通信投票。
(2)启动myid为1的服务,第二台启动了以后,这两台ZooKeeper就可以相互通信了,接下来就可以进行投票选举了。
3)2台ZooKeeper进行投票选举的时候,第一次都是推荐自己为Leader,投票包含的信息是:服务器本身的myid和ZXID。比如第一台投自己的话,它会发送给第二台机器的投票是(0,0),第一个0代表的是机器的myid,第二个0代表是的ZXID。故两台机器收到的投票情况如下: 第一台:(1,0) //这是收到其他机器信息 第二台:(0,0) //这是收到其他机器信息
(4)两台服务器在接收到投票后,将别人的票和自己的投票进行PK。PK的是规则是: (a)优先对比ZXID,ZXID大的优先作为Leader(ZXID大的表示数据多) (b)如果ZXID一样的话,那么就比较myid,让myid大的作为Leader服务器。 那根据这个规则的话,第一台服务器,接受到的投票是(1,0),跟自己的投票(0,0)比,ZXID是一样的,但是myid比接收到的投票的小,所以第一台原先是推荐自己投票为(0,0),现在进行了PK以后,投票修改为(1,0)。第二台服务器,接受到的投票是(0,0),跟自己的投票(1,0)比,ZXID是一样的,但是myid是比接收到的投票的大,所以坚持自己的投票(1,0)。两台服务器再次进行投票。
(5)每次投票以后,服务器都会统计所有的投票,只要过半的机器投了相同的机器,那么Leader就选举成功了,上面的两台服务器进行第二次投票之后,两台服务器都会收到相同的投票(1,0)。那么此时myid为1的服务器就是Leader了。
投自己 交流--改票,PK(zxid, myid) 选票 投票箱
数据持久化原理
1. 日志记录 2. 存盘
日志经过分析留下最终的命令信息
节点数据操作流程
子主题
1.在Client向Follwer发出一个写的请求 2.Follwer把请求发送给Leader 3.Leader接收到以后开始发起投票并通知Follwer进行投票 4.Follwer把投票结果发送给Leader 5.Leader将结果汇总后如果需要写入,则开始写入同时把写入操作通知给Leader,然后commit; 6.Follwer把请求结果返回给Client
操作
Nacos
核心功能
1、服务发现与注册: Nacos支持服务的注册与发现,允许服务实例在Nacos中注册,并被客户端发现和调用。 2、配置管理: 提供动态的配置服务,支持配置的集中存储、版本控制和动态更新。 3、服务健康检查: Nacos实现服务健康检查,确保只有健康的实例被发现和调用。 4、动态路由和负载均衡: 支持动态路由策略和负载均衡,在提供服务时能根据实际情况进行智能调整。 5、集群管理和分组: 支持服务的集群管理和分组功能,方便进行大规模服务管理和隔离。
服务注册中心高可用
1、集群部署: 通过集群部署,确保Nacos服务器的高可用性。 2、数据一致性: 使用数据一致性协议(如Raft)来确保集群中数据的一致性。 3、故障检测与自动转移: 实现故障检测机制,并在发现节点不可用时自动进行故障转移。 4、客户端负载均衡: Nacos客户端实现本地缓存和负载均衡,即使部分节点不可用,也能保证服务的正常发现和调用。
配置管理功能
1、集中式配置存储: 提供一个中心化的配置存储服务,方便统一管理。 2、版本控制与管理: 支持配置的版本控制,可以追踪配置的变更历史。 3、动态配置更新: 支持配置的动态更新,不需要重启服务即可实时生效。 4、多环境配置: 支持多环境配置管理,例如开发环境、测试环境和生产环境的配置隔离。 5、配置变更监听: 客户端可以监听配置变更,一旦配置发生变化,可以立即响应。
在处理大规模服务实例时的策略
1、分片存储: 对服务实例进行分片存储,减少单个节点的压力。 2、增量推送: 在服务实例变更时采用增量推送机制,而不是全量推送,减少网络传输和处理开销。 3、服务分组和隔离: 通过服务分组和隔离,有效管理和控制大量的服务实例。 4、负载均衡与容错: 实现负载均衡和容错机制,保证服务的稳定性和可靠性。
健康检查机制
1、心跳机制: 服务实例定期向Nacos发送心跳,以证明自己是健康的。 2、健康检查策略: Nacos支持多种健康检查策略,如HTTP、TCP或自定义脚本。 3、故障自动摘除: 如果服务实例未按预期发送心跳或健康检查失败,Nacos会自动将其摘除出服务列表。 4、故障恢复: 当服务实例恢复正常后,可以自动重新注册到服务列表中。
支持哪些数据一致性协议?
1、Raft协议: Nacos使用Raft协议来确保集群中数据的一致性。 2、Distro协议: 对于服务发现功能,Nacos实现了基于分布式的Distro协议,用于在服务节点间高效同步数据。
简述Nacos的命名空间Namespace ?
进行多环境配置和服务的管理及隔离
例如,你可能存在本地开发环境dev、测试环境test、生产环境prod 三个不同的环境,那么可以创建三个不同的 Namespace 区分不同的环境。
成功创建新命名空间后,就可以在 springboot 的配置文件配置命名空间的 id 切换到对应的命名空间,并获取对应空间下的配置文件,但在没有指定命名空间配置的情况下,默认的配置都是在 public 空间中,指定命名空间的方式如下: # 对应创建的命名空间的ID,此处对应的是dev命名空间 cloud.nacos.config.namespace=483bb765-a42d-4112-90bc-50b8dff768b3
简述 Nacos和Eureka区别 ?
CAP理论的区别
CAP理论:C一致性,A高可用,P分区容错性。
eureka
eureka只支持AP(高可用和分区容错性)
nacos
nacos支持CP和AP两种
nacos是根据配置识别CP或AP模式,如果注册Nacos的client节点注册时是ephemeral=true即为临时节点,那么Naocs集群对这个client节点效果就是AP,反之则是CP,即不是临时节点 #false为永久实例,true表示临时实例开启,注册为临时实例 spring.cloud.nacos.discovery.ephemeral=true
连接方式不同
nacos使用的是netty和服务直接进行连接,属于长连接。 eureka是使用定时发送和服务进行联系,属于短连接。
服务异常剔除区别
eureka --30s心跳,90s下线
Eureka client在默认情况每隔30s想Eureka Server发送一次心跳,当Eureka Server在默认连续90s秒的情况下没有收到心跳, 会把Eureka client 从注册表中剔除,在由Eureka-Server 60秒的清除间隔,把Eureka client 给下线
EurekaInstanceConfigBean类下 private int leaseRenewalIntervalInSeconds = 30; //心跳间隔30s private int leaseExpirationDurationInSeconds = 90; //默认90s没有收到心跳从注册表中剔除 EurekaServerConfigBean 类下 private long evictionIntervalTimerInMs = 60000L;
nacos --5s心跳,30s下线
nacos client 通过心跳上报方式告诉 nacos注册中心健康状态,默认心跳间隔5秒, nacos会在超过15秒未收到心跳后将实例设置为不健康状态,可以正常接收到请求 超过30秒nacos将实例删除,不会再接收请求
操作实例方式不同
nacos
提供了nacos console可视化控制话界面,可以对实例列表进行监听,对实例进行上下线,权重的配置,并且config server提供了对服务实例提供配置中心,且可以对配置进行CRUD,版本管理
eureka
仅提供了实例列表,实例的状态,错误信息,相比于nacos过于简单
自我保护机制不同 --超过阈值保护
nacos
当域名健康实例 (Instance) 占总服务实例(Instance) 的比例小于阈值时,无论实例 (Instance) 是否健康,都会将这个实例 (Instance) 返回给客户端。这样做虽然损失了一部分流量,但是保证了集群的剩余健康实例 (Instance) 能正常工作。 例:一个服务有10个实例,2个可用,则将全部实例返给消费端,让其负载均衡调用,保证20%的可用性,这样避免2个服务承载过多的压力问题。
假如现在有一个服务,本来有10个实例,但是现在挂掉了8个,剩下2个正常实例,此时本来由10个实例处理的流量,就全部交给这个两个正常实例来处理了,此时这两个实例很有可能是处理不过来的,最终导致被压垮,为了应对这种情况,Nacos提供了保护阈值这个功能,我们可以给某个服务设置一个0-1的阈值,比如0.5,那就表示,一旦实例中只剩下一半的健康实例了,比如10个实例,只剩下5个健康实例了,那么消费者在进行服务发现时,则会把该服务的所有实例,也包括不健康的实例都拉取到本地,然后再从所有实例中进行负载均衡,选出一个实例进行调用,在这种情况下,选出来的即可能是一个健康的实例,也可能是挂掉的实例,但是通过这种方式,很好的保护的剩下的健康实例,至少保证了一部分请求能正常的访问,而不至于所有请求都不能正常访问,这就是Nacos中的保护阈值,同时,这个功能在Spring Cloud Tencent中叫全死全活
eureka
当在短时间内,统计续约失败的比例,如果达到一定阈值,则会触发自我保护的机制,在该机制下,Eureka Server不会剔除任何的微服务,等到正常后,再退出自我保护机制。自我保护开关(eureka.server.enable-self-preservation: false)
分布式事务
描述
分布式系统会把一个应用系统拆分为可独立部署的多个服务,因此需要服务与服务之间远程协作才能完成事务操作,这种分布式系统环境下由不同的服务之间通过网络远程协作完成事务称之为分布式事务
例如用户注册送积分事务、创建订单减库存事务,银行转账事务等都是分布式事务。
从刚性事务到柔性事务
刚性事务:遵循ACID原则,强一致性。 柔性事务:遵循BASE理论,最终一致性;与刚性事务不同,柔性事务允许一定时间内,不同节点的数据不一致,但要求最终一致。
刚性事务的理论基础-ACID原则
原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)
原子性
事务内的操作要么全部完成,要么全部不完成,不可能结束在中间某个环节。
一致性
事务必须使数据库从一个一致性状态变换到另一个一致性状态
隔离性
并发的事务是相互隔离的,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰
持久性
一旦事务提交,则其结果就是永久的,即使系统崩溃也不会丢失
刚性事务的实现方案
由于一项操作通常会包含许多子操作,而这些子操作可能会因为硬件的损坏或其他因素产生问题,要正确实现ACID并不容易。ACID建议数据库将所有需要更新以及修改的资料一次操作完毕,但实际上并不可行。
WAL(Write ahead logging) 提前写日志
对数据文件的修改(它们是表和索引的载体)必须是只能发生在这些修改已经记录了日志之后。
影子分页(Shadow paging)
每个page只在日志文件中存一份,无论这个页被修改过多少次。日志文件中,只记录事务开始前page的原始信息,进行恢复时,只需要利用日志文件中的page进行覆盖即可
两阶段型
对应技术上的XA、JTA/JTS 参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
准备阶段
1.协调者节点向所有参与者节点询问是否可以执行提交操作,并开始等待各参与者节点的响应。 2.参与者节点执行询问发起为止的所有事务操作,并将undo信息和redo信息写入日志。(若成功,其实当前每个参与者已经执行了事务操作) 3.各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个“同意”消息;如果参与者节点的事务实际执行失败,则它返回一个“中止”消息。
提交阶段
如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚消息;否则,发送提交消息; 参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(必须在最后阶段释放锁资源)
提交
协调者节点向所有参与者节点发出提交请求。 参与者节点正式完成操作,并释放在整个事务期间内占用的资源。 参与者节点向协调者节点发送提交完成消息。 协调者节点受到所有参与者节点反馈的提交完成消息后,完成事务。
回滚
协调者节点向所有参与者节点发出回滚请求。 参与者节点利用之前写入的undo信息执行回滚,并释放在整个事务期间内占用的资源。 参与者节点向协调者发送回滚完成消息。 协调者节点收到所有参与者节点反馈的回滚完成消息后,取消事务。
缺陷
1. 同步阻塞问题:执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。 2. 单点故障:由于协调者的重要性,一旦协调者发生故障,参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题) 3. 数据不一致:在二阶段提交的阶段二中,当协调者向参与者发送提交请求之后,发生了局部网络异常或者在发送提交请求过程中协调者发生了故障,这会导致只有一部分参与者接受到了提交请求。而在这部分参与者接到提交请求之后就会执行提交操作。但是其他部分未接到提交请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。 4. 协调者在发出提交消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否已经提交。
分布式事务的理论基础-CAP理论
一个分布式系统不可能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三个基本需求,最多只能同时满足其中两项。
一致性(多副本同步一样的数据) 在分布式环境中,一致性是指数据在多个副本之间是否能够保持一致的特性。在一致性的要求下,当一个系统在数据一致的状态下执行更新操作后,应该保证系统的数据仍然处于一致的状态。
可用性(在有限时间返回结果) 可用性是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。
分区容错性(网络故障仍可以提供一致性和可用性服务) 分区容错性约束了一个分布式系统需要具有如下特性:分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障。
CAP应用
说明放弃一致性(C)
最终一致性(有时间限制) 放弃C指放弃数据的强一致性,而保留数据的最终一致性。这样的系统无法保证数据保持实时的一致性,但是能够承诺的是,数据最终会达到一个一致的状态。这就引入了一个时间窗口的概念,具体多久能够达到数据一致取决于系统的设计,主要包括数据副本在不同节点之间的复制时间长短。
放弃可用性(A)
不可用(网络问题,无法对外提供正常服务) 一旦系统遇到网络分区或其他故障时,那么受到影响的服务需要等待一定的时间,因此在等待期间系统无法对外提供正常的服务,即不可用。
放弃分区容错性(P)
不用多个节点提供数据(数据放在一个节点上) 如果希望能够避免系统出现分区容错性问题,一种较为简单的做法是将所有的数据(或者仅仅是与事务相关的数据)都放在一个分布式节点上。
柔性事务的理论基础-BASE理论
BASE是对CAP中一致性和可用性权衡的结果,其核心思想是即使无法做到强一致性,但每个应用都可以根据自身的业务特点,采用适当的方法来使系统达到最终一致性。
基本可用(Basically Available) --响应时间可能变慢、或是降级
基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性 响应时间上的损失:正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障,查询结果的响应时间增加到了1-2秒。 功能上的损失:在一个电子商务网站上进行购物,消费者几乎能够顺利完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
软状态(Soft state) --允许延时
不同节点副本数据可以延时 软状态,和硬状态相对,是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
最终一致性(Eventually consistent)
最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
柔性事务的实现
补偿型
TCC(Try/Confirm/Cancel)型事务。
WS-BusinessActivity提供了一种基于补偿的long-running的事务处理模型。还是上面的例子,服务器A的事务如果执行顺利,那么事务A就先行提交,如果事务B也执行顺利,则事务B也提交,整个事务就算完成。但是如果事务B执行失败,事务B本身回滚,这时事务A已经被提交,所以需要执行一个补偿操作,将已经提交的事务A执行的操作作反操作,恢复到未执行前事务A的状态。这样的SAGA事务模型,是牺牲了一定的隔离性和一致性的,但是提高了long-running事务的可用性。
总结
本地事务:一个完整的事务操作可以在同一物理介质(例如:内存)上同时完成; 分布式事务:一个完整事务需要跨物理介质或跨物理节点(网络通讯); 在分布式事务的定义里,无疑排它锁、共享锁等等就没有用武之地了,无法保证原子性完成事务。为了能够达到原子性的效果,二阶段提交提出了协调者角色,协调者拥有数据读取写入的唯一性。但同时带来了严重的同步阻塞问题,且如果协调者释放读取的能力,则无法保证原子性。 实际在分布式事务的发展过程中,刚性事务只在副本存储等局限场景中使用,柔性事务无疑是主要角色,甚至一般讲分布式事务,就是在讲柔性事务。 在柔性事务中,最重要的无疑是如何实现数据的最终一致性
场景
典型的场景就是微服务架构 微服务之间通过远程调用完成事务操作。 比如:订单微服务和库存微服务,下单的同时订单微服务请求库存微服务减库存。 简言之:跨JVM进程产生分布式事务。
微服务架构
单体系统访问多个数据库实例 当单体系统需要访问多个数据库(实例)时就会产生分布式事务。
多数据库实例
多服务访问同一个数据库实例 比如:订单微服务和库存微服务即使访问同一个数据库也会产生分布式事务,原因就是跨JVM进程,两个微服务持有了不同的数据库链接进行数据库操作,此时产生分布式事务。
多服务访问同一个数据库实例
解决方案
2PC-两阶段提交 -传统方案是在数据库层面实现的
描述
2PC即两阶段提交协议,是将整个事务流程分为两个阶段,准备阶段(Prepare phase)、提交阶段(commitphase),2是指两个阶段,P是指准备阶段,C是指提交阶段
两阶段提交
缺点
老大就是协调者
无法解决的问题
子主题
1. 准备阶段(Prepare phase)
协调者询问参与者是否可以提交事务,参与者锁定资源并准备提交。
事务管理器给每个参与者发送Prepare消息,每个数据库参与者在本地执行事务,并写本地的Undo/Redo日志,此时事务没有提交。(Undo日志是记录修改前的数据,用于数据库回滚,Redo日志是记录修改后的数据,用于提交事务后写入数据文件)
2. 提交阶段(commit phase)
协调者根据参与者的响应决定提交或回滚事务,并通知参与者。
如果事务管理器收到了参与者的执行失败或者超时消息时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据事务管理器的指令执行提交或者回滚操作,并释放事务处理过程中使用的锁资源。注意:必须在最后阶段释放锁资源。
成功
失败
子主题
缺点
阻塞时间长,单点故障问题,数据不一致风险
三阶段提交协议
有超时机制
在2PC的基础上增加了一个预提交阶段,以降低阻塞时间和单点故障问题。
XA方案
例
新用户注册送积分为例来说明
流程
1.应用程序(AP)持有用户库和积分库两个数据源。 2.应用程序(AP)通过TM通知用户库RM新增用户,同时通知积分库RM为该用户新增积分,RM此时并未提交事务,此时用户和积分资源锁定。 3.TM收到执行回复,只要有一方失败则分别向其他RM发起回滚事务,回滚完毕,资源锁释放。 4.TM收到执行回复,全部成功,此时向所有RM发起提交事务,提交完毕,资源锁释放。
DTP模型定义如下角色:
AP(Application Program):即应用程序,可以理解为使用DTP分布式事务的程序。 RM(Resource Manager):即资源管理器,可以理解为事务的参与者,一般情况下是指一个数据库实例,通过资源管理器对该数据库进行控制,资源管理器控制着分支事务。 TM(Transaction Manager):事务管理器,负责协调和管理事务,事务管理器控制着全局事务,管理事务生命周期,并协调各个RM。全局事务是指分布式事务处理环境中,需要操作多个数据库共同完成一个工作,这个工作即是一个全局事务。 DTP模型定义TM和RM之间通讯的接口规范叫XA,简单理解为数据库提供的2PC接口协议,基于数据库的XA协议来实现2PC又称为XA方案。
TCC(Try, Confirm, Cannel)
原理介绍
相对于传统事务机制,其特征在于它不依赖资源管理器对XA的支持,而是通过对(由业务系统提供的)业务逻辑的调度来实现分布式事务,主要由三步操作,Try:尝试执行业务、Confirm:确认执行业务、Cancel:取消执行业务。
子主题
模式特点
该模式对代码的嵌入性高,要求每个业务需要写三种步骤的操作。 该模式对有无本地事务控制都可以支持使用面广。 数据一致性控制几乎完全由开发者控制,对业务开发难度要求高。
两阶段提交(2PC)
try-请求服务方预留资源
Try 阶段是做业务检查(一致性)及资源预留(隔离),此阶段仅是一个初步操作,它和后续的Confirm 一起才能真正构成一个完整的业务逻辑。
confirm-通知服务方预留资源确认提交
Confirm 阶段是做确认提交,Try阶段所有分支事务执行成功后开始执行 Confirm。通常情况下,采用TCC则认为 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。
cancel-通知服务方预留资源回滚
Cancel 阶段是在业务执行错误需要回滚的状态下执行分支事务的业务取消,预留资源释放。通常情况下,采用TCC则认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。
2个
可靠消息最终一致性
第一个网上的例子
异步确保:服务A完成操作后,发送消息到消息队列,服务B监听队列并处理消息以完成其操作。 补偿事务:如果服务B处理失败,服务A可以检测到并触发一个补偿事务来回滚之前的状态。 重试机制:对于消息处理失败的情况,可以设置重试逻辑。 幂等性:确保服务B的操作是幂等的,以防止重复处理消息
下订单、扣库存的分布式事务处理思路 1. 订单服务: 用户下单后,订单服务生成订单并保存到数据库。 订单服务发送一条扣库存的消息到消息队列。 2. 库存服务: 监听消息队列,当收到扣库存的消息时,尝试减少库存。 如果扣库存成功,则更新库存状态并发送一条确认消息回订单服务(可选)。 如果扣库存失败(如库存不足),则记录日志,并可能触发一个通知给订单服务进行补偿。 3. 订单服务(补偿逻辑): 定时检查或接收库存服务的确认/失败消息。 如果收到库存扣减失败的消息或超时未收到确认消息,则触发补偿逻辑(如取消订单或标记为异常状态)。 4. 重试和幂等性: 在库存服务中,对于扣库存的操作,确保是幂等的,以防止重复处理消息。 设置合理的重试机制,以便在网络抖动或短暂的服务不可用情况下能够成功处理消息。 5. 监控和告警: 监控分布式事务的处理情况,包括成功、失败和超时等指标。 设置告警规则,以便在出现问题时能够及时发现并处理。
第二个网上的例子
基于消息中间件的两阶段提交往往用在高并发场景下,将一个分布式事务拆成一个消息事务(A系统的本地操作+发消息)+B系统的本地操作,其中B系统的操作由消息驱动,只要消息事务成功,那么A操作一定成功,消息也一定发出来了,这时候B会收到消息去执行本地操作,如果本地操作失败,消息会重投,直到B操作成功,这样就变相地实现了A与B的分布式事务。
虽然上面的方案能够完成A和B的操作,但是A和B并不是严格一致的,而是最终一致的,我们在这里牺牲了一致性,换来了性能的大幅度提升。当然,这种玩法也是有风险的,如果B一直执行不成功,那么一致性会被破坏,具体要不要玩,还是得看业务能够承担多少风险。 适用于高并发最终一致 低并发基本一致:二阶段提交 高并发强一致:没有解决方案
最大努力通知
其实就是加了一个定时任务,定期去查询主动方接口,看看有没有要处理的记录
最大努力通知也称为定期校对,是对MQ事务方案的进一步优化。它在事务主动方增加了消息校对的接口,如果事务被动方没有接收到主动方发送的消息,此时可以调用事务主动方提供的消息校对的接口主动获取 在可靠消息事务中,事务主动方需要将消息发送出去,并且让接收方成功接收消息,这种可靠性发送是由事务主动方保证的;但是最大努力通知,事务主动方仅仅是尽最大努力(重试,轮询....)将事务发送给事务接收方,所以存在事务被动方接收不到消息的情况,此时需要事务被动方主动调用事务主动方的消息校对接口查询业务消息并消费,这种通知的可靠性是由事务被动方保证的。 所以最大努力通知适用于业务通知类型,例如微信交易的结果,就是通过最大努力通知方式通知各个商户,既有回调通知,也有交易查询接口
消息中间件
本地事务+定时任务+消息队列+事件表
表设计
四段设计:支付业务事务、定时任务发送MQ、消费MQ、订单业务事务 注:幂等设计,重试机制,
seata
描述
http://seata.io/zh-cn/docs/user/quickstart.html
组成部分
1. 事务协调器(TC):维护全局事务和分支事务的状态,驱动全局提交或回滚。 2. 事务管理器(TM):定义全局事务的范围:开始全局事务,提交或回滚全局事务。 3. 资源管理器(RM):管理正在处理的分支事务的资源,与TC对话以注册分支事务并报告分支事务的状态,并驱动分支事务的提交或回滚。 TM-班主任,TC-上课老师,RM-班同学 TM班主任发起开班,通知TC上课老师,建XID,通知RM班同学向TC上课老师注册
生命周期
1.TM要求TC开始一项新的全局事务。TC生成代表全局事务的XID。 2.XID通过微服务的调用链传播。 3.RM将本地事务注册为XID到TC的相应全局事务的分支。 4.TM要求TC提交或回退相应的XID全局事务。 5.TC驱动XID的相应全局事务下的所有分支事务以完成分支提交或回滚。
原理
成功 1.先存入全局一条记录 2. 分支存一条记录
回滚 1. 2.
表
分支事务
全局事务
全局锁
回滚日志表
安装服务
seata-server服务协调者
项目
各方案常见使用场景总结
1. 2PC/3PC:依赖于数据库,能够很好的提供强一致性和强事务性,但延迟比较高,比较适合传统的单体应用,在同一个方法中存在跨库操作的情况,不适合高并发和高性能要求的场景。 2. TCC:适用于执行时间确定且较短,实时性要求高,对数据一致性要求高,比如互联网金融企业最核心的三个服务:交易、支付、账务。 3. 本地消息表/MQ 事务:适用于事务中参与方支持操作幂等,对一致性要求不高,业务上能容忍数据不一致到一个人工检查周期,事务涉及的参与方、参与环节较少,业务上有对账/校验系统兜底。 4. Saga 事务:由于 Saga 事务不能保证隔离性,需要在业务层控制并发,适合于业务场景事务并发操作同一资源较少的情况。Saga 由于缺少预提交动作,导致补偿动作的实现比较麻烦,例如业务是发送短信,补偿动作则得再发送一次短信说明撤销,用户体验比较差。所以,Saga 事务较适用于补偿动作容易处理的场景
商品库存扣减与数据一致性
表结构设计
商品库存对账表
CREATE TABLE `goods_stock_log` ( `id` bigint(20) NOT NULL, `before_num` int(32) DEFAULT NULL COMMENT '之前的库存', `after_num` int(32) DEFAULT NULL COMMENT '之后的库存', `num` int(32) DEFAULT NULL COMMENT '数量', //操作数量(商家增量,商家减量,下单减量,取消订单) `goods_id` bigint(20) DEFAULT NULL COMMENT '商品id', `type` tinyint(5) DEFAULT NULL COMMENT '1=下单(未支付,已支付) 2=商家增量 3=商家扣减 4=取消订单', `create_time` datetime DEFAULT NULL, `version` int(32) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品库存对账表';
商品库存一致表
CREATE TABLE `stock_operation_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `before_num` int(32) DEFAULT NULL COMMENT '之前的库存', `after_num` int(32) DEFAULT NULL COMMENT '之后的库存', `num` int(32) DEFAULT NULL COMMENT '数量', `goods_id` bigint(20) DEFAULT NULL COMMENT '商品id', `state` tinyint(5) DEFAULT NULL COMMENT '0 = 待处理 1 = 完成', `type` tinyint(5) DEFAULT NULL COMMENT '1=下单(未支付,已支付) 2=商家增量 3=商家扣减 4=取消订单', `create_time` datetime DEFAULT NULL, `version` int(32) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='库存最终一致性表';
最终一致性记录表 --商品扣减表记录表
商品ID、 扣减数量、 状态(待扣减、已扣减) 下单时间、 创建时间 更新时间
创建订单流程
思考问题 1. 扣减redis成功后,服务宕机 2. redis命令执行成功,响应超时,那么redis库存比db库存少。 此时,会存在数据暂时不一致的情况 注: 1. 使用redis的incrby特性来扣减库存(采用lua脚本实现)。 2. 扣减库存采用微服务专门做库存管理(建议) 参考: https://blog.csdn.net/GV7lZB0y87u7C/article/details/126151296?utm_medium=distribute.pc_relevant.none-task-blog-2~default~baidujs_baidulandingword~default-0-126151296-blog-128036748.235^v43^control&spm=1001.2101.3001.4242.1&utm_relevant_index=1
redis重启要恢复库存
API接口
最终一致性-定时器 --解决扣减真实库存
1. 查询最终一致性记录 2. 减少商品库存数量 3. 更新最终一致性表状态 注:以上要在同一事物中处理
对账-定时器 --保障redis和DB数据一致
如何对账
描述:取前一天和当天的数据,与redis缓存对比
select goods_id, sum(num) as num '余量' from goods_stock_log a where a.create_time between 前一天 and 当前天 and b.create_time between 前一天 and 当前天 group by goods_id
正常情况下
对账表
异常情况下
对账表
第五条纪录应该是6000开始但是为什么是从5997开始了呢?这就是可能流程图当中出现的可能性导致的,redis库存比真实库存少导致的。
解决方案
补偿机制: 定时任务查询订单商品与对账表,如果发现对账缺少记录,则新增一条对账记录
对账期间不下单
对账期间不下单,或是说对账过程中,既不允许做最终一致性处理,也不允许下单,也没有正在进行的下单。或者说是在对账过程中redis中的缓存版本和数据库中的库存版本不能在发生变动,
解决方案
分布式锁
2. 定时期执行SQL,查询“对不上的账”,可交由人工或采用补偿机制任务来处理(见上面的解决方案)
考虑的问题
1. 对账期间商品出现状态变更
比如:订单退款
解决方案
最终一致性定时器
在【退款】【下单】【商家增量】【商家减量】都是由最终一致性定时器处理
对账定时器
负责统计对账表数据与redis数据是否一致
总结
对账表:用于对账和保障redis数据一致性 扣减表:用于同步DB库存数量 商家增减:要用分布多锁 分布式锁:对账期间,最终一致性
分布式服务
分布式存储
读写分离如何在业务中落地?
什么时候需要读写分离
互联网大部分业务场景都是读多写少的,对于电商等典型业务,读和写的请求对比可能差了不止一个数量级。为了不让数据库的读成为业务瓶颈,同时也为了保证写库的成功率,一般会采用读写分离的技术来保证。
分离读库和写库操作,从 CRUD 的角度,主数据库处理新增、修改、删除等事务性操作,而从数据库处理 SELECT 查询操作。具体的实现上,可以有一主一从,一个主库配置一个从库;也可以一主多从,也就是一个主库,但是配置多个从库,读操作通过多个从库进行,支撑更高的读并发压力。
binlog 日志
MySQL InnoDB 引擎的主从复制,是通过二进制日志 binlog 来实现。除了数据查询语句 select 以外,binlog 日志记录了其他各类数据写入操作,包括 DDL 和 DML 语句。
binlog 有三种格式:Statement、Row 及 Mixed。 Statement 格式,基于 SQL 语句的复制 在 Statement 格式中,binlog 会记录每一条修改数据的 SQL 操作,从库拿到后在本地进行回放就可以了。 Row 格式,基于行信息复制 Row 格式以行为维度,记录每一行数据修改的细节,不记录执行 SQL 语句的上下文相关的信息,仅记录行数据的修改。假设有一个批量更新操作,会以行记录的形式来保存二进制文件,这样可能会产生大量的日志内容。 Mixed 格式,混合模式复制 Mixed 格式,就是 Statement 与 Row 的结合,在这种方式下,不同的 SQL 操作会区别对待。比如一般的数据操作使用 row 格式保存,有些表结构的变更语句,使用 statement 来记录。
主从复制过程
主库将变更写入 binlog 日志,从库连接到主库之后,主库会创建一个log dump 线程,用于发送 bin log 的内容。 从库开启同步以后,会创建一个 IO 线程用来连接主库,请求主库中更新的 bin log,I/O 线程接收到主库 binlog dump 进程发来的更新之后,保存在本地 relay 日志中。 接着从库中有一个 SQL 线程负责读取 relay log 中的内容,同步到数据库存储中,也就是在自己本地进行回放,最终保证主从数据的一致性。
主从复制下的延时问题
由于主库和从库是两个不同的数据源,主从复制过程会存在一个延时,当主库有数据写入之后,同时写入 binlog 日志文件中,然后从库通过 binlog 文件同步数据,由于需要额外执行日志同步和写入操作,这期间会有一定时间的延迟。特别是在高并发场景下,刚写入主库的数据是不能马上在从库读取的,要等待几十毫秒或者上百毫秒以后才可以。
为了解决主从同步延迟的问题,通常有以下几个方法。
敏感业务强制读主库 在开发中有部分业务需要写库后实时读数据,这一类操作通常可以通过强制读主库来解决。 关键业务不进行读写分离 对一致性不敏感的业务,比如电商中的订单评论、个人信息等可以进行读写分离,对一致性要求比较高的业务,比如金融支付,不进行读写分离,避免延迟导致的问题。
主从复制如何避免丢数据
MySQL 数据库主从复制有异步复制、半同步复制和全同步复制的方式。
异步复制
异步复制模式下,主库在接受并处理客户端的写入请求时,直接返回执行结果,不关心从库同步是否成功,这样就会存在上面说的问题,主库崩溃以后,可能有部分操作没有同步到从库,出现数据丢失问题。
半同步复制
在半同步复制模式下,主库需要等待至少一个从库完成同步之后,才完成写操作。主库在执行完客户端提交的事务后,从库将日志写入自己本地的 relay log 之后,会返回一个响应结果给主库,主库确认从库已经同步完成,才会结束本次写操作。相对于异步复制,半同步复制提高了数据的安全性,避免了主库崩溃出现的数据丢失,但是同时也增加了主库写操作的耗时。
全同步复制
全同步复制指的是在多从库的情况下,当主库执行完一个事务,需要等待所有的从库都同步完成以后,才完成本次写操作。全同步复制需要等待所有从库执行完对应的事务,所以整体性能是最差的。
为什么需要分库分表,如何实现?
分库分表的背景
互联网业务的一个特点就是用户量巨大,BAT等头部公司都是亿级用户,产生的数据规模也飞速增长,传统的单库单表架构不足以支撑业务发展,存在下面的性能瓶颈:
读写的数据量限制
数据库的数据量增大会直接影响读写的性能,比如一次查询操作,扫描 5 万条数据和 500 万条数据,查询速度肯定是不同的。
数据库连接限制
数据库的连接是有限制的,不能无限制创建,比如 MySQL 中可以使用 max_connections 查看默认的最大连接数,当访问连接数过多时,就会导致连接失败。以电商为例,假设存储没有进行分库,用户、商品、订单和交易,所有的业务请求都访问同一个数据库,产生的连接数是非常可观的,可能导致数据库无法支持业务请求。 使用数据库连接池,可以优化连接数问题,但是更好的方式是通过分库等手段,避免数据库连接成为业务瓶颈。
分库分表原理
分库分表,顾名思义,就是将原本存储于单个数据库上的数据拆分到多个数据库,把原来存储在单张数据表的数据拆分到多张数据表中,实现数据切分,从而提升数据库操作性能。分库分表的实现可以分为两种方式:垂直切分和水平切分。
垂直切分
垂直分库针对的是一个系统中对不同的业务进行拆分,根据业务维度进行数据的分离,剥离为多个数据库。比如电商网站早期,商品数据、会员数据、订单数据都是集中在一个数据库中,随着业务的发展,单库处理能力已成为瓶颈,这个时候就需要进行相关的优化,进行业务维度的拆分,分离出会员数据库、商品数据库和订单数据库等。
水平切分
水平拆分是把相同的表结构分散到不同的数据库和不同的数据表中,避免访问集中的单个数据库或者单张数据表,具体的分库和分表规则,一般是通过业务主键,进行哈希取模操作。
例
例如,电商业务中的订单信息访问频繁,可以将订单表分散到多个数据库中,实现分库;在每个数据库中,继续进行拆分到多个数据表中,实现分表。路由策略可以使用订单 ID 或者用户 ID,进行取模运算,路由到不同的数据库和数据表中。
分库分表后引入的问题
分布式事务问题
比如数据库拆分后,订单和库存在两个库中,一个下单减库存的操作,就涉及跨库事务。关于分布式事务的处理,我们在专栏“分布式事务”的模块中也介绍过,可以使用分布式事务中间件,实现 TCC 等事务模型;也可以使用基于本地消息表的分布式事务实现
跨库关联查询问题
分库分表后,跨库和跨表的查询操作实现起来会比较复杂,性能也无法保证。在实际开发中,针对这种需要跨库访问的业务场景,一般会使用额外的存储,比如维护一份文件索引。另一个方案是通过合理的数据库字段冗余,避免出现跨库查询。
跨库跨表的合并和排序问题
分库分表以后,数据分散存储到不同的数据库和表中,如果查询指定数据列表,或者需要对数据列表进行排序时,就变得异常复杂,则需要在内存中进行处理,整体性能会比较差,一般来说,会限制这类型的操作。具体的实现,可以依赖开源的分库分表中间件来处理,下面就来介绍一下。
分库分表中间件实现
业务中实现分库分表,需要自己去实现路由规则,实现跨库合并排序等操作,具有一定的开发成本,可以考虑使用开源的分库分表中间件。这里比较推荐 Apache ShardingSphere,另外也可以参考淘宝的 TDDL 等。
存储拆分后,如何解决唯一主键问题?
在单库单表时,业务 ID 可以依赖数据库的自增主键实现,现在我们把存储拆分到了多处,如果还是用数据库的自增主键,势必会导致主键重复。
生成主键有哪些方案
一个最直接的方案是使用单独的自增数据表,存储拆分以后,创建一张单点的数据表,比如现在需要生成订单 ID,我们创建下面一张数据表:
CREATE TABLE IF NOT EXISTS `order_sequence`( `order_id` INT UNSIGNED AUTO_INCREMENT, PRIMARY KEY ( `order_id` ) )ENGINE=InnoDB DEFAULT CHARSET=utf8;
使用 UUID 能否实现
public String getUUID(){ UUID uuid=UUID.randomUUID(); return uuid.toString(); }
首先 UUID 作为数据库主键太长了,会导致比较大的存储开销
缺点
UUID 是无序的,如果使用 UUID 作为主键,会降低数据库的写入性能。
MySQL InnoDB 引擎支持索引,底层数据结构是 B+ 树,如果主键为自增 ID 的话,那么 MySQL 可以按照磁盘的顺序去写入;如果主键是非自增 ID,在写入时需要增加很多额外的数据移动,将每次插入的数据放到合适的位置上,导致出现页分裂,降低数据写入的性能。
基于 Snowflake 算法

第 1 位默认不使用,作为符号位,总是 0,保证数值是正数; 41 位时间戳,表示毫秒数,我们计算一下,41 位数字可以表示 241 毫秒,换算成年,结果是 69 年多一点,一般来说,这个数字足够在业务中使用了; 10 位工作机器 ID,支持 210 也就是 1024 个节点; 12 位序列号,作为当前时间戳和机器下的流水号,每个节点每毫秒内支持 212 的区间,也就是 4096 个 ID,换算成秒,相当于可以允许 409 万的 QPS,如果在这个区间内超出了 4096,则等待至下一毫秒计算。
Snowflake 算法可以作为一个单独的服务,部署在多台机器上,产生的 ID 是趋势递增的,不需要依赖数据库等第三方系统,并且性能非常高,理论上 409 万的 QPS 是一个非常可观的数字,可以满足大部分业务场景,其中的机器 ID 部分,可以根据业务特点来分配,比较灵活。
不足
如果服务器在同步 NTP 时出现不一致,出现时钟回拨,那么 SnowFlake 在计算中可能出现重复 ID。除了 NTP 同步,闰秒也会导致服务器出现时钟回拨,不过时钟回拨是小概率事件,在并发比较低的情况下一般可以忽略。关于如何解决时钟回拨问题,可以进行延迟等待,直到服务器时间追上来为止
数据库维护区间分配
一种基于数据库维护自增ID区间,结合内存分配的策略
首先在数据库中创建 sequence 表,其中的每一行,用于记录某个业务主键当前已经被占用的 ID 区间的最大值。 接下来插入一条行记录,当需要获取主键时,每台服务器主机从数据表中取对应的 ID 区间缓存在本地,同时更新 sequence 表中的 value 最大值记录。 当服务器在获取主键增长区段时,首先访问对应数据库的 sequence 表,更新对应的记录,占用一个对应的区间。比如我们这里设置步长为 200,原先的 value 值为 1000,更新后的 value 就变为了 1200。 取到对应的 ID 区间后,在服务器内部进行分配,涉及的并发问题可以依赖乐观锁等机制解决。
CREATE TABLE `sequence` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'Id', `name` varchar(64) NOT NULL COMMENT 'sequence name', `value` bigint(32) NOT NULL COMMENT 'sequence current value', PRIMARY KEY (`id`), UNIQUE KEY `unique_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO sequence (name,value) values('order_sequence',1000);i
例
Sharding-JDBC-自定义主键生成器
1. 分布式主键生成器的接口org.apache.shardingsphere.spi.keygen.ShardingKeyGenerator,方便用户自行实现自定义的自增主键生成器。 2. 配置SPI 在Apache ShardingSphere中,很多功能实现类的加载方式是通过SPI注入的方式完成的。 Service Provider Interface (SPI)是一种为了被第三方实现或扩展的API,它可以用于实现框架扩展或组件替换。 注意:在resources目录下新建META-INF文件夹,再新建services文件夹, 然后新建文件的名字为org.apache.shardingsphere.spi.keygen.ShardingKeyGenerator, 打开文件:复制自定义分布式主键的类全路径到文件中保存 com.example.shareingjdbctest.config.MyKeyGenerator 3. 配置application.properties 其中MyKeyGenerator对应自定主键类的getType里面的实现内容 spring.shardingsphere.sharding.tables.user.key-generator.column=id spring.shardingsphere.sharding.tables.user.key-generator.type=MyKeyGenerator
//例子1 public class SimpleShardingKeyGenerator implements ShardingKeyGenerator { private AtomicLong atomic = new AtomicLong(0); @Getter @Setter private Properties properties = new Properties(); @Override public Comparable<?> generateKey() { return atomic.incrementAndGet(); } @Override public String getType() { //声明类型 return "SIMPLE"; } }
//例子2 public class MyKeyGenerator implements ShardingKeyGenerator { @Override public Comparable<?> generateKey() { Snowflake snowflake = IdUtil.createSnowflake(1, 1); Long id = snowflake.nextId(); System.out.println("我自定义的id" + id); return id; } @Override public String getType() { return "MyKeyGenerator"; } @Override public Properties getProperties() { return null; } @Override public void setProperties(Properties properties) { } }
分库分表以后,如何实现扩容?
从业务场景出发进行讨论
首先选择存储实现,订单作为电商业务的核心数据,应该尽量避免数据丢失,并且对数据一致性有强要求,肯定是选择支持事务的关系型数据库,比如使用 MySQL 及 InnoDB 存储引擎。 然后是数据库的高可用,订单数据是典型读多写少的数据,不仅要面向消费者端的读请求,内部也有很多上下游关联的业务模块在调用,针对订单进行数据查询的调用量会非常大。基于这一点,我们在业务中配置基于主从复制的读写分离,并且设置多个从库,提高数据安全。 最后是数据规模,6000 万的数据量,显然超出了单表的承受范围,参考《阿里巴巴 Java 开发手册》中「单表行数超过 500 万行」进行分表的建议,此时需要考虑进行分库分表,那么如何设计路由规则和拆分方案呢?接下来会对此展开讨论。
路由规则与扩容方案
1. 哈希取模的方式
哈希取模是分库分表中最常见的一种方案,也就是根据不同的业务主键输入,对数据库进行取模,得到插入数据的位置。 6000 万的数据规模,我们按照单表承载百万数量级来拆分,拆分成 64 张表,进一步可以把 64 张表拆分到两个数据库中,每个库中配置 32 张表。当新订单创建时,首先生成订单 ID,对数据库个数取模,计算对应访问的数据库;接下来对数据表取模,计算路由到的数据表,当处理查询操作时,也通过同样的规则处理,这样就实现了通过订单 ID 定位到具体数据表。
子主题
缺点
不利于后面的扩容
数据库拆分以后,订单库和表的数量都需要调整,路由规则也需要调整,为了适配新的分库分表规则,保证数据的读写正常,不可避免地要进行数据迁移,具体的操作,可以分为停机迁移和不停机迁移两种方式。
停机迁移 停机迁移的方式比较简单,比如我们在使用一些网站或者应用时,经常会收到某段时间内暂停服务的通知,一般是在这段时间内,完成数据迁移,将历史数据按照新的规则重新分配到新的存储中,然后切换服务。
不停机迁移 不停机迁移也就是常说的动态扩容,依赖业务上的双写操作实现,需要同时处理存量和增量数据,并且做好各种数据校验。
扩容
一般来说,具体的数据库扩容方式有基于原有存储增加节点,以及重新部署一套新的数据库两种策略,针对不同的扩容方式,需要不同的迁移方案和双写策略支持。
如果重新部署新的数据库存储,可以粗略地分为以下的步骤: 创建一套新的订单数据库; 在某个时间点上,将历史数据按照新的路由规则分配到新的数据库中; 在旧数据库的操作中开启双写,同时写入到两个数据库; 用新的读写服务逐步替代旧服务,同步进行数据不一致校验,最后完成全面切流。
2. 基于数据范围进行拆分
基于数据范围进行路由,通常是根据特定的字段进行划分不同区间,对订单表进行拆分中,如果基于数据范围路由,可以按照订单 ID 进行范围的划分。 同样是拆分成 64 张数据表,可以把订单 ID 在 3000万 以下的数据划分到第一个订单库,3000 万以上的数据划分到第二个订单库,在每个数据库中,继续按照每张表 100万 的范围进行划分。
缺点
缺点就是数据访问不均匀。如果按照这种规则,另外一个数据库在很长一段时间内都得不到应用,导致数据节点负荷不均,在极端情况下,当前热点库可能出现性能瓶颈,无法发挥分库分表带来的性能优势。
3. 结合数据范围和哈希取模
如果结合以上两种方式数据范围和哈希取模,那么是不是可以实现数据均匀分布,也可以更好地进行扩容? 通过哈希取模结合数据区间的方式,可以比较好地平衡两种路由方案的优缺点。当数据写入时,首先通过一次取模,计算出一个数据库,然后使用订单 ID 的范围,进行二次计算,将数据分散到不同的数据表中。
消息队列
子主题
子主题
子主题
子主题
每个消费者只消费一部分信息
每个消费者都有自己的队列
优点
异步处理
异步化,不阻塞
易伸缩
消费者可加服务处理更多的任务
使峰值变平缓
隔离失效机器及自我修复
消费者处理失败了,应用程序可以自行维护处理
解耦
生产者和消费者实现解耦
挑战
消息无序
消息重新入队列
竞态条件
多线程并发执行,不同的执行顺序有不同的结果,消息队列在分布式环境下,实现并发访问控制,并发执行导致对资源的争用,通常使用锁的机制进行并发控制。
复杂度风险
架构复杂化
反模式
子主题
总结
分布式缓存
不止业务缓存,分布式系统中还有哪些缓存?
查看一个商品详情页,这个过程就涉及多种缓存的协同,我们从页面入口开始梳理一下
前端缓存
前端缓存包括页面和浏览器缓存,如果你使用的是 App,那么在 App 端也会有缓存
页面缓存属于客户端缓存的一种,在第一次访问时,页面缓存将浏览器渲染的页面存储在本地,当用户再次访问相同的页面时,可以不发送网络连接,直接展示缓存的内容,以提升整体性能。 页面缓存一般用于数据更新比较少的数据,不会频繁修改。除了页面缓存,大部分浏览器自身都会实现缓存功能,比如查看某个商品信息,我如果要回到之前的列表页,点击后退功能,就会应用到浏览器缓存;另外对于页面中的图片和视频等,浏览器都会进行缓存,方便下次查看。
网络传输缓存
大多数业务请求都是通过 HTTP/HTTPS 协议实现的,它们工作在 TCP 协议之上,多次握手以后,浏览器和服务器建立 TCP 连接,然后进行数据传输,在传输过程中,会涉及多层缓存,比如 CDN 缓存等。 网络中缓存包括 CDN 缓存,CDN(Content Delivery Network,内容分发网络)实现的关键包括 内容存储 和 内容分发 ,内容存储就是对数据的缓存功能,内容分发则是 CDN 节点支持的负载均衡。 网络缓存还包括 负载均衡中的缓存 ,负载均衡服务器主要实现的是请求路由,也就是负载均衡功能;也可以实现部分数据的缓存,比如一些配置信息等很少修改的数据。 目前业务开发中大部分负载均衡都是通过 Nginx 实现的,用户请求在达到应用服务器之前,会先访问 Nginx 负载均衡器。如果发现有缓存信息,则直接返回给用户,如果没有发现缓存信息,那么 Nginx 会 回源 到应用服务器获取信息。
服务端缓存
服务端缓存是缓存的重点,也是业务开发平时打交道最多的缓存。它还可以进一步分为 本地缓存 和 外部缓存 。 1. 应用内缓存 ,比如 Guava 实现的各级缓存。 2. 外部缓存就是我们平常应用的 Redis、Memchaed 等 NoSQL 存储的分布式缓存,它也是在系统设计中对整体性能提升最大的缓存。
数据库缓存
MyBatis 为每个 SqlSession 都创建了 LocalCache,LocalCache 可以实现查询请求的缓存, 如果查询语句命中了 缓存 , 返回给用户,否则查询数据库, 并且 写入 LocalCache, 返回结果给用户。不过在实际开发中,数据库持久层的缓存非常容易出现数据不一致的情况,所以一般不推荐使用。 在数据库执行查询语句时,MySQL 会保存一个 Key-Value 的形式缓存在内存中,其中 Key 是查询语句,Value 是结果集。如果缓存 Key 被命中,则会直接返回给客户端,否则会通过数据库引擎 进行 查询,并且把结果缓存起来,方便下一次调用。虽然 MySQL 支持缓存,但是由于需要保证一致性,当数据有修改时,需要删除缓存。如果是某些更新特别频繁的数据,缓存的有效时间非常短,带来的优化效果并不明显。
如何避免缓存穿透、缓存击穿、缓存雪崩?
缓存穿透
业务请求穿过了缓存层,落到持久化存储上。
发生缓存穿透的场景
不合理的缓存失效策略
设置了大量缓存在同一时间点失效,那么将导致大量缓存数据在同一时刻发生缓存穿透,业务请求直接打到持久化存储层。
外部用户的恶意攻击
外部恶意用户利用不存在的 Key,来构造大批量不存在的数据请求我们的服务,由于缓存中并不存在这些数据,因此海量请求全部穿过缓存,落在数据库中,将导致数据库崩溃。
解决方案
通过缓存空数据的方式避免。缓存空数据非常好理解,就是针对数据库不存在的数据,在查询为空时,添加一个对应 null 的值到缓存中
布隆过滤器
使用布隆过滤器,可在缓存前添加一层过滤,布隆过滤器映射到缓存,在缓存中不存在的数据,会在布隆过滤器这一层拦截,从而保护缓存和数据库的安全。
缓存击穿
前端请求大量的访问某个热点 Key,而这个热点 Key 在某个时刻恰好失效,导致请求全部落到数据库上。
缓存击穿和缓存穿透都是降低了整体的缓存命中率,不过在表现上比较类似。缓存击穿可以认为是缓存穿透的一种特殊场景,所以在解决方案上也可以应用上面提到的那几种手段。
解决方案
通过缓存空数据的方式避免。缓存空数据非常好理解,就是针对数据库不存在的数据,在查询为空时,添加一个对应 null 的值到缓存中
布隆过滤器
使用布隆过滤器,可在缓存前添加一层过滤,布隆过滤器映射到缓存,在缓存中不存在的数据,会在布隆过滤器这一层拦截,从而保护缓存和数据库的安全。
缓存雪崩
一种是大量的缓存数据在同一时刻失效,请求全部转发到数据库,将导致数据库压力过大,服务宕机;
另外一种是缓存服务不稳定,比如负责缓存的 Redis 集群宕机。
解决方案
限流和降级
合理的限流和降级,防止大量请求直接拖垮缓存
缓存集群高可用
以 Redis 为例,可以通过部署 RedisCluster、Proxy 等不同的缓存集群,来实现缓存集群高可用。
缓存稳定性
缓存的命中率要高
从缓存数据的层面,有一个缓存命中率的概念,是指落到缓存上的请求占整体请求总量的占比。缓存命中率在电商大促等场景中是一个非常关键的指标,我们要尽可能地提高缓存数据的命中率,一般要求达到 90% 以上,如果是大促等场景,会要求 99% 以上的命中率。
集群
从缓存服务的层面,缓存集群本身也是一个服务,也会有集群部署,服务可用率,服务的最大容量等。在应用缓存时,要对缓存服务进行压测,明确缓存的最大水位,如果当前系统容量超过缓存阈值,就要通过其他的高可用手段来进行调整,比如服务限流,请求降级,使用消息队列等不同的方式。
先更新数据库,还是先更新缓存?
数据不一致问题
缓存层和数据库存储层是独立的系统,我们在数据更新的时候,最理想的情况当然是缓存和数据库同时更新成功。但是由于缓存和数据库是分开的,无法做到原子性的同时进行数据修改,可能出现缓存更新失败,或者数据库更新失败的情况,这时候会出现数据不一致,影响前端业务。
更新缓存有哪些方式
先更新数据库,再更新缓存
先来看第一种方式,在写操作中,先更新数据库,更新成功后,再更新缓存。这种方式最容易想到,但是问题也很明显,数据库更新成功以后,由于缓存和数据库是分布式的,更新缓存可能会失败,就会出现上面例子中的问题,数据库是新的,但缓存中数据是旧的,出现不一致的情况。
先删缓存,再更新数据库
假如某次的更新操作,更新了商品详情 A 的价格,线程 A 进行更新时失效了缓存数据,线程 B 此时发起一次查询,发现缓存为空,于是查询数据库并更新缓存,然后线程 A 更新数据库为新的价格。 在这种并发操作下,缓存的数据仍然是旧的,出现业务不一致。
先更新数据库,再删缓存
这个是经典的缓存 + 数据库读写的模式,有些资料称它为 Cache Aside 方案。具体操作是这样的:读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应,更新的时候,先更新数据库,数据库更新成功之后再删除缓存。 在 Cache Aside 方案中,调整了数据库更新和缓存失效的顺序,先更新数据库,再失效缓存。
对缓存更新的思考
为什么删除而不是更新缓存
不是所有的缓存数据都是频繁访问的,更新后的缓存可能会长时间不被访问,所以说,从计算资源和整体性能的考虑,更新的时候删除缓存,等到下次查询命中再填充缓存,是一个更好的方案。 系统设计中有一个思想叫 Lazy Loading,适用于那些加载代价大的操作,删除缓存而不是更新缓存,就是懒加载思想的一个应用。
多级缓存如何更新
服务端缓存分为应用内缓存和外部缓存,比如在电商的商品信息展示中,可能会有多级缓存协同。 常见的方案是通过消息队列通知的方式,也就是在数据库更新后,通过事务性消息队列加监听的方式,失效对应的缓存。 在具体业务中,还是需要有针对性地进行设计,比如通过给数据添加版本号,或者通过时间戳 + 业务主键的方式,控制缓存的数据版本实现最终一致性。
失效策略:缓存过期都有哪些策略?
页面置换算法
在操作系统中,文件的读取会先分配一定的页面空间,也就是我们说的 Page,使用页面的时候首先去查询空间是否有该页面的缓存,如果有的话,则直接拿出来;否则就先查询,页面空间没有满,就把新页面缓存起来,如果页面空间满了,就删除部分页面,方便新的页面插入。
淘汰旧页面的机制不同
FIFO(First In First Out,先进先出),根据缓存被存储的时间,离当前最远的数据优先被淘汰; LRU(Least Recently Used,最近最少使用),根据最近被使用的时间,离当前最远的数据优先被淘汰; LFU(Least Frequently Used,最不经常使用),在一段时间内,缓存数据被使用次数最少的会被淘汰。
内存淘汰策略
分布式缓存中,就是缓存的内存淘汰策略
当 Redis 节点分配的内存使用到达最大值以后,为了继续提供服务,Redis 会启动内存淘汰策略
noeviction,这是默认的策略,对于写请求会拒绝服务,直接返回错误,这种策略下可以保证数据不丢失; allkeys-lru,这种策略操作的范围是所有 key,使用 LRU 算法进行缓存淘汰; volatile-lru,这种策略操作的范围是设置了过期时间的 key,使用 LRU 算法进行淘汰; allkeys-random,这种策略下操作的范围是所有 key,会进行随机淘汰数据; volatile-random,这种策略操作的范围是设置了过期时间的 key,会进行随机淘汰; volatile-ttl,这种策略操作的范围是设置了过期时间的 key,根据 key 的过期时间进行淘汰,越早过期的越优先被淘汰。
缓存过期策略
分布式缓存中的过期策略和内存淘汰策略又有一些不同,希望大家不要混淆,内存淘汰是缓存服务层面的操作,而过期策略定义的是具体缓存数据何时失效
Redis 是 key-value 数据库,可以设置缓存 key 的过期时间,过期策略就是指当 Redis 中缓存的 key 过期了,Redis 如何处理。
定时过期
为每个设置过期时间的 key 都需要创建一个定时器,到过期时间就会立即清除。
惰性过期
可以类比懒加载的策略,这个就是懒过期,只有当访问一个 key 时,才会判断该 key 是否已过期,并且进行删除操作。
定期过期
添加一个即将过期的缓存字典,每隔一定的时间,会扫描一定数量的 key,并清除其中已过期的 key。
负载均衡:一致性哈希解决了哪些问题?
缓存的集群高可用
关于缓存集群高可用的配置方式,有数据同步和不同步之分。 1. 在数据同步的方案下,所有节点之间数据都是一样的,不同节点互为副本,这种方式不需要关心缓存数据的分发,实现了缓存集群的最大可用,但是由于冗余了多份缓存数据,会造成比较多的服务器资源浪费;另外一方面,在更新缓存数据时,还要考虑不同节点之间的一致性。 2. 数据不同步的方案,就是每个缓存节点存储的数据不同,在缓存读写时使用一定的策略进行分发。在实际开发中,大部分都是应用数据不同步的方案,如果需要冗余数据,则可以通过缓存集群主从同步实现。
一致性哈希
一致性哈希是一种特殊的哈希算法。在使用一致性哈希算法后,哈希表槽位数(大小)的改变平均只需要对 K/n 个关键字重新映射,其中 K 是关键字的数量,n 是槽位数量。然而在传统的哈希表中,添加或删除一个槽位几乎需要对所有关键字进行重新映射。
一致性哈希通过一个哈希环实现,Hash 环的基本思路是获取所有的服务器节点 hash 值,然后获取 key 的 hash,与节点的 hash 进行对比,找出顺时针最近的节点进行存储和读取。
一致性哈希虽然对扩容和缩容友好,但是存在另外一个问题,就很容易出现数据倾斜。
原理
一致性的Hash算法是对2的32方取模。即,一致性Hash算法将整个Hash空间组织成一个虚拟的圆环,Hash函数的值空间为0 ~ 2^32 - 1(一个32位无符号整型)
第二步,我们将各个服务器使用Hash进行一个哈希,具体可以选择服务器的IP或主机名作为关键字进行哈希,这样每台服务器就确定在了哈希环的一个位置上,比如我们有三台机器。
将数据Key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针查找,遇到的服务器就是其应该定位到的服务器。
容错性和可扩展性
C节点宕机情况,数据移到节点A上 假设我们的Node C宕机了,我们从图中可以看到,A、B不会受到影响,只有Object C对象被重新定位到Node A。所以我们发现,在一致性Hash算法中,如果一台服务器不可用,受影响的数据仅仅是此服务器到其环空间前一台服务器之间的数据(这里为Node C到Node B之间的数据),其他不会受到影响。
增加新的服务器节点X 此时对象ObjectA、ObjectB没有受到影响,只有Object C重新定位到了新的节点X上。
一致性Hash算法对于节点的增减都只需重定位环空间中的一小部分数据,有很好的容错性和可扩展性。
数据倾斜问题
在一致性Hash算法服务节点太少的情况下,容易因为节点分布不均匀而造成数据倾斜(被缓存的对象大部分缓存在某一台服务器上)问题
解决方案
一致性Hash算法引入了虚拟节点机制,即对每一个服务器节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。 数据定位算法不变,只需要增加一步:虚拟节点到实际点的映射。
在服务器IP后面增加编号,每一台服务器产生多个Hash值,就能放置在0~2^32-1的多个位置上了。这样一来,顺时针行走能找到不同的服务器概率将会大大提高,避免了偏斜问题。虚拟的服务器节点数越多,偏斜出现的概率就越低。通常都需要设置32或以上的虚拟节点数目,我见过甚至有设置500的。
缓存高可用:缓存如何保证高可用?
Redis 的主从复制
主从复制技术在关系型数据库、缓存等各类存储节点中都有比较广泛的应用。Redis 的主从复制,可以将一台服务器的数据复制到其他节点,在 Redis 中,任何节点都可以成为主节点,通过 Slaveof 命令可以开启复制。
选主
正常情况下,当主节点发生故障宕机,需要运维工程师手动从从节点服务器列表中,选择一个晋升为主节点,并且需要更新上游客户端的配置,这种方式显然是非常原始的,我们希望有一个机制,可以自动实现 Failover,也就是自动故障转移 。
Redis Sentinel——Redis 哨兵
Redis Sentinel 就是我们常说的 Redis 哨兵机制,也是官方推荐的高可用解决方案,上面我们提到的主从复制场景,就可以依赖 Sentinel 进行集群监控。
Redis-Sentinel 是一个独立运行的进程,假如主节点宕机,它还可以进行主从之间的切换
Sentinel 也存在单点问题,如果 Sentinel 宕机,高可用也就无法实现了,所以,Sentinel 必须支持集群部署。
功能: 不定期监控 Redis 服务运行状态 发现 Redis 节点宕机,可以通知上游的客户端进行调整 当发现 Master 节点不可用时,可以选择一个 Slave 节点,作为新的 Master 机器,并且更新集群中的数据同步关系
Redis Cluster 集群
Redis Cluster 是官方的集群方案,是一种无中心的架构,可以整体对外提供服务。
因为在 Redis Cluster 集群中,所有 Redis 节点都可以对外提供服务,包括路由分片、负载信息、节点状态维护等所有功能都在 Redis Cluster 中实现。 Redis 各实例间通过 Gossip 通信,这样设计的好处是架构清晰、依赖组件少,方便横向扩展,有资料介绍 Redis Cluster 集群可以扩展到 1000 个以上的节点。
分布式高可用
从双十一看高可用的保障方式
从几方面入手
第一个特点是海量用户请求,万倍日常流量,大促期间的流量是平时的千百倍甚至万倍,从这一点来讲,要做好容量规划,在平时的演练中需做好调度。 解决方案:目前大部分公司的部署都是应用 Docker 容器化编排,分布式需要快速扩展集群,而容器化编排操作简单,可以快速扩展实例,可以说,容器化和分布式是天生一对,提供了一个很好的解决方案。
第二点是流量突增,是典型的秒杀系统请求曲线,我们都知道秒杀系统的流量是在瞬间达到一个峰值,流量曲线非常陡峭。为了吸引用户下单,电商大促一般都会安排若干场的秒杀抢购。 解决方案:通过独立热点集群部署、消息队列削峰、相关活动商品预热缓存等方案来解决。
第三点是高并发,支海量用户请求,对于业务系统来说就是高并发,QPS 会是平时的几百倍甚至更高。 解决方案:电商大促面临的问题主要是支撑高并发和高可用,高可用常见的手段有缓存、消息队列。
总结 以双十一电商大促作为背景,总结了电商大促的业务特点,业务开发中保证稳定性的关键,并且简单介绍了高可用技术保障的几个常见手段,包括我们在前面讲解的消息队列技术、缓存技术,以及后面要展开讲解的限流、降级、熔断、隔离、负载均衡等手段。
高并发场景下如何实现系统限流?
常见限流算法
限流是服务降级的一种手段,顾名思义,通过限制系统的流量,从而实现保护系统的目的。 合理的限流配置,需要了解系统的吞吐量,所以,限流一般需要结合容量规划和压测来进行。当外部请求接近或者达到系统的最大阈值时,触发限流,采取其他的手段进行降级,保护系统不被压垮。常见的降级策略包括延迟处理、拒绝服务、随机拒绝等。
服务降级系统中的限流不简单
第一,限流方案必须是可选择的,没有任何方案可以适用所有场景,每种限流方案都有自己适合的场景,我们得根据业务和资源的特性和要求来选择限流方案; 第二,限流策略必须是可配的,对策略调优一定是个长期的过程,这里说的策略,可以理解成建立在某个限流方案上的一套相关参数。
常见的限流方式
通过限制单位时间段内调用量来限流 通过限制系统的并发调用程度来限流 使用漏桶(Leaky Bucket)算法来进行限流 使用令牌桶(Token Bucket)算法来进行限流
计数器法
我们进行限流时使用的是单位时间内的请求数,也就是平常说的 QPS,统计 QPS 最直接的想法就是实现一个计数器。
计数器策略进行限流,可以从单点扩展到集群,适合应用在分布式环境中。单点限流使用内存即可,如果扩展到集群限流,可以用一个单独的存储节点,比如 Redis 或者 Memcached 来进行存储,在固定的时间间隔内设置过期时间,就可以统计集群流量,进行整体限流。
例
计数器法是限流算法里最简单的一种算法,我们假设一个接口限制 100 秒内的访问次数不能超过 10000 次,维护一个计数器,每次有新的请求过来,计数器加 1。这时候判断,如果计数器的值小于限流值,并且与上一次请求的时间间隔还在 100 秒内,允许请求通过,否则拒绝请求;如果超出了时间间隔,要将计数器清零。
public class CounterLimiter { //初始时间 private static long startTime = System.currentTimeMillis(); //初始计数值 private static final AtomicInteger ZERO = new AtomicInteger(0); //时间窗口限制 private static final int interval = 10000; //限制通过请求 private static int limit = 100; //请求计数 private AtomicInteger requestCount = ZERO; //获取限流 public boolean tryAcquire() { long now = System.currentTimeMillis(); //在时间窗口内 if (now < startTime + interval) { //判断是否超过最大请求 if (requestCount.get() < limit) { requestCount.incrementAndGet(); return true; } return false; } else { //超时重置 requestCount = ZERO; startTime = now; return true; } } }
缺点
对临界流量不友好,限流不够平滑。假设这样一个场景,我们限制用户一分钟下单不超过 10 万次,现在在两个时间窗口的交汇点,前后一秒钟内,分别发送 10 万次请求。也就是说,窗口切换的这两秒钟内,系统接收了 20 万下单请求,这个峰值可能会超过系统阈值,影响服务稳定性。
优化
对计数器算法的优化,就是避免出现两倍窗口限制的请求,可以使用滑动窗口算法实现
漏桶
漏桶算法可以用漏桶来对比,假设现在有一个固定容量的桶,底部钻一个小孔可以漏水,我们通过控制漏水的速度,来控制请求的处理,实现限流功能。 不管突然流量有多大,漏桶都保证了流量的常速率输出,也可以类比于调用量,比如,不管服务调用多么不稳定,我们只固定进行服务输出,比如每10毫秒接受一次服务调用
漏桶算法的拒绝策略很简单,如果外部请求超出当前阈值,则会在水桶里积蓄,一直到溢出,系统并不关心溢出的流量。漏桶算法是从出口处限制请求速率,并不存在上面计数器法的临界问题,请求曲线始终是平滑的。
问题点
漏桶算法的一个核心问题是,对请求的过滤太精准了,我们常说,水至清则无鱼,其实在限流里也是一样的, 我们限制每秒下单 10 万次,那 10 万零 1 次请求呢?是不是必须拒绝掉呢? 大部分业务场景下这个答案是否定的,虽然限流了,但还是希望系统允许一定的突发流量,这时候就需要令牌桶算法。
令牌桶算法
在令牌桶算法中,假设我们有一个大小恒定的桶,这个桶的容量和设定的阈值有关,桶里放着很多令牌,通过一个固定的速率,往里边放入令牌,如果桶满了,就把令牌丢掉,最后桶中可以保存的最大令牌数永远不会超过桶的大小。 当有请求进入时,就尝试从桶里取走一个令牌,如果桶里是空的,那么这个请求就会被拒绝。 两种令牌生成方法,第一种就是直接往桶中放回使用的令牌数,第二种就是不做任何操作,有另一个额外的令牌生成步骤来将令牌匀速放回桶中。
例
在 Guava 中,就有限流策略的工具类 RateLimiter,RateLimiter 基于令牌桶算法实现流量限制,使用非常方便。 RateLimiter 会按照一定的频率往桶里扔令牌,线程拿到令牌才能执行,RateLimter 的 API 可以直接应用,主要方法是 acquire 和 tryAcquire,acquire 会阻塞,tryAcquire 方法则是非阻塞的。
public class LimiterTest { public static void main(String[] args) throws InterruptedException { //允许10个,permitsPerSecond RateLimiter limiter = RateLimiter.create(100); for(int i=1;i<200;i++){ if (limiter.tryAcquire(1)){ System.out.println("第"+i+"次请求成功"); }else{ System.out.println("第"+i+"次请求拒绝"); } } } }
不同限流算法的比较
计数器算法实现比较简单,特别适合集群情况下使用,但是要考虑临界情况,可以应用滑动窗口策略进行优化,当然也是要看具体的限流场景。 漏桶算法和令牌桶算法,漏桶算法提供了比较严格的限流,令牌桶算法在限流之外,允许一定程度的突发流量。在实际开发中,我们并不需要这么精准地对流量进行控制,所以令牌桶算法的应用更多一些。 如果我们设置的流量峰值是 permitsPerSecond=N,也就是每秒钟的请求量,计数器算法会出现 2N 的流量,漏桶算法会始终限制N的流量,而令牌桶算法允许大于 N,但不会达到 2N 这么高的峰值
降级和熔断:如何增强服务稳定性?
高可用之降级
在大促场景下,请求量剧增,可我们的系统资源是有限的,服务器资源是企业的固定成本,这个成本不可能无限扩张,所以说,降级是解决系统资源不足和海量业务请求之间的矛盾。
手段
在暴增的流量请求下,对一些非核心流程业务、非关键业务,进行有策略的放弃,以此来释放系统资源,保证核心业务的正常运行。服务降级就是尽量避免这种系统资源分配的不平衡,打破二八策略,让更多的机器资源,承载主要的业务请求。
例
我们都有在 12306 网站购票的经历,在早期春运抢票时,会有大量的购票者进入请求,如果火车票服务不能支撑,你想一想,是直接失败好呢,还是返回一个空的信息好呢?一般都会返回一个空的信息,这其实是一种限流后的策略,我们从一个广义的角度去理解,限流也是一种服务降级手段,是针对部分请求的降级。
注意哪些点
首先需要注意梳理核心流程,知道哪些业务是可以被牺牲的,比如双十一大家都忙着抢购,这时候一些订单评论之类的边缘功能,就很少有人去使用。
要明确开启时间,在系统水位到达一定程度时开启。还记得我们在第 16 课时提到的分布式配置中心吗?降级一般是通过配置的形式,做成一个开关,在高并发的场景中打开开关,开启降级。
高可用之熔断
在高可用设计中,也有熔断的技术手段,熔断模式保护的是业务系统不被外部大流量或者下游系统的异常而拖垮。 通过添加合理的熔断策略,可以防止系统不断地去请求可能超时和失败的下游业务,跳过下游服务的异常场景,防止被拖垮,也就是防止出现服务雪崩的情况。 一个设计完善的熔断策略,可以在下游服务异常时关闭调用,在下游服务恢复正常时,逐渐恢复流量。
例
现在有一个订单查询的场景,QPS 非常高,但是恰好评论服务因为某些原因部分机器宕机,出现大量调用失败的情况。如果没有熔断机制,订单系统可能会在失败后多次重试,最终导致大量请求阻塞,产生级联的失败,并且影响订单系统的上游服务,出现类似服务雪崩的问题,导致整个系统的响应变慢,可用性降低。
解决方案
如果开启了熔断,订单服务可以在下游调用出现部分异常时,调节流量请求,比如在出现 10% 的失败后,减少 50% 的流量请求,如果继续出现 50% 的异常,则减少 80% 的流量请求;相应的,在检测的下游服务正常后,首先恢复 30% 的流量,然后是 50% 的流量,接下来是全部流量。 对于熔断策略的具体实现,我建议你查看 Alibaba Sentinel 或者 Netflix Hystrix 的设计,熔断器的实现其实是数据结构中有限状态机(Finite-state Machines,FSM)的一种应用
如何选择适合业务的负载均衡策略?
负载均衡在处理高并发,缓解网络压力,以及支持扩容等方面非常关键,在不同的应用场景下,可以选择不同的负载均衡
负载均衡的应用
负载均衡是指如何将网络请求派发到集群中的一个或多个节点上处理,一般来说,传统的负载均衡可以分为硬件负载均衡和软件负载均衡。 硬件负载均衡,就是通过专门的硬件来实现负载均衡,比如常见的 F5 设备。 软件负载均衡则是通过负载均衡软件实现,常见的就是 Nginx。
常见的负载均衡策略
轮询策略
轮询策略会顺序地从服务器列表中选择一个节点,请求会均匀地落在各个服务器上。轮询适合各个节点性能接近,并且没有状态的情况,但是在实际开发中,不同节点之间性能往往很难相同,这时候就可以应用另一种加权轮询策略。
加权轮询
加权轮询是对轮询策略的优化,给每个节点添加不同的权重。举个简单的例子,在实际开发中通常使用数组的数据结构来实现轮询,比如现在我有 A、B、C 三个节点,就可以在数组中添加 1、2、3 的数据,分别对应三个节点。现在我进行一个加权调整,让 1、2、3 对应 A,4、5 对应 B、C,这时候继续进行轮询,不同节点的权重就有变化了。
随机策略
随机策略和轮询相似,从列表中随机的取一个。我们都学过概率论的课程,真正的随机是很难实现的,所以如果访问量不是很大,最好不要应用随机策略,可能会导致请求不均匀。
最小响应时间
这个主要是在一些对请求延时敏感的场景中,在进行路由时,会优先发送给响应时间最小的节点。
最小并发数策略
你可以对比最小响应时间,最小并发策略会记录当前时刻每个节点正在处理的事务数,在路由时选择并发最小的节点。最小并发策略可以比较好地反应服务器运行情况,适用于对系统负载较为敏感的场景。
负载均衡如何实现
在服务器端负载均衡中,请求先发送到负载均衡服务器,然后通过负载均衡算法,在众多可用的服务器之中选择一个来处理请求。 在客户端负载均衡中,不需要额外的负载均衡软件,客户端自己维护服务器地址列表,自己选择请求的地址,通过负载均衡算法将请求发送至该服务器。
在分布式服务调用中,服务端负载均衡常用的组件是 Spring Cloud Eureka,如果你选择了 Dubbo 作为中间件,那么可以应用 Dubbo 内置的路由策略。
在 Spring Cloud 中开启负载均衡的方法很简单,有一个专门的注解 @LoadBalanced 注解,配置这个注解之后,客户端在发起请求的时候会选择一个服务端,向该服务端发起请求,实现负载均衡。另外一种客户端负载均衡,也有对应的实现,典型的是 Spring Cloud Ribbon。 Ribbon 实际上是一个实现了 HTTP 的网络客户端,内置负载均衡工具、支持多种容错等。
场景
下单时依赖商品服务,假设我们选择的是轮询策略,当某台商品服务器出现网络故障、服务超时,此时下单就会受影响,如果改为最小可用时间策略,订单服务就会自动进行故障转移,不去请求超时的节点,实现高可用。
子主题
线上服务有哪些稳定性指标?
Tomcat
Springboot内置Tomcat配置调优
内置好处
1、方便微服务部署,减少繁杂的配置 2、方便项目启动,不需要单独下载web容器,如Tomcat,jetty等。
#云服务器配置12核心,24G内存,java启动jar命令:
nohup $JAVA_HOME/bin/java -server -Xms10240m -Xmx14336m -Xmn9216m -XX:MetaspaceSize=400m -XX:MaxMetaspaceSize=5120m -XX:-OmitStackTraceInFastThrow -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:./song_gc.log -XX:ErrorFile=./song_error.log -jar $APP_HOME/$APP_MAINCLASS >> $LOG_FILE 2>&1 &
-XX:MetaspaceSize=128m (元空间默认大小) -XX:MaxMetaspaceSize=128m (元空间最大大小) -Xms1024m (堆最大大小) -Xmx1024m (堆默认大小) -Xmn256m (新生代大小) -Xss256k (棧最大深度大小) -XX:SurvivorRatio=8 (新生代分区比例 8:1:1),一个Eden区和两个Survivor区(S0和S1) -XX:+UseConcMarkSweepGC (指定使用的垃圾收集器,这里使用CMS收集器) -XX:+PrintGCDetails (打印详细的GC日志)
调优地址:https://www.toutiao.com/a6730210807671423499/
针对目前的容器优化,可以从以下几点考虑
1、线程数 2、超时时间 3、JVM优化 首先,线程数是一个重点,每一次HTTP请求到达Web服务器,Web服务器都会创建一个线程来处理该请求,该参数决定了应用服务同时可以处理多少个HTTP请求。 比较重要的有两个:初始线程数和最大线程数。 初始线程数:保障启动的时候,如果有大量用户访问,能够很稳定的接受请求。最大线程数:用来保证系统的稳定性。 超时时间:用来保障连接数不容易被压垮。如果大批量的请求过来,延迟比较高,很容易把线程数用光,这时就需要提高超时时间。这种情况在生产中是比较常见的 ,一旦网络不稳定,宁愿丢包也不能把服务器压垮。 min-spare-threads:最小备用线程数,tomcat启动时的初始化的线程数。 max-threads:Tomcat可创建的最大的线程数,每一个线程处理一个请求,超过这个请求数后,客户端请求只能排队,等有线程释放才能处理。(建议这个配置数可以在服务器CUP核心数的200~250倍之间) 4核8G内存单进程调度线程数800-1000,超过这个并发数之后,将会花费巨大的时间在cpu调度上。 等待队列长度:队列做缓冲池用,但也不能无限长,消耗内存,出入队列也耗cpu。 accept-count:当调用Web服务的HTTP请求数达到tomcat的最大线程数时,还有新的HTTP请求到来,这时tomcat会将该请求放在等待队列中,这个acceptCount就是指能够接受的最大等待数,默认100。如果等待队列也被放满了,这个时候再来新的请求就会被tomcat拒绝(connection refused)。 max-connections:这个参数是指在同一时间,tomcat能够接受的最大连接数。一般这个值要大于(max-threads)+(accept-count)。 connection-timeout:最长等待时间,如果没有数据进来,等待一段时间后断开连接,释放线程。
# Tomcat server: tomcat: uri-encoding: UTF-8 #最小线程数 min-spare-threads: 500 #最大线程数 max-threads: 2500 #最大链接数 max-connections: 6500 #最大等待队列长度 accept-count: 1000 #请求头最大长度kb max-http-header-size: 1048576 #请请求体最大长度kb #max-http-post-size: 2097152 #服务http端口 port: 8080 #链接建立超时时间 connection-timeout: 12000 servlet: #访问根路径 context-path: /song
线程最佳获取
服务器端最佳线程数量=((线程等待时间+线程cpu时间)/线程cpu时间) * cpu数量
假设性实例:Web服务器线程数量的估算 背景信息 假设我们负责一个电商网站的后台服务器,该服务器主要处理用户请求、数据库查询和返回响应。服务器的硬件配置和性能要求如下: 服务器配置: CPU:8核(Intel Xeon处理器) 内存:32GB 磁盘:SSD,高性能存储 网络:高速网络连接 性能要求: 能够处理至少1000个并发请求 响应时间不超过200ms 线程数量估算步骤 确定任务类型: 服务器主要处理的是数据库查询和网络I/O,属于I/O密集型任务。 估算线程CPU时间和等待时间: 假设通过性能测试或监控数据,我们发现每个请求平均在CPU上执行的时间(线程CPU时间)为5ms。 同时,每个请求平均等待数据库响应或网络传输的时间(线程等待时间)为100ms。 应用线程数量公式: 服务器端最佳线程数量 = ((线程等待时间 + 线程CPU时间) / 线程CPU时间) * CPU数量 = ((100ms + 5ms) / 5ms) * 8 = (105ms / 5ms) * 8 = 21 * 8 = 168 调整和优化: 初步估算得出168个线程,但这只是一个理论值。 在实际应用中,我们需要考虑线程创建、销毁和管理的开销。 还需要考虑系统的其他进程和任务的资源占用。 因此,可能会通过实际的性能测试来微调这个数值,以达到最优的性能和响应时间。 实施和监控: 在服务器上配置相应数量的线程,并进行实时监控。 根据服务器的实际响应时间和资源利用率来调整线程数量。 注意事项 这个实例是基于假设的性能数据和硬件配置。 在实际应用中,你需要根据你的具体应用场景、硬件配置和实际性能测试数据来估算最佳线程数量。 线程数量的优化是一个持续的过程,需要根据实际情况进行微调。 虽然这不是一个真实的例子,但它展示了如何根据具体的硬件性能和任务特性来估算服务器端的最佳线程数量。希望这个假设性的实例能帮助你更好地理解线程数量的估算过程。
10ms处理时间,400个线程,一个线程处理100QPS,共计4万QPS,考虑多因素,如CPU、网络延迟、磁盘IO速度等,有30%的损耗,还有70%,则4万 * 0.7 = 2.8万
网关
gateway
概述
API网关是一个服务器,是系统对外的唯一入口。API网关封装了系统内部架构,为每个客户端提供定制的API。所有的客户端和消费都通过统一的网关接入微服务,在网关层处理所有非业务功能
子主题
优点
1. 聚合接口使得服务对调用者透明,客户端与后端的耦合度降低 2. 聚合后台服务,节省流量。提高性能,提升户体验 3. 提供安全、流控、过滤、缓存、计费、监控等API管理功能
网关的作用
子主题
统一对外接口
对于公司内部不同的服务,提供的接口可能在风格上存在一定的差异,通过APIGateway可以统一这种差异。 当内部服务修改时,可以通过APIGateway进行适配,不需要调用方进行调整。
增加系统安全性
APIGateway对外部和内部进行了隔离,减少对外暴露服务可以增加系统安全性,保障了后台服务的安全性。
统一鉴权
通过APIGateway对访问进行统一鉴权,不需要每个应用单独对调用方进行鉴权,应用可以专注业务。
服务注册与授权
可以控制调用方可以使用和不可以使用的服务。
服务限流
通过APIGateway可以对调用方调用每个接口的每日调用及总调用次数限制。
提升预发能力
为服务熔断,灰度发布,线上测试提供简单方案。
全链路跟踪
通过APIGateway提供的唯一请求Id,监控调用流程,以及调用的响应时间。
常用网关解决方案
Spring Cloud Gateway
Spring Cloud Gateway是基于Spring生态系统之上构建的API网关,包括: Spring 5,Spring Boot 2和Project Reactor。Spring cloud Gateway旨在提供一种简单而有效的方法来路由到API,并为它们提供跨领域的关注点,例如:安全性,监视/指标,限流等。由于Spring 5.0支持Netty,Http2,而Spring Boot 2.0支持 Spring 5.0,因此Spring Cloud Gateway支持 Netty和Http2顺理成章。
Nginx + Lua
一个高性能的 HTTP和反向代理服务器。Nginx一方面可以做反向代理,另外—方面做可以做静态资源服务器。 Nginx适合做门户网关,是作为整个全局的网关,对外的处于最外层的那种,而Gateway属于业务网关,主要用来对应不同的客户端提供服务,用于聚合业务。各个微服务独立部署,职责单一,对外提供服务的时候需要有一个东西把业务聚合起来。 Gateway 可以实现熔断、重试等功能,这是Nginx不具备的
工作原理
核心概念
路由(Route)
路由是网关最基础的部分,路由信息由ID、目标URl、一组断言和一组过滤器组成。如果断言路由为真,则说明请求的URI和配置匹配。
断言(Predicate)
Spring Cloud Gateway 中的断言函数允许开发者去定义匹配来自于Http Request中的任何信息,比加如请求头和参数等。
过滤器(Filter)
Spring Cloud Gateway 中的 Flter分为两种类型,分别是Gateway Filter和Global Filter。过滤器将会对清求和响应进行处理。
工作原理
子主题
1. 客户端向Spring Cloud Gateway发出请求。 2. 再由网关处理程序Gateway Handler Mapping 映射确定与请求相匹配的路由, 3. 将其发送到网关Web处理程序Gateway Web Handler。 4. 该处理程序通过指定的过滤嚣也将请求发送到我们实际的服务执行业务逻辑,然后返回。
过滤器由虚线分隔的原因是,过滤器可以在发送代理请求之前和之后运行逻辑。所有 pre过滤器逻辑均被执行。然后发出代理清求。发出代理清求后,将运行post过滤器逻辑, 在没有端口的路由中定义的 URI 分别为 HTTP 和 HTTPS URI 获得默认端口值 80 和 443。
工程实践
RoutePredicateFactory
使用RoutePredicateFactory 创建 Predicate对象,Predicate对象可以赋值给Route 1.所有这些断言都匹配 HTTP请求的不同属性。 2.多个Route Predicate Factories可以通过逻辑与(and)结合起来一起使用。 3.路由断言工厂RoutePredicateFactory包含的主要实现类如图所示,包括Datetime、请求的远程地址、路由权重、请求头、Http地址、请求方法、请求路径和请求参数等类型的路由断言。
路由断言工厂(路由规则)
After
AfterRoutePredicateFactory 表示匹配在指定日期时间之后发生的请求。
predicates: #断言,为真则匹配成功 # 表示匹配在指定日期时间之后发生的请求。(匹配亚洲上海时间 2021-11-29:17:42:47 以后的请求) - After=2021-11-29T17:42:47.000+08:00[Asia/Shanghai] - Path=/app1/** #配置规则Path,如果是app1开头的请求,则会将该请求转发到目标URL
Before
BeforeRoutePredicateFactory 匹配发生在指定日期时间之前的请求。
predicates: - Before=2017-01-20T17:42:47.789-07:00[America/Denver]
Between
BetweenRoutePredicateFactory 匹配发生在 datetime1之后和datetime2之前的请求。
predicates: - Between=2017-01-20T17:42:47.789-07:00[America/Denver], 2017-01-21T17:42:47.789-07:00[America/Denver]
Cookie
CookieRoutePredicateFactory 可以接收两个参数,一个是 Cookie 名称,一个是正则表达式,路由规则会通过获取对应的 Cookie 名称值和正则表达式去匹配,如果匹配上就会执行路由,如果没有匹配上则不执行。
- id: cookie_route uri: https://example.org predicates: - Cookie=chocolate, ch.p
Header
HeaderRoutePredicateFactory 和 Cooki一样,也是接收 2 个参数,一个 header 中属性名称和一个正则表达式,这个属性值和正则表达式匹配则执行。
routes: - id: header_route uri: https://example.org predicates: - Header=X-Request-Id, \d+
Host
HostRoutePredicateFactory 接收一组参数,一组匹配的域名列表。它通过参数中的主机地址作为匹配规则。
routes: - id: host_route uri: https://example.org predicates: - Host=**.somehost.org,**.anotherhost.org
Method
MethodRoutePredicateFactory 需要methods的参数,它是一个或多个参数,使用HTTP方法来匹配。
routes: - id: method_route uri: https://example.org predicates: - Method=GET,POST
如果请求方法是GET或POST,则此路由匹配。
Path
PathRoutePredicateFactory 接收一个匹配路径的参数。使用请求路径来匹配。
routes: - id: path_route uri: https://example.org predicates: - Path=/red/{segment},/blue/{segment}
如果请求路径是,例如:/red/1、 /red/1/、 /red/blueor、/blue/green ,则此路由匹配。
Query
QueryRoutePredicateFactory 接受两个参数,请求参数和可选的regexp(Java正则表达式)。
routes: - id: query_route uri: https://example.org predicates: - Query=green,gree.
RemoteAddr
RemoteAddrRoutePredicateFactory 通过请求 ip 地址进行匹配,
- id: remoteaddr_route uri: https://example.org predicates: - RemoteAddr=192.168.1.1/24
默认情况下,RemoteAddr使用来自传入请求的远程地址。如果 Spring Cloud Gateway 位于代理层之后,这可能与实际客户端 IP 地址不匹配。 您可以通过设置自定义RemoteAddressResolver带有一个非默认远程地址解析器。
Weight
WeightRoutePredicateFactory 有两个参数:group 和 weight(一个int数值)。权重是按组计算的。
routes: - id: weight_high uri: https://weighthigh.org predicates: - Weight=group1, 8 - id: weight_low uri: https://weightlow.org predicates: - Weight=group1, 2
该路由会将约 80% 的流量转发到weighthigh.org,将约 20% 的流量转发到weightlow.org。
Route类
Route 是 gateway 中最基本的组件之一,表示一个具体的路由信息载体。路由信息由ID、目标URl、一组断言和一组过滤器组成。如果断言路由为真,则说明请求的URI和配置匹配。
子主题
子主题
AsyncPredicate
AsyncPredicate是Routed的一个成员属性,用于条件匹配。
实现了 Java 8 提供的函数式接口Function<T,R> ,Function<T,R> 接口用来根据一个类型的数据得到另一个类型的数据,前者称为前置条件,后者称为后置条件。
AsyncPredicate 定义了 3 种逻辑操作方法:
HTTP请求-响应交互的契约 ServerWebExchange 接口
ServerWebExchange是一个HTTP请求-响应交互的契约。提供对HTTP请求和响应的访问,并公开额外的服务器端处理相关属性和特性,如请求属性 其实,ServerWebExchange命名为服务网络交换器,存放着重要的请求-响应属性、请求实例和响应实例等等,有点像Context的角色。
public interface ServerWebExchange { // 日志前缀属性的KEY,值为org.springframework.web.server.ServerWebExchange.LOG_ID // 可以理解为 attributes.set("org.springframework.web.server.ServerWebExchange.LOG_ID","日志前缀的具体值"); // 作用是打印日志的时候会拼接这个KEY对饮的前缀值,默认值为"" String LOG_ID_ATTRIBUTE = ServerWebExchange.class.getName() + ".LOG_ID"; String getLogPrefix(); // 获取ServerHttpRequest对象 ServerHttpRequest getRequest(); // 获取ServerHttpResponse对象 ServerHttpResponse getResponse(); // 返回当前exchange的请求属性,返回结果是一个可变的Map Map<String, Object> getAttributes(); // 根据KEY获取请求属性 @Nullable default <T> T getAttribute(String name) { return (T) getAttributes().get(name); } // 根据KEY获取请求属性,做了非空判断 @SuppressWarnings("unchecked") default <T> T getRequiredAttribute(String name) { T value = getAttribute(name); Assert.notNull(value, () -> "Required attribute '" + name + "' is missing"); return value; } // 根据KEY获取请求属性,需要提供默认值 @SuppressWarnings("unchecked") default <T> T getAttributeOrDefault(String name, T defaultValue) { return (T) getAttributes().getOrDefault(name, defaultValue); } // 返回当前请求的网络会话 Mono<WebSession> getSession(); // 返回当前请求的认证用户,如果存在的话 <T extends Principal> Mono<T> getPrincipal(); // 返回请求的表单数据或者一个空的Map,只有Content-Type为application/x-www-form-urlencoded的时候这个方法才会返回一个非空的Map -- 这个一般是表单数据提交用到 Mono<MultiValueMap<String, String>> getFormData(); // 返回multipart请求的part数据或者一个空的Map,只有Content-Type为multipart/form-data的时候这个方法才会返回一个非空的Map -- 这个一般是文件上传用到 Mono<MultiValueMap<String, Part>> getMultipartData(); // 返回Spring的上下文 @Nullable ApplicationContext getApplicationContext(); // 这几个方法和lastModified属性相关 boolean isNotModified(); boolean checkNotModified(Instant lastModified); boolean checkNotModified(String etag); boolean checkNotModified(@Nullable String etag, Instant lastModified); // URL转换 String transformUrl(String url); // URL转换映射 void addUrlTransformer(Function<String, String> transformer); // 注意这个方法,方法名是:改变,这个是修改ServerWebExchange属性的方法,返回的是一个Builder实例,Builder是ServerWebExchange的内部类 default Builder mutate() { return new DefaultServerWebExchangeBuilder(this); } interface Builder { // 覆盖ServerHttpRequest Builder request(Consumer<ServerHttpRequest.Builder> requestBuilderConsumer); Builder request(ServerHttpRequest request); // 覆盖ServerHttpResponse Builder response(ServerHttpResponse response); // 覆盖当前请求的认证用户 Builder principal(Mono<Principal> principalMono); // 构建新的ServerWebExchange实例 ServerWebExchange build(); } }
网关过滤器 GatewayFilter
GatewayFilter为网关过滤器,很多框架都有 Filter 的设计,用于实现可扩展的切面逻辑。
Filter 最终是通过 filter chain 来形成链式调用的,每个 filter 处理完 pre filter 逻辑后委派给 filter chain,filter chain 再委派给下一下 filter。
路由定位器 RouteLocator
是一个路由定位器,用来获取路由对象
RouteDefinitionRouteLocator 基于路由定义的定位器 CachingRouteLocator 基于缓存的路由定位器 CompositeRouteLocator 基于组合方式的路由定位器
路由定义定位器的顶级接口 RouteDefinitionLocator接口
主要作用就是读取路由的配置信息(org.springframework.cloud.gateway.route.RouteDefinition)。它有五种不同的实现类
接口有多个实现类: PropertiesRouteDefinitionLocator:基于属性配置 DiscoveryClientRouteDefinitionLocator:基于服务发现 CompositeRouteDefinitionLocator:组合方式 CachingRouteDefinitionLocator:缓存方式 其中还有一个接口 RouteDefinitionRepository 继承自RouteDefinitionLocator,用于对路由定义的操作(保存、删除路由定义),其只有一个默认的实现类InMemoryRouteDefinitionRepository
Route 信息进行定义 RouteDefinition 类
组件用来对 Route 信息进行定义,最终会被 RouteLocator 解析成 Route,其属性和Route类的差不多
初始化加载流程
1. 路由构建方式
2种 方式
外部化配置
routes: - id: app-service001 路由唯一ID uri: http://localhost:9000 目标URI, predicates: 断言,为真则匹配成功 - Path=/app1/** 配置规则Path,如果是app1开头的请求,则会将该请求转发到目标URL filters: - AddRequestHeader=X-Request-Foo, Bar 定义了一个 Filter,所有的请求转发至下游服务时会添加请求头 X-Request-Foo:Bar ,由AddRequestHeaderGatewayFilterFactory 来生产。
编程方式
@Bean public RouteLocator customerRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route(r -> r.path("/app1/**") .filters(f -> f.filter(new RequestLogGatewayFilter())) .uri("http://localhost:9000") ) .build(); }
2.加载配置
基于Spring Boot的自动装配功能,Gateway 模块自动装配类为 GatewayAutoConfiguration,对应的配置类为 GatewayProperties。 可以看到,GatewayAutoConfiguration中注入了很多个需要使用的Bean对象。
在yml中配置路由后,在加载的时候会被解析为RouteDefinition对象集合,每个RouteDefinition包含了Id、uri、 predicates、filters等。
3. 加载PropertiesRouteDefinitionLocator
PropertiesRouteDefinitionLocator是RouteDefinitionLocator,主要用来获取RouteDefinition(路由定义信息),
@Bean @ConditionalOnMissingBean public PropertiesRouteDefinitionLocator propertiesRouteDefinitionLocator(GatewayProperties properties) { return new PropertiesRouteDefinitionLocator(properties); }
4. 加载RouteDefinitionRouteLocator
RouteDefinitionRouteLocator也会在 GatewayAutoConfiguration类中被加载,注意不要和上面的RouteDefinitionLocator搞混了,名字很像: RouteDefinitionLocator :通过配置、JAVA代码、服务发现,加载路由为RouteDefinition。 RouteDefinitionRouteLocator:用于将 RouteDefinition 转换成 Route。
@Bean public RouteLocator routeDefinitionRouteLocator(GatewayProperties properties, List<GatewayFilterFactory> gatewayFilters, List<RoutePredicateFactory> predicates, RouteDefinitionLocator routeDefinitionLocator, ConfigurationService configurationService) { return new RouteDefinitionRouteLocator(routeDefinitionLocator, predicates, gatewayFilters, properties, configurationService); }
可以看到,使用RouteDefinitionRouteLocator构造函数,传入了GatewayProperties 、GatewayFilterFactory、RoutePredicateFactory、RouteDefinitionLocator 、ConfigurationService 。可以网关过滤器有28个,断言工厂有13个。
//其构造函数会初始化相关属性, public RouteDefinitionRouteLocator(RouteDefinitionLocator routeDefinitionLocator, List<RoutePredicateFactory> predicates, List<GatewayFilterFactory> gatewayFilterFactories, GatewayProperties gatewayProperties, ConfigurationService configurationService) { // 设置 RouteDefinitionLocator this.routeDefinitionLocator = routeDefinitionLocator; // 设置ConfigurationService this.configurationService = configurationService; // 初始化predicates,将所有的RoutePredicateFactory放入一个Map中,并会打印"Loaded RoutePredicateFactory + 前缀 日志 this.initFactories(predicates); // 初始化GatewayFilter,将所有的GatewayFilter放入一个Map中 gatewayFilterFactories.forEach((factory) -> { GatewayFilterFactory var10000 = (GatewayFilterFactory)this.gatewayFilterFactories.put(factory.name(), factory); }); // 设置 GatewayProperties this.gatewayProperties = gatewayProperties; }
5. 加载HandlerMapping、WebHandler
客户端向Spring Cloud Gateway发出请求。再由网关处理程序Gateway Handler Mapping 映射确定与请求相匹配的路由,将其发送到网关Web处理程序Gateway Web Handler。该处理程序通过指定的过滤嚣也将请求发送到我们实际的服务执行业务逻辑,然后返回。
@Bean public FilteringWebHandler filteringWebHandler(List<GlobalFilter> globalFilters) { return new FilteringWebHandler(globalFilters); } @Bean public RoutePredicateHandlerMapping routePredicateHandlerMapping(FilteringWebHandler webHandler, RouteLocator routeLocator, GlobalCorsProperties globalCorsProperties, Environment environment) { return new RoutePredicateHandlerMapping(webHandler, routeLocator, globalCorsProperties, environment); }
5. 启动服务
在所有的组件完成初始化后,服务就启动完成了,Web 程序监听端口,会接受到外部请求,然后通过核心控制器、处理映射器、web处理器、过滤器,然后到达目标地址,再一路返回,整个流程就结束了。
GatewayFilter网关过滤器详解
Filter分为两种类型,分别是Gateway Filter和Global Filter。过滤器将会对请求和响应进行处理.。比如添加参数、URL重写等。
默认网关过滤器
子主题
1. AddRequestHeader
需要name和value参数 示例表示将X-Request-red:blue消息头添加到所有匹配请求的下游请求消息头中。
routes: - id: add_request_header_route uri: https://example.org filters: - AddRequestHeader=X-Request-red, blue
2. AddRequestParameter
需要name和value参数。 表示将red=blue添加到下游请求参数中。
routes: - id: add_request_parameter_route uri: https://example.org filters: - AddRequestParameter=red, blue
3. AddResponseHeader
表示将X-Response-Foo:Bar添加到所有匹配请求的下游响应消息头中。
routes: - id: add_response_header_route uri: https://example.org filters: - AddResponseHeader=X-Response-Red, Blue
4. DedupeResponseHeader
剔除重复的响应头,接受一个name参数和一个可选strategy参数。name可以包含以空格分隔的标题名称列表。 如果网关 CORS 逻辑和下游逻辑都添加了响应头Access-Control-Allow-Credentials和Access-Control-Allow-Origin响应头的重复值,这将删除它们。 该DedupeResponseHeader过滤器还接受一个可选的strategy参数。接受的值为RETAIN_FIRST(默认)、RETAIN_LAST、 和RETAIN_UNIQUE。
routes: - id: dedupe_response_header_route uri: https://example.org filters: - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
5. CircuitBreaker
将网关路由包装在断路器中
Spring Cloud Circuit Breaker 支持多个可与 Spring Cloud Gateway 一起使用的库。Spring Cloud 支持开箱即用的 Resilience4J。
routes: - id: circuitbreaker_route uri: https://example.org filters: - CircuitBreaker=myCircuitBreaker
6. FallbackHeaders --添加异常信息
允许您在转发到fallbackUri外部应用程序中的请求的标头中添加 Spring Cloud Circuit Breaker 执行异常详细信息
routes: - id: ingredients uri: lb://ingredients predicates: - Path=//ingredients/** filters: - name: CircuitBreaker args: name: fetchIngredients fallbackUri: forward:/fallback - id: ingredients-fallback uri: http://localhost:9994 predicates: - Path=/fallback filters: - name: FallbackHeaders args: executionExceptionTypeHeaderName: Test-Header
在运行断路器时发生执行异常后,请求将转发到fallback运行于 localhost:9994上的应用程序中的端点或处理程序。带有异常类型、消息和(如果可用)根本原因异常类型和消息的标头由FallbackHeaders过滤器添加到该请求中。
7. MapRequestHeader --新的命名标头
采用fromHeader和toHeader参数。它创建一个新的命名标头 ( toHeader),并从传入的 http 请求中从现有命名标头 ( fromHeader) 中提取值。如果输入标头不存在,则过滤器没有影响。如果新命名的标头已存在,则其值将使用新值进行扩充。
routes: - id: map_request_header_route uri: https://example.org filters: - MapRequestHeader=Blue, X-Request-Red
以上配置表示这将X-Request-Red:< values>使用来自传入 HTTP 请求Blue标头的更新值向下游请求添加标头。
8. PrefixPath --前缀匹配
采用单个prefix参数。
routes: - id: prefixpath_route uri: https://example.org filters: - PrefixPath=/mypath
以上配置表示将/mypath作为所有匹配请求路径的前缀。因此,将向/hello 发送请求/mypath/hello。
9. PreserveHostHeader --检查请求属性
没有参数。此过滤器设置路由过滤器检查的请求属性,以确定是否应发送原始Host 消息头,而不是由 HTTP 客户端确定的Host 消息头。
routes: - id: preserve_host_route uri: https://example.org filters: - PreserveHostHeader
10. RequestRateLimiter --限流
RequestRateLimiter使用RateLimiter实现是否允许继续执行当前请求。如果不允许继续执行,则返回HTTP 429 - Too Many Requests (默认情况下)。用于限流,后续详解。
11. RedirectTo
需要两个参数,status和url。该status参数应该是300系列HTTP重定向代码,如301,url参数应该是一个有效的URL。这是消息头的Location值。
routes: - id: prefixpath_route uri: https://example.org filters: - RedirectTo=302, https://acme.org
以上配置表示,将设置302状态码,并添加Location为 https://acme.org的消息头。
12. RemoveRequestHeader --删除标头
需要一个name参数。它是要删除的请求消息头的名称。
routes: - id: removerequestheader_route uri: https://example.org filters: - RemoveRequestHeader=X-Request-Foo
这会在向下游发送之前删除标头X-Request-Foo。
13. RemoveResponseHeader --删除响应头
工厂需要一个name参数。它是要删除的响应消息头的名称。
routes: - id: removeresponseheader_route uri: https://example.org filters: - RemoveResponseHeader=X-Response-Foo
这将在响应返回到网关客户端之前从响应中删除标头X-Response-Foo。 要删除任何类型的敏感标头,您应该为您可能想要这样做的任何路由配置此过滤器。此外,您可以使用此过滤器配置一次spring.cloud.gateway.default-filters并将其应用于所有路由。
14. RemoveRequestParameter --删除请求参数
需要一个name参数。它是要删除的请求参数的名称。
routes: - id: removerequestparameter_route uri: https://example.org filters: - RemoveRequestParameter=red
这将在将参数发送到下游之前删除参数red。
15. RewritePath --重写路径
采用regexp参数和replacement参数。使用 Java 正则表达式来灵活地重写请求路径。
routes: - id: rewritepath_route uri: https://example.org predicates: - Path=/red/** filters: - RewritePath=/red/?(?<segment>.*), /$\{segment}
对于/red/blue的请求路径,这会在发出下游请求之前将路径设置为/blue。
SaveSession --保存会话
在向下游服务转发请求之前强制执行 WebSession::save操作。这在将Spring Session 之类的东西与惰性数据存储一起使用时特别有用,并且您需要确保在进行转发调用之前已保存会话状态。
routes: - id: save_session uri: https://example.org predicates: - Path=/foo/** filters: - SaveSession
SetPath --路径模板并修改
输入一个参数:template,匹配 Spring Framework URI 路径模板并修改,允许多个匹配
routes: - id: setpath_route uri: http://www.hxmec.com predicates: - Path=/foo/{segment} filters: - SetPath=/{ segment}
子主题
SetRequestHeader --重置请求头
setRequestHeader重置请求头的值,使用 name 和 value 参数接收值
routes: - id: setrequestheader_route uri: https://www.hxmec.com filters: - SetRequestHeader=X-Request-Foo, Bar
与AddRequestHeader不同的是,这是替换 Header 而不是添加
SetResponseHeader --替换响应头
routes: - id: setresponseheader_route uri: https://example.org filters: - SetResponseHeader=X-Response-Red, Blue
此GatewayFilter 替换(而不是添加)具有给定名称的所有标头。因此,如果下游服务器以X-Response-Red:1234 响应,则将其替换为X-Response-Red:Blue,这是网关客户端将收到的。
SetStatus --状态设置
采用单个参数,status。它必须是有效的 Spring Http Status。它可能是404枚举的整数值或字符串表示形式:NOT_FOUND。
routes: - id: setstatusstring_route uri: https://example.org filters: - SetStatus=BAD_REQUEST - id: setstatusint_route uri: https://example.org filters: - SetStatus=401
无论哪种情况,响应的 HTTP 状态都设置为 401。 您可以将 SetStatus 配置为从响应的标头中的代理请求返回原始 HTTP 状态代码。如果配置了以下属性,则将标头添加到响应中:
set-status: original-status-header-name: original-http-status
StripPrefix --剥离的路径中的部分数
有一个参数:parts。该parts参数指示在将请求发送到下游之前要从请求中剥离的路径中的部分数。
routes: - id: nameRoot uri: https://nameservice predicates: - Path=/name/** filters: - StripPrefix=2
当通过网关向/name/blue/red发出请求时,向 nameservice发出的请求看起来像nameservice/red.
Retry --重试请求
该过滤器用于重试请求,支持如下参数的配置: retries: 重试的次数 statuses: 应被重试的 HTTP Status Codes,参考 org.springframework.http.HttpStatus methods: 应被重试的 HTTP Methods,参考org.springframework.http.HttpMethod series: 应被重试的 Status Codes 系列,参考 org.springframework.http.HttpStatus.Series exceptions: 应被重试的异常列表 backoff: 为重试配置指数级的 backoff。重试时间间隔的计算公式为 firstBackoff * (factor ^ n),n 是重试的次数;如果设置了 maxBackoff,最大的 backoff 限制为 maxBackoff. 如果 basedOnPreviousValue 设置为 true, backoff 计算公式为 prevBackoff * factor. 如果Retry filter 启用,默认配置如下: retries — 3 times series — 5XX series methods — GET method exceptions — IOException and TimeoutException backoff — disabled
routes: - id: retry_test uri: http://localhost:8080/flakey predicates: - Host=*.retry.com filters: - name: Retry args: retries: 3 statuses: BAD_GATEWAY methods: GET,POST backoff: firstBackoff: 10ms maxBackoff: 50ms factor: 2 basedOnPreviousValue: false
当下游服务返回502状态码时,gateway会重试3次。 注意:当将重试过滤器与带有forward:前缀的 URL 一起使用时,应仔细编写目标端点,以便在发生错误的情况下,它不会做任何可能导致响应发送到客户端并提交的操作。 例如,如果目标端点是带注释的控制器,则目标控制器方法不应返回带有错误状态代码的 ResponseEntity。 相反,它应该引发 Exception 或发出错误信号(例如,通过Mono.error(ex)返回值),可以配置重试过滤器来进行重试处理。 警告:当将重试过滤器与任何带有 body 的 HTTP方法一起使用时,body 将被缓存,并且网关将受到内存的限制。 body 将缓存在 ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR定义的请求属性中,对象的类型是org.springframework.core.io.buffer.DataBuffer。
可以使用单个status和来添加简化的“快捷方式”符号method。
routes: - id: retry_route uri: https://example.org filters: - name: Retry args: retries: 3 statuses: INTERNAL_SERVER_ERROR methods: GET backoff: firstBackoff: 10ms maxBackoff: 50ms factor: 2 basedOnPreviousValue: false - id: retryshortcut_route uri: https://example.org filters: - Retry=3,INTERNAL_SERVER_ERROR,GET,10ms,50ms,2,false
RequestSize --请求大小限制
RequestSize 当请求大小大于允许的限制时,RequestSize可以限制请求到达下游服务。过滤器接受一个maxSize参数。可以被定义为一个数字,后跟一个可选的DataUnit后缀,例如“KB”或“MB”。字节的默认值为“Bit”。它是以字节为单位定义的请求的允许大小限制。
routes: - id: request_size_route uri: http://localhost:8080/upload predicates: - Path=/upload filters: - name: RequestSize args: maxSize: 5000000
如果未在路由定义中作为过滤器参数提供,则默认请求大小设置为 5 MB。
SetRequestHostHeader --覆盖消息头Host
在某些情况下,可能需要覆盖消息头Host。在这种情况下,SetRequestHostHeade可以用指定的值替换现有的Host。过滤器接受一个host参数。
routes: - id: set_request_host_header_route uri: http://localhost:8080/headers predicates: - Path=/headers filters: - name: SetRequestHostHeader args: host: example.org
该SetRequestHostHeade替换Host的值为example.org。
ModifyRequestBody --修改请求主体
修改请求主体,然后将其由网关向下游发送。只能使用 Java DSL 来配置此过滤器
@Bean public RouteLocator routes(RouteLocatorBuilder builder) { return builder.routes() .route("rewrite_request_obj", r -> r.host("*.rewriterequestobj.org") .filters(f -> f.prefixPath("/httpbin") .modifyRequestBody(String.class, Hello.class, MediaType.APPLICATION_JSON_VALUE, (exchange, s) -> return Mono.just(new Hello(s.toUpperCase())))).uri(uri)) .build(); } static class Hello { String message; public Hello() { } public Hello(String message) { this.message = message; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } }
如果请求没有正文,RewriteFilter则将通过null。Mono.empty()应该返回以在请求中分配缺失的主体。
Token Relay
是 OAuth2 消费者充当客户端并将传入令牌转发到传出资源请求的地方。消费者可以是纯客户端(如 SSO 应用程序)或资源服务器。 Spring Cloud Gateway 可以将 OAuth2 访问令牌下游转发到它正在代理的服务。要将此功能添加到网关,您需要添加 TokenRelayGatewayFilterFactory如下内容:
@Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route("resource", r -> r.path("/resource") .filters(f -> f.tokenRelay()) .uri("http://localhost:9000")) .build(); } 或 routes: - id: resource uri: http://localhost:9000 predicates: - Path=/resource filters: - TokenRelay=
并且它将(除了登录用户并获取令牌之外)将身份验证令牌下游传递给服务(在本例中 /resource)。 要为Spring Cloud Gateway 启用此功能,请添加以下依赖项 org.springframework.boot:spring-boot-starter-oauth2-client
Default Filters
要添加过滤器并将其应用于所有路由,您可以使用spring.cloud.gateway.default-filters. 此属性采用过滤器列表。以下清单定义了一组默认过滤器:
gateway: default-filters: - AddResponseHeader=X-Response-Default-Red, Default-Blue - PrefixPath=/httpbin
GlobalFilter全局过滤器详解
GlobalFilter是应用于所有路由的特殊过滤器。
当请求与路由匹配时,Web 处理程序会将所有的GlobalFilter和特定的GatewayFilter添加到过滤器链中。这个组合过滤器链是按org.springframework.core.Ordered接口排序的,也通过实现getOrder()方法来设置。
ForwardRoutingFilter
转发路由网关过滤器。其根据 forward:// 前缀( Scheme )过滤处理,将请求转发到当前网关实例本地接口。
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 获得 请求Url URI requestUrl = (URI)exchange.getRequiredAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR); // 获取协议 String scheme = requestUrl.getScheme(); if (!ServerWebExchangeUtils.isAlreadyRouted(exchange) && "forward".equals(scheme)) { if (log.isTraceEnabled()) { log.trace("Forwarding to URI: " + requestUrl); } // 如果是forward ,则DispatcherHandler 匹配并转发到当前网关实例本地接口 return this.getDispatcherHandler().handle(exchange); } else { return chain.filter(exchange); } }
ReactiveLoadBalancerClientFilter --服务发现,负载均衡
该过滤器,可以使用服务发现机制,即通过注册服务名去访问实际IP地址,多个相同服务时,还可以实现负载均衡。
ReactiveLoadBalancerClientFilter会在ServerWebExchange 查询gatewayRequestUrl 属性。如果 URL 是lb开头(例如lb://myservice),则它使用ReactorLoadBalancer将名称myservice解析为实际主机和端口,并替换同一属性中的 URI。 未修改的原始 URL 将附加到ServerWebExchangeUtils.GATEWAY_ORIGINAL_REQUEST_URL_ATTR属性中的列表中。过滤器还会查看ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR属性以查看它是否等于lb。如果是这样,则适用相同的规则。
routes: - id: myRoute uri: lb://service predicates: - Path=/service/**
默认情况下,当无法找到服务实例时LoadBalancer,将503返回。您可以通过设置将网关配置为返回 404 。Gateway 支持所有 LoadBalancer 功能。 spring.cloud.gateway.loadbalancer.use404=true。
NettyRoutingFilter
Netty 路由网关过滤器。其根据 http:// 或 https:// 前缀( Scheme )过滤处理,使用基于 Netty 实现的 HttpClient 请求后端 Http 服务。
首先获取请求的URL及前缀,判断前缀是不是http或者https,如果该请求已经被路由或者前缀不合法,则调用过滤器链直接向后传递;否则正常对头部进行过滤操作。 NettyRoutingFilter 过滤器的构造函数有三个参数: HttpClient httpClient : 基于 Netty 实现的 HttpClient,通过该属性请求后端 的 Http 服务 ObjectProvider headersFilters: ObjectProvider 类型 的 headersFilters,用于头部过滤 HttpClientProperties properties: Netty HttpClient 的配置属性
NettyWriteResponseFilter
如果有一个运行的NettyHttpClientResponse在ServerWebExchangeUtils.CLIENT_RESPONSE_ATTR属性。它在所有其他过滤器完成后运行,并将代理响应写回网关客户端响应。 NettyWriteResponseFilter与 NettyRoutingFilter成对使用的网关过滤器。其将 NettyRoutingFilter 请求后端 Http 服务的响应写回客户端。
RouteToRequestUrlFilter
这个过滤器用于将从request里获取的原始url转换成Gateway进行请求转发时所使用的url。 如果URL 具有 scheme 前缀,例如 lb:ws://serviceid ,该 lb scheme将从URL中剥离,并放到 ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR中,方便后面的过滤器使用。
WebsocketRoutingFilter
如果位于ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR交换属性中的 URL具有ws或wss方案,则 WebsocketRoutingFilter 路由过滤器运行。它使用 Spring Web Socket 基础结构向下游转发 web socket 请求。 您可以通过在 URI 前加上lb 前缀来对 websockets 进行负载平衡,例如lb:ws://serviceid.
如果你使用Sock JS作为普通 HTTP 的后备,你应该配置一个普通的 HTTP 路由以及 websocket 路由。 以下清单配置了一个 websocket 路由过滤器: routes: SockJS route - id: websocket_sockjs_route uri: http://localhost:3001 predicates: - Path=/websocket/info/** Normal Websocket route - id: websocket_route uri: ws://localhost:3001 predicates: - Path=/websocket/**
GatewayMetricsFilter
要启用网关指标,请将 spring-boot-starter-actuator 添加为项目依赖项。然后,默认情况下,只要属性spring.cloud.gateway.metrics.enabled未设置为false,网关指标过滤器就会运行。此过滤器添加了一个以spring.cloud.gateway.requests以下标签命名的计时器指标:
routeId: 路由ID。 routeUri:API 路由到的 URI。 outcome:结果,按HttpStatus.Series分类。 status:返回给客户端的请求的 HTTP 状态。 httpStatusCode:返回给客户端的请求的 HTTP 状态。 httpMethod:用于请求的 HTTP 方法。
此外,通过属性spring.cloud.gateway.metrics.tags.path.enabled(默认情况下,设置为 false),您可以使用标签激活额外的指标: path: 请求的路径。 然后可以从/actuator/metrics/spring.cloud.gateway.requests中抓取这些指标,并且可以轻松地与 Prometheus 集成以创建Grafana 仪表板。 要启用prometheus 端点,请添加micrometer-registry-prometheus为项目依赖项。
WebClientHttpRoutingFilter
WebClientHttpRoutingFilterHttp 路由网关过滤器。其根据 http:// 或 https:// 前缀( Scheme )过滤处理,使用基于 org.springframework.cloud.gateway.filter.WebClient实现的 HttpClient 请求后端 Http 服务。
自定义网关过滤器
方式1 继承AbstractGatewayFilterFactory
仿照默认的网关过滤器,实现一个简单打印请求路径和过滤器配置参数的功能。
routes: - id: app-service001 路由唯一ID uri: http://localhost:9000 目标URI, predicates: 断言,为真则匹配成功 匹配亚洲上海时间 2021-11-29:17:42:47 以后的请求 - After=2021-11-29T17:42:47.000+08:00[Asia/Shanghai] - Path=/app1/** 配置规则Path,如果是app1开头的请求,则会将该请求转发到目标URL filters: - RequestLog=config
方式2 实现GatewayFilter 接口
可以直接实现GatewayFilter 接口,编写过滤器逻辑处理。
但是这种方式需要用JAVA代码编写路由信息添加过滤器,所以不是很推荐使用
自定义全局过滤器
实现两个接口GlobalFilter,ordered
自定义全局过滤器需要实现以下两个接口:GlobalFilter,ordered。通过全局过滤器可以实现权限校验,安全性验证等功能。
需求
需求:对所有请求进行鉴权,校验消息头是否携带了 Spring Security Oauth2 访问令牌,没有则直接拦截,还需要对登录等接口进行放行处理。
代码
1. 编写过滤器
public class AuthGlobalFilter implements GlobalFilter, Ordered
package org.gateway.filter; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; import java.util.Map; /** * 实现权限校验,安全性验证等功能 */ @Component @Slf4j public class AuthGlobalFilter implements GlobalFilter, Ordered { @Autowired ObjectMapper objectMapper; // 放行路径,可以编写配置类,配置在YML中 private static final String[] SKIP_PATH = {"/app1/login", "/app1/skip/**"}; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 1. 判断是否放行路径 String requestPath = exchange.getRequest().getPath().pathWithinApplication().value(); boolean match = Arrays.stream(SKIP_PATH).map(path -> path.replaceAll("/\\*\\*", "")) .anyMatch(path -> path.startsWith(requestPath)); if (match) { return chain.filter(exchange); } // 2. 判断是否包含Oauth2 令牌 String authorizationHeader = exchange.getRequest().getHeaders().getFirst("Authorization"); if (StringUtils.isEmpty(authorizationHeader)) { // 如果消息头中没有 Authorization ,并且不是 Bearer开头,则抛出异常 ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().add("Content-Type", "application/json;charset=UTF-8"); String result = ""; try { Map<String, Object> map = new HashMap<>(16); map.put("code", HttpStatus.UNAUTHORIZED.value()); map.put("msg", "当前请求未认证,不允许访问"); map.put("data", null); result = objectMapper.writeValueAsString(map); } catch (JsonProcessingException e) { log.error(e.getMessage(), e); } DataBuffer buffer = response.bufferFactory().wrap(result.getBytes(StandardCharsets.UTF_8)); return response.writeWith(Flux.just(buffer)); } return chain.filter(exchange); } @Override public int getOrder() { return -1; } }
需求
需求:对所有请求进行鉴权,校验Get请求参数是否携带了恶意脚本,发现时,拒绝访问。
代码
添加一个全局过滤器,获取Get请求参数,然后正则匹配是否包含恶意脚本。
public class XssGlobalFilter implements GlobalFilter, Ordered
package org.gateway.filter; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.net.URI; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.regex.Pattern; /** * 防范XSS 攻击 */ @Slf4j @Component public class XssGlobalFilter implements GlobalFilter, Ordered { @Autowired ObjectMapper objectMapper; private final static Pattern[] scriptPatterns = { Pattern.compile("<script>(.*?)</script>", Pattern.CASE_INSENSITIVE), Pattern.compile("src[\r\n]*=[\r\n]*\\\'(.*?)\\\'", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL), Pattern.compile("</script>", Pattern.CASE_INSENSITIVE), Pattern.compile("<script(.*?)>", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL), Pattern.compile("eval\\((.*?)\\)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL), Pattern.compile("expression\\((.*?)\\)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL), Pattern.compile("javascript:", Pattern.CASE_INSENSITIVE), Pattern.compile("vbscript:", Pattern.CASE_INSENSITIVE), Pattern.compile("onload(.*?)=", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL) }; @SneakyThrows @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest serverHttpRequest = exchange.getRequest(); HttpMethod method = serverHttpRequest.getMethod(); URI uri = exchange.getRequest().getURI(); if (method == HttpMethod.GET) { String paramsString = uri.getRawQuery().replaceAll("[\u0000\n\r]", ""); if (StringUtils.isBlank(paramsString)) { return chain.filter(exchange); } String lowerValue = URLDecoder.decode(paramsString, "UTF-8").toLowerCase(); for (Pattern pattern : scriptPatterns) { if (pattern.matcher(lowerValue).find()) { return xssResponse(exchange); } } } return chain.filter(exchange); } @Override public int getOrder() { return 0; } public Mono<Void> xssResponse(ServerWebExchange exchange) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.BAD_REQUEST); response.getHeaders().add("Content-Type", "application/json;charset=UTF-8"); String result = ""; try { Map<String, Object> map = new HashMap<>(16); map.put("code", HttpStatus.BAD_REQUEST.value()); map.put("msg", "当前请求可能存在恶意脚本,拒绝访问"); result = objectMapper.writeValueAsString(map); } catch (JsonProcessingException e) { log.error(e.getMessage(), e); } DataBuffer buffer = response.bufferFactory().wrap(result.getBytes(StandardCharsets.UTF_8)); return response.writeWith(Flux.just(buffer)); } }
http://localhost/app1/test?name=%3Cscript%3Ealert(document.cookie);%3C/script%3E
基于注册中心Nacos的动态路由案例及加载执行流程源码解析
Spring Cloud Gateway支持与Eureka、Nacos、Consul等整合开发,根据service ld自动从注册中心获取服务地址并转发请求,这样做的好处不仅可以通过单个端点来访问应用的所有服务,而且在添加或移除服务实例时不用修改Gateway的路由配置。
配置
routes: - id: app-service001 #路由唯一ID # uri: http://localhost:9001 #目标URI #通过服务的注册名来访问服务,例:lb://my-service 指定了通过负载均衡器访问服务名为 my-service 的服务。 uri: lb://app-service001 predicates: #断言,为真则匹配成功 # 表示匹配在指定日期时间之后发生的请求。(匹配亚洲上海时间 2021-11-29:17:42:47 以后的请求) # - After=2021-11-29T17:42:47.000+08:00[Asia/Shanghai] - Path=/app1/** #配置规则Path,如果是app1开头的请、求,则会将该请求转发到目标URL - Path=/app-service001/** #配置规则Path,如果是app-service001开头的请、求,则会将该请求转发到目标URL filters: # 定义了一个 Filter,所有的请求转发至下游服务时会添加请求头 X-Request-Foo:Bar , #由AddRequestHeaderGatewayFilterFactory 来生产。 # - AddRequestHeader=X-Request-Foo, Bar # - RequestLog=config
# 网关地址/服务注册名/目标请求路径 http://localhost/app-service001/app1/test
执行流程
1. 动态加载路由
1.1 路由信息加载
DiscoveryClientRouteDefinitionLocator类 在之前的案例中,我们分析过,路由信息会被加载到RouteDefinitionLocator中,YML配置的路由是PropertiesRouteDefinitionLocator来加载的。 基于服务发现时,使用的是DiscoveryClientRouteDefinitionLocator
1.2 初始化DiscoveryClientRouteDefinitionLocator
public DiscoveryClientRouteDefinitionLocator(ReactiveDiscoveryClient discoveryClient, DiscoveryLocatorProperties properties) { // 1. 属性初始化,routeIdPrefix、SimpleEvaluationContext this(discoveryClient.getClass().getSimpleName(), properties); // 2. 调用服务发现客户端,查询动态的路由信息。 this.serviceInstances = discoveryClient.getServices().flatMap((service) -> { return discoveryClient.getInstances(service).collectList(); }); }
构造函数需要ReactiveDiscoveryClient和DiscoveryLocatorProperties参数,ReactiveDiscoveryClient是一个服务发现客户端,是Spring cloud提供的协议接口,因为我们引入的是Nacos,所以这里的客户端为NacosReactiveDiscoveryClient。
1.3 获取RouteDefinitions
DiscoveryClientRouteDefinitionLocator通过调用 DiscoveryClient 获取在注册中心的服务列表,生成对应的 RouteDefinition 数组,调用的是getRouteDefinitions方法,最终在注册中心的服务,就被网关获取到路由信息了。
2. 执行过滤器
2.1 路由信息
获取到动态路由后,就是方法执行了。 首先可以看到,动态路由中,有一个Path类型的断言,其值为/app-service001/**,那么只要是访问路径以注册服务名开头,则就会匹配这个路由。
2.2 进入网关过滤器RewritePathGatewayFilterFactory
请求经过Web 处理=》断言通过以后,就到达了RewritePathGatewayFilterFactory网关过滤器中,主要是去掉访问路径的服务名,并设置到Request中。
ServerHttpRequest req = exchange.getRequest(); // ServerWebExchange添加属性,gatewayOriginalRequestUrl=》http://localhost/app-service001/app1/test ServerWebExchangeUtils.addOriginalRequestUrl(exchange, req.getURI()); // 原始路径=》/app-service001/app1/test String path = req.getURI().getRawPath(); // 新路径=》/app1/test String newPath = path.replaceAll(config.regexp, replacement); ServerHttpRequest request = req.mutate().path(newPath).build(); // 将重写后的的URL(http://localhost/app1/test),写到ServerWebExchange的gatewayRequestUrl属性中。 exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, request.getURI()); return chain.filter(exchange.mutate().request(request).build());
2.3 进入全局过滤器 ReactiveLoadBalancerClientFilter
ReactiveLoadBalancerClientFilter通过LoadBalancer 负载均衡器去获取当前服务可用的实际IP地址等信息,然后调用。
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // lb://app-service001/app1/test URI url = (URI)exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR); // lb String schemePrefix = (String)exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR); if (url != null && ("lb".equals(url.getScheme()) || "lb".equals(schemePrefix))) { ServerWebExchangeUtils.addOriginalRequestUrl(exchange, url); if (log.isTraceEnabled()) { log.trace(ReactiveLoadBalancerClientFilter.class.getSimpleName() + " url before: " + url); } // lb://app-service001/app1/test URI requestUri = (URI)exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR); // 服务名=》app-service001 String serviceId = requestUri.getHost(); Set<LoadBalancerLifecycle> supportedLifecycleProcessors = LoadBalancerLifecycleValidator.getSupportedLifecycleProcessors(this.clientFactory.getInstances(serviceId, LoadBalancerLifecycle.class), RequestDataContext.class, ResponseData.class, ServiceInstance.class); // 创建客户端请求对象 DefaultRequest<RequestDataContext> lbRequest = new DefaultRequest(new RequestDataContext(new RequestData(exchange.getRequest()), this.getHint(serviceId, this.loadBalancerProperties.getHint()))); return this.choose(lbRequest, serviceId, supportedLifecycleProcessors).doOnNext((response) -> { if (!response.hasServer()) { supportedLifecycleProcessors.forEach((lifecycle) -> { lifecycle.onComplete(new CompletionContext(Status.DISCARD, lbRequest, response)); }); throw NotFoundException.create(this.properties.isUse404(), "Unable to find instance for " + url.getHost()); } else { // 获取到可用服务的实际IP 地址和端口 ServiceInstance retrievedInstance = (ServiceInstance)response.getServer(); // 访问路径 http://localhost/app1/test URI uri = exchange.getRequest().getURI(); // 请求协议 HTTP String overrideScheme = retrievedInstance.isSecure() ? "https" : "http"; if (schemePrefix != null) { overrideScheme = url.getScheme(); } DelegatingServiceInstance serviceInstance = new DelegatingServiceInstance(retrievedInstance, overrideScheme); // 实际访问地址=》http://192.168.58.1:9000/app1/test URI requestUrl = this.reconstructURI(serviceInstance, uri); if (log.isTraceEnabled()) { log.trace("LoadBalancerClientFilter url chosen: " + requestUrl); } // 将实际的访问地址塞到请求对象中,供后续过滤器去调用访问。 exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, requestUrl); exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_LOADBALANCER_RESPONSE_ATTR, response); supportedLifecycleProcessors.forEach((lifecycle) -> { lifecycle.onStartRequest(lbRequest, response); }); } }).then(chain.filter(exchange)).doOnError((throwable) -> { supportedLifecycleProcessors.forEach((lifecycle) -> { lifecycle.onComplete(new CompletionContext(Status.FAILED, throwable, lbRequest, (Response)exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_LOADBALANCER_RESPONSE_ATTR))); }); }).doOnSuccess((aVoid) -> { supportedLifecycleProcessors.forEach((lifecycle) -> { lifecycle.onComplete(new CompletionContext(Status.SUCCESS, lbRequest, (Response)exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_LOADBALANCER_RESPONSE_ATTR), new ResponseData(exchange.getResponse(), new RequestData(exchange.getRequest())))); }); }); } else { return chain.filter(exchange); } }
最终,ReactiveLoadBalancerClientFilter完成了动态服务名到实际访问地址的转换,再经过其他过滤器去调动,获取到响应,最终返回到网关,再响应给页面,整个流程就结束了。
Sign授权签名认证-拦截器
描述
从接口的使用范围也可以分为对内和对外两种 1. 对内的接口一般不需要签名 2. 对于对外的接口,要保证正确性、合法性、时效性等
鉴权有很多方案,如:SpringSecurity、Shiro、拦截器、过滤器等等。如果只是对一些URL进行认证鉴权的话,我们完全没必要引入SpringSecurity或Shiro等框架,使用拦截器或过滤器就足以实现需求。本文介绍如何使用过滤器Filter实现URL签名认证鉴权。 方案一:Spring Boot+Aop+注解实现Api接口签名验证 方案二:在已有接口上,拦截器拦截,接口路径,白名单匹配
考虑几点
1. 保证请求数据正确性
当请求中的某一个字段的值变化时,原有的签名结果就会发生变化
JSONObject signObj = new JSONObject(); signObj.put("appid", appid); signObj.put("timestamp", timestamp); signObj.put("nonce", nonce); String mySign = getSign(signObj, apiKey.getSecret()); // 验证签名 if (!mySign.equals(sign)) { return ObjectResponse.fail("签名信息错误"); } /** * 获取签名信息 * @param data * @param secret * @return */ private static String getSign(JSONObject data, String secret) { // 由于map是无序的,这里主要是对key进行排序(字典序) Set<String> keySet = data.keySet(); String[] keyArr = keySet.toArray(new String[keySet.size()]); Arrays.sort(keyArr); StringBuilder sbd = new StringBuilder(); for (String k : keyArr) { if (StringUtil.isNotEmpty(data.getString(k))) { sbd.append(k + "=" + data.getString(k) + "&"); } } // secret最后拼接 sbd.append("secret=").append(secret); return MD5Util.encode(sbd.toString()); } 5.基于SringBoot以及Redis使用Aop来实现Api接口签名验证的源码 @Component @Aspect @Slf4j public class ThridPartyApiAspect { @Autowired private HttpServletRequest request; @Autowired private HttpServletResponse response; @Autowired private RedisService redisService; @Autowired private CoreApiKeyService coreApiKeyService; /** * 表示匹配带有自定义注解的方法 */ @Pointcut("@annotation(com.stan.framework.anno.ThridPartyApi)") public void pointcut() { } @Around("pointcut()") public Object around(ProceedingJoinPoint point) { try { // 供应商的id,验证用户的真实性 String appid = request.getHeader("appid"); // 请求发起的时间 String timestamp = request.getHeader("timestamp"); // 随机数 String nonce = request.getHeader("nonce"); // 签名算法生成的签名 String sign = request.getHeader("sign"); if (StringUtil.isEmpty(appid) || StringUtil.isEmpty(timestamp) || StringUtil.isEmpty(nonce) || StringUtil.isEmpty(sign)) { return ObjectResponse.fail("请求头参数不能为空"); } // 限制为(含)60秒以内发送的请求 long time = 60; long now = System.currentTimeMillis() / 1000; if (now - Long.valueOf(timestamp) > time) { return ObjectResponse.fail("请求发起时间超过服务器限制时间"); } // 查询appid是否正确 CoreApiKey apiKey = coreApiKeyService.selectByAppid(appid); if (apiKey == null) { return ObjectResponse.fail("appid参数错误"); } // 验证请求是否重复 if (redisService.hasKeyHashItem("third_party_key", apiKey.getAppid() + nonce)) { return ObjectResponse.fail("请不要发送重复的请求"); } else { // 如果nonce没有存在缓存中,则加入,并设置失效时间(秒) redisService.setHashItem("third_party_key", apiKey.getAppid() + nonce, nonce, time); } JSONObject signObj = new JSONObject(); signObj.put("appid", appid); signObj.put("timestamp", timestamp); signObj.put("nonce", nonce); String mySign = getSign(signObj, apiKey.getSecret()); // 验证签名 if (!mySign.equals(sign)) { return ObjectResponse.fail("签名信息错误"); } try { return point.proceed(); } catch (Throwable throwable) { throwable.printStackTrace(); } } catch (Exception e) { e.printStackTrace(); return ObjectResponse.fail("解析请求参数异常"); } return null; } /** * 获取签名信息 * @param data * @param secret * @return */ private static String getSign(JSONObject data, String secret) { // 由于map是无序的,这里主要是对key进行排序(字典序) Set<String> keySet = data.keySet(); String[] keyArr = keySet.toArray(new String[keySet.size()]); Arrays.sort(keyArr); StringBuilder sbd = new StringBuilder(); for (String k : keyArr) { if (StringUtil.isNotEmpty(data.getString(k))) { sbd.append(k + "=" + data.getString(k) + "&"); } } // secret最后拼接 sbd.append("secret=").append(secret); return MD5Util.encode(sbd.toString()); } } 6. 测试签名 /** * @Author lc * @description: * @Date 2022/4/26 15:19 * @Version 1.0 */ @RestController @Api(tags = "对外接口") @RequestMapping("/test") public class ThirdPartyApiAspectController { @ThirdPartyApi @ApiOperation("对接接口测试") @PostMapping(value = "/test") public ResponseVO<String> test(HttpServletRequest request){ return new ResponseVO ("签名校验"); } }
2. 保证请求来源合法
一般情况下,生成签名的算法都会成对出现一个 appKey 和一个 appSecret,根据 appKey 能识别出调用者身份;根据 appSecret 能识别出签名是否合法。
3. 识别接口的时效性
一般情况下,签名和参数中会包含时间戳,这样服务端就可以验证客户端请求是否在有效时间内,从而避免接口被长时间的重复调用
4. 是否存在重复请求
根据随机数验证,可以在后端放redis中,并设置过期时间
// 验证请求是否重复 if (redisService.hasKeyHashItem("third_party_key", apiKey.getAppid() + nonce)) { return ObjectResponse.fail("请不要发送重复的请求"); } else { // 如果nonce没有存在缓存中,则加入,并设置失效时间(秒) redisService.setHashItem("third_party_key", apiKey.getAppid() + nonce, nonce, time); }
参数说明
应用:appId(用于查询秘钥) 应用秘钥:appSecret(两端要保密) 时间戳:timestamp(需要后端校验时间时效) 随机字符串:noncestr(保证碓一性,防重复) 签名字符串:signature(MD5生成)
appId代表合法性 签名保证数据正确 随机数防止重复请求 时间戳代表时效性
客户端使用说明
前端调用
1.客户端可以为前后端分离的vue或者react。可以为:Android、iOS或者uniapp等移动端应用,都可以采用认证签名授权访问接口
第三方调用
2. 我这边主要的业务是给第三方应用提供对接接口,因为三方不需要登录,但是又不能随便让他们请求接口,就采用签名授权的方式对外公布我们的接口。
客户端生成签名
生成规则 signature = Md5(timestamp+noncestr+appSecret+body).toUpper() 时间戳+随机字符串+应用授权秘钥+请求body实体
传递参数说明
例如请求地址: http://127.0.0.1:8080/qingfeng/port/findInfo 请求header:Authorization = appId="8qj0y778",timestamp="9897969594",signature=" 9E26BB3F746DCD982435221AA03DA400",noncestr=xxx 请求实体:{"name":"张三","type":"1"}
接口签名验签实现机制
申请秘钥
1. 申请appId、appSecret 2. 下发appId、appSecret
服务器解密
服务端通过使用Filter实现签名认证鉴权,服务端获取客户传递的timestamp+noncestr+body生成签名,然后比对传递的签名是否一致。
解密验证过程
1. 解析参数
2. 校验时间是否在1分钟内
3.根据参数appId查询约定密钥
4.重新生成MD5签名
参数:时间戳&随机字符串&秘钥&请求实体
5. 生成的MD5签名与参数签名对比
签名概念
请求参数
access_key(用户身份标识)、 nonce(随机数)、 timestamp(时间戳)、 业务参数列表...、 sign签名
appId(access_key):
用户身份标识,前后端约定
nonce:
随机字符串,请求唯一标识,每个请求的nonce均被缓存在服务端(redis),并设置10分钟有效期,防止缓存不断累积
timestamp:
当前时间戳,用于判断请求与当前时间差,与nonce对应,设置10分钟有效期,超过时间请求失效,既防止请求长期有效又允许客户端和服务端之间存在10分钟时间差
secret_key: --密钥,前后端约定,保密
密钥,用于生成sign签名,前后端约定,不能泄漏
签名
md5加密 access_key+nonce+timestamp+业务参数列表+secret_key,业务参数列表按key自然排序,上各参数以&拼接后进行md5加密,md5转为大写作为sign签名
服务端
拦截请求(拦截器或AOP均可,配合注解拦截指定接口,便于区分拦截不同接口),根据参数重构md5签名,依次检查access_key、nonce、timestamp、sign的一致性和准确性
有效期
将timestamp有效期设置在2~3分钟
限流
限流算法
计数器(固定窗口)算法
计数器算法是使用计数器在周期内累加访问次数,当达到设定的限流值时,触发限流策略。下一个周期开始时,进行清零,重新计数。 此算法在单机还是分布式环境下实现都非常简单,使用redis的incr原子自增性和线程安全即可轻松实现。
滑动窗口算法
滑动窗口算法是将时间周期分为N个小周期,分别记录每个小周期内访问次数,并且根据时间滑动删除过期的小周期。 如图,假设时间周期为1min,将1min再分为2个小周期,统计每个小周期的访问数量,则可以看到,第一个时间周期内,访问数量为75,第二个时间周期内,访问数量为100,超过100的访问则被限流掉了。
漏桶算法
漏桶算法是访问请求到达时直接放入漏桶,如当前容量已达到上限(限流值),则进行丢弃(触发限流策略)。漏桶以固定的速率进行释放访问请求(即请求通过),直到漏桶为空。
令牌桶算法
令牌桶算法是程序以r(r=时间周期/限流值)的速度向令牌桶中增加令牌,直到令牌桶满,请求到达时向令牌桶请求令牌,如获取到令牌则通过请求,否则触发限流策略
注
网关层集群限流+内部服务的单机限流兜底,这样才能保证不被流量冲垮。
熔断
当前调用服务出现异常、超时等情况,可考虑熔断
#hystrix配置,信号量隔离,5秒后自动超时 hystrix: command: default: execution: isolation: strategy: SEMAPHORE # 隔离策略,默认是线程池隔离 thread: timeoutInMilliseconds: 60000 # 超时时间,单位:毫秒 fallbackCmd: execution: isolation: strategy: SEMAPHORE thread: #5秒没响应自动熔断 跳转到配置的fallbackUri timeoutInMilliseconds: 5000 circuitBreaker: #熔断器配置 enabled: true # requestVolumeThreshold: 20 # 熔断器开启的最小请求数 errorThresholdPercentage: 50 # 错误率达到50%熔断器开启 sleepWindowInMilliseconds: 5000 # 熔断器休眠时间 resetTimeInMilliseconds: 30000 # 熔断器重置时间
nginx
描述
默认监听80端口
master-worker模式
worker
读取并验证配置文件nginx.conf;管理worker进程;
命令
改配置:执行命令 nginx -s reload 关闭:nginx -s stop (快速停止nginx) nginx -s quit (完整有序的停止nginx)
负载均衡
单个服务器解决不了,我们增加服务器的数量,然后将请求分发到各个服务器上,将原先请求集中到单个服务器上的情况改为将请求分发到多个服务器上,将负载均衡分发到不同的服务器
多种负载均衡方式
轮询法
每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器 down 掉,能 自动剔除
权重模式
指定轮询几率,weight 和 访问比率成正比,用于后端服务器性能不均的情况。
ip_hash
如果客户已经访问了某个服务器,当用户再次访问时,会将该请求通过哈希算法,自动定位到该服务器。每个请求按访问 ip 的 hash 结果分配,这样每个访客固定访问一个后端服务器,可以解决 session 的问题
动静分离
有些请求是需要后台处理的,有些请求是不需要经过后台处理的(如:css、html、jpg、 js 等文件),这些不需要经过后台处理的文件称为 静态文件。可以根据 静态资源 的特点将其做缓存操作,以提高资源的响应速度。
设计模式
设计原则与思想
面向对象
封装、抽象、继承、多态分别解决了哪些编程问题
封装(Encapsulation)
封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据 对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是访问权限控制。例子中的 private、public 等关键字就是 Java 语言中的访问权限控制语法
抽象(Abstraction)
封装主要讲的是如何隐藏信息、保护数据,而抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。
常借助编程语言提供的接口类(比如 Java 中的 interface 关键字语法)或者抽象类(比如 Java 中的 abstract 关键字语法)这两种语法机制,来实现抽象这一特性。 实际上,抽象这个特性是非常容易实现的,并不需要非得依靠接口类或者抽象类这些特殊语法机制来支持。换句话说,并不是说一定要为实现类(PictureStorage)抽象出接口类(IPictureStorage),才叫作抽象。即便不编写 IPictureStorage 接口类,单纯的 PictureStorage 类本身就满足抽象特性。
public interface IPictureStorage { void savePicture(Picture picture); Image getPicture(String pictureId); void deletePicture(String pictureId); void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo); } public class PictureStorage implements IPictureStorage { // ...省略其他属性... @Override public void savePicture(Picture picture) { ... } @Override public Image getPicture(String pictureId) { ... } @Override public void deletePicture(String pictureId) { ... } @Override public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... } }
抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构设计等。而且这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性、所以,它没有很强的“特异性”,有时候并不被看作面向对象编程的特性之一。
意义
抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。 抽象作为一个非常宽泛的设计思想,在代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等
继承(Inheritance)
继承是用来表示类之间的 is-a 关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。
意义
代码复用
两个子类就可以重用父类中的代码,避免代码重复写多遍
多态(Polymorphism)
多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现
对于多态特性的实现方式,除了利用“继承加方法重写”这种实现方式之外,我们还有其他两种比较常见的的实现方式,一个是利用接口类语法,另一个是利用 duck-typing 语法。不过,并不是每种编程语言都支持接口类或者 duck-typing 这两种语法机制
意义
多态特性能提高代码的可扩展性和复用性
面向对象相比面向过程有哪些优势?面向过程真的过时了吗?
什么是面向过程编程与面向过程编程语言?
面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
面向过程编程也是一种编程范式或编程风格。它以过程(可以理解为方法、函数、操作)作为组织代码的基本单元,以数据(可以理解为成员变量、属性)与方法相分离为最主要的特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。 面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(比如继承、多态、封装),仅支持面向过程编程。
面向过程和面向对象最基本的区别就是,代码的组织方式不同。面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中。
面向对象编程相比面向过程编程有哪些优势?
1.OOP 更加能够应对大规模复杂程序的开发
但对于大规模复杂程序的开发来说,整个程序的处理流程错综复杂,并非只有一条主线。如果把整个程序的处理流程画出来的话,会是一个网状结构。如果我们再用面向过程编程这种流程化、线性的思维方式,去翻译这个网状结构,去思考如何把程序拆解为一组顺序执行的方法,就会比较吃力。这个时候,面向对象的编程风格的优势就比较明显了
面向对象编程是以类为思考对象。在进行面向对象编程的时候,我们并不是一上来就去思考,如何将复杂的流程拆解为一个一个方法,而是采用曲线救国的策略,先去思考如何给业务建模,如何将需求翻译为类,如何给类之间建立交互关系,而完成这些工作完全不需要考虑错综复杂的处理流程。当我们有了类的设计之后,然后再像搭积木一样,按照处理流程,将类组装起来形成整个程序。这种开发模式、思考问题的方式,能让我们在应对复杂程序开发的时候,思路更加清晰。
2.OOP 风格的代码更易复用、易扩展、易维护
封装特性 是面向对象编程相比于面向过程编程的一个最基本的区别,因为它基于的是面向对象编程中最基本的类的概念。面向对象编程通过类这种组织代码的方式,将数据和方法绑定在一起,通过访问权限控制,只允许外部调用者通过类暴露的有限方法访问数据,而不会像面向过程编程那样,数据可以被任意方法随意修改。因此,面向对象编程提供的封装特性更有利于提高代码的易维护性。
抽象特性 面向对象编程还提供了其他抽象特性的实现方式。这些实现方式是面向过程编程所不具备的,比如基于接口实现的抽象。基于接口的抽象,可以让我们在不改变原有实现的情况下,轻松替换新的实现逻辑,提高了代码的可扩展性。
继承特性 是面向对象编程相比于面向过程编程所特有的两个特性之一(另一个是多态)。如果两个类有一些相同的属性和方法,我们就可以将这些相同的代码,抽取到父类中,让两个子类继承父类。这样两个子类也就可以重用父类中的代码,避免了代码重复写多遍,提高了代码的复用性。
多态特性。 基于这个特性,我们在需要修改一个功能实现的时候,可以通过实现一个新的子类的方式,在子类中重写原来的功能逻辑,用子类替换父类。在实际的代码运行过程中,调用子类新的功能逻辑,而不是在原有代码上做修改。这就遵从了“对修改关闭、对扩展开放”的设计原则,提高代码的扩展性。除此之外,利用多态特性,不同的类对象可以传递给相同的方法,执行不同的代码逻辑,提高了代码的复用性。
3.OOP 语言更加人性化、更加高级、更加智能
跟二进制指令、汇编语言、面向过程编程语言相比,面向对象编程语言的编程套路、思考问题的方式,是完全不一样的。前三者是一种计算机思维方式,而面向对象是一种人类的思维方式。我们在用前面三种语言编程的时候,我们是在思考,如何设计一组指令,告诉机器去执行这组指令,操作某些数据,帮我们完成某个任务。而在进行面向对象编程时候,我们是在思考,如何给业务建模,如何将真实的世界映射为类或者对象,这让我们更加能聚焦到业务本身,而不是思考如何跟机器打交道。可以这么说,越高级的编程语言离机器越“远”,离我们人类越“近”,越“智能”。
总结
对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发。 面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。 从编程语言跟机器打交道的方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。
精彩评论
使用任何一个编程语言编写的程序,最终执行上都要落实到CPU一条一条指令的执行(无论通过虚拟机解释执行,还是直接编译为机器码),CPU看不到是使用何种语言编写的程序。对于所有编程语言最终目的是两种:提高硬件的运行效率和提高程序员的开发效率。然而这两种很难兼得。 C语言在效率方面几乎做到了极致,它更适合挖掘硬件的价值,如:C语言用数组char a[8],经过编译以后变成了(基地址+偏移量)的方式。对于CPU来说,没有运算比加法更快,它的执行效率的算法复杂度是O(1)的。从执行效率这个方面看,开发操作系统和贴近硬件的底层程序,C语言是极好的选择。 C语言带来的问题是内存越界、野指针、内存泄露等。它只关心程序飞的高不高,不关心程序猿飞的累不累。为了解脱程序员,提高开发效率,设计了OOP等更“智能”的编程语言,但是开发容易毕竟来源于对底层的一层一层又一层的包装。完成一个特定操作有了更多的中间环节, 占用了更大的内存空间, 占用了更多的CPU运算。从这个角度看,OOP这种高级语言的流行是因为硬件越来越便宜了。我们可以想象如果大众消费级的主控芯片仍然是单核600MHz为主流,运行Android系统点击一个界面需要2秒才能响应,那我们现在用的大部分手机程序绝对不是使用JAVA开发的,Android操作系统也不可能建立起这么大的生态。
哪些代码设计看似是面向对象,实际是面向过程的?
哪些代码设计看似是面向对象,实际是面向过程的?
1. 滥用 getter、setter 方法
它违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。
public class ShoppingCart { private int itemsCount; private double totalPrice; private List<ShoppingCartItem> items = new ArrayList<>(); public int getItemsCount() { return this.itemsCount; } public void setItemsCount(int itemsCount) { this.itemsCount = itemsCount; } public double getTotalPrice() { return this.totalPrice; } public void setTotalPrice(double totalPrice) { this.totalPrice = totalPrice; } public List<ShoppingCartItem> getItems() { return this.items; } public void addItem(ShoppingCartItem item) { items.add(item); itemsCount++; totalPrice += item.getPrice(); } // ...省略其他方法... }
而面向对象封装的定义是:通过访问权限控制,隐藏内部数据,外部仅能通过类提供的有限的接口访问、修改内部数据。所以,暴露不应该暴露的 setter 方法,明显违反了面向对象的封装特性。数据没有访问权限控制,任何代码都可以随意修改它,代码就退化成了面向过程编程风格的了。
总结一下,在设计实现类的时候,除非真的需要,否则,尽量不要给属性定义 setter 方法。除此之外,尽管 getter 方法相对 setter 方法要安全些,但是如果返回的是集合容器(比如例子中的 List 容器),也要防范集合内部数据被修改的危险。
2. 滥用全局变量和全局方法
在面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。单例类对象在全局代码中只有一份,所以,它相当于一个全局变量。静态成员变量归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量。而常量是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到一个 Constants 类中。静态方法一般用来操作静态变量或者外部数据。你可以联想一下我们常用的各种 Utils 类,里面的方法一般都会定义成静态方法,可以在不用创建对象的情况下,直接拿来使用。静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格。
例
public class Constants { public static final String MYSQL_ADDR_KEY = "mysql_addr"; public static final String MYSQL_DB_NAME_KEY = "db_name"; public static final String MYSQL_USERNAME_KEY = "mysql_username"; public static final String MYSQL_PASSWORD_KEY = "mysql_password"; public static final String REDIS_DEFAULT_ADDR = "192.168.7.2:7234"; public static final int REDIS_DEFAULT_MAX_TOTAL = 50; public static final int REDIS_DEFAULT_MAX_IDLE = 50; public static final int REDIS_DEFAULT_MIN_IDLE = 20; public static final String REDIS_DEFAULT_KEY_PREFIX = "rt:"; // ...省略更多的常量定义... }
定义一个如此大而全的 Constants 类,并不是一种很好的设计思路。为什么这么说呢?原因主要有几点。
这样的设计会影响代码的可维护性。
如果参与开发同一个项目的工程师有很多,在开发过程中,可能都要涉及修改这个类,比如往这个类里添加常量,那这个类就会变得越来越大,成百上千行都有可能,查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率。
这样的设计还会增加代码的编译时间。
当 Constants 类中包含很多常量定义的时候,依赖这个类的代码就会很多。那每次修改 Constants 类,都会导致依赖它的类文件重新编译,因此会浪费很多不必要的编译时间。不要小看编译花费的时间,对于一个非常大的工程项目来说,编译一次项目花费的时间可能是几分钟,甚至几十分钟。而我们在开发过程中,每次运行单元测试,都会触发一次编译的过程,这个编译时间就有可能会影响到我们的开发效率。
这样的设计还会影响代码的复用性。
如果我们要在另一个项目中,复用本项目开发的某个类,而这个类又依赖 Constants 类。即便这个类只依赖 Constants 类中的一小部分常量,我们仍然需要把整个 Constants 类也一并引入,也就引入了很多无关的常量到新的项目中。
改进
第一种是将 Constants 类拆解为功能更加单一的多个类,比如跟 MySQL 配置相关的常量,我们放到 MysqlConstants 类中;跟 Redis 配置相关的常量,我们放到 RedisConstants 类中。 还有一种我个人觉得更好的设计思路,那就是并不单独地设计 Constants 常量类,而是哪个类用到了某个常量,我们就把这个常量定义到这个类中。比如,RedisConfig 类用到了 Redis 配置相关的常量,那我们就直接将这些常量定义在 RedisConfig 中,这样也提高了类设计的内聚性和代码的复用性。
Utils 类
在定义 Utils 类之前,你要问一下自己,你真的需要单独定义这样一个 Utils 类吗?是否可以把 Utils 类中的某些方法定义到其他类中呢?如果在回答完这些问题之后,你还是觉得确实有必要去定义这样一个 Utils 类,那就大胆地去定义它吧。因为即便在面向对象编程中,我们也并不是完全排斥面向过程风格的代码。只要它能为我们写出好的代码贡献力量,我们就可以适度地去使用。
类比 Constants 类的设计,我们设计 Utils 类的时候,最好也能细化一下,针对不同的功能,设计不同的 Utils 类,比如 FileUtils、IOUtils、StringUtils、UrlUtils 等,不要设计一个过于大而全的 Utils 类。
3. 定义数据和方法分离的类
常见的面向过程风格的代码。那就是,数据定义在一个类中,方法定义在另一个类中。你可能会觉得,这么明显的面向过程风格的代码,谁会这么写呢?实际上,如果你是基于 MVC 三层结构做 Web 方面的后端开发,这样的代码你可能天天都在写。
传统的 MVC 结构分为 Model 层、Controller 层、View 层这三层。不过,在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为 Controller 层、Service 层、Repository 层。Controller 层负责暴露接口给前端调用,Service 层负责核心业务逻辑,Repository 层负责数据读写。而在每一层中,我们又会定义相应的 VO(View Object)、BO(Business Object)、Entity。一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、Repository 类中。这就是典型的面向过程的编程风格。
在面向对象编程中,为什么容易写出面向过程风格的代码?
你可以联想一下,在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。 面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。
面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。
面向过程编程及面向过程编程语言就真的无用武之地了吗?
如果我们开发的是微小程序,或者是一个数据处理相关的代码,以算法为主,数据为辅,那脚本式的面向过程的编程风格就更适合一些。当然,面向过程编程的用武之地还不止这些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?我们仔细想想,类中每个方法的实现逻辑,不就是面向过程风格的代码吗?
总结
1. 滥用 getter、setter 方法在设计实现类的时候,除非真的需要,否则尽量不要给属性定义 setter 方法。除此之外,尽管 getter 方法相对 setter 方法要安全些,但是如果返回的是集合容器,那也要防范集合内部数据被修改的风险。 2.Constants 类、Utils 类的设计问题对于这两种类的设计,我们尽量能做到职责单一,定义一些细化的小类,比如 RedisConstants、FileUtils,而不是定义一个大而全的 Constants 类、Utils 类。除此之外,如果能将这些类中的属性和方法,划分归并到其他业务类中,那是最好不过的了,能极大地提高类的内聚性和代码的可复用性。 3. 基于贫血模型的开发模式关于这一部分,我们只讲了为什么这种开发模式是彻彻底底的面向过程编程风格的。这是因为数据和操作是分开定义在 VO/BO/Entity 和 Controler/Service/Repository 中的。今天,你只需要掌握这一点就可以了。
接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?
使用接口来实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则。 使用抽象类来实现面向对象的继承特性和模板设计模式等等。
抽象类3个特点
1. 抽象类不允许被实例化,只能被继承。也就是说,你不能 new 一个抽象类的对象出来(Logger logger = new Logger(…); 会报编译错误)。 2. 抽象类可以包含属性和方法。方法既可以包含代码实现(比如 Logger 中的 log() 方法),也可以不包含代码实现(比如 Logger 中的 doLog() 方法)。不包含代码实现的方法叫作抽象方法。 3. 子类继承抽象类,必须实现抽象类中的所有抽象方法。对应到例子代码中就是,所有继承 Logger 抽象类的子类,都必须重写 doLog() 方法。
接口3个特性
接口不能包含属性(也就是成员变量)。 接口只能声明方法,方法不能包含代码实现。 类实现接口的时候,必须实现接口中声明的所有方法。
区别
从语法特性上对比,这两者有比较大的区别,比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。除了语法特性,从设计的角度,两者也有比较大的区别。抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种 is-a 的关系,那抽象类既然属于类,也表示一种 is-a 的关系。相对于抽象类的 is-a 关系来说,接口表示一种 has-a 关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)。
解决问题
抽象类解决问题
抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码。
普通父类没有抽象类实现思路优雅,有几点原因
public class Logger { // ...省略部分代码... public void log(Level level, String mesage) { // do nothing... } } public class FileLogger extends Logger { // ...省略部分代码... @Override public void log(Level level, String mesage) { if (!isLoggable()) return; // 格式化level和message,输出到日志文件 fileWriter.write(...); } } public class MessageQueueLogger extends Logger { // ...省略部分代码... @Override public void log(Level level, String mesage) { if (!isLoggable()) return; // 格式化level和message,输出到消息中间件 msgQueueClient.send(...); } }
在 Logger 中定义一个空的方法,会影响代码的可读性。如果我们不熟悉 Logger 背后的设计思想,代码注释又不怎么给力,我们在阅读 Logger 代码的时候,就可能对为什么定义一个空的 log() 方法而感到疑惑,需要查看 Logger、FileLogger、MessageQueueLogger 之间的继承关系,才能弄明白其设计意图。 当创建一个新的子类继承 Logger 父类的时候,我们有可能会忘记重新实现 log() 方法。之前基于抽象类的设计思路,编译器会强制要求子类重写 log() 方法,否则会报编译错误。你可能会说,我既然要定义一个新的 Logger 子类,怎么会忘记重新实现 log() 方法呢?我们举的例子比较简单,Logger 中的方法不多,代码行数也很少。但是,如果 Logger 有几百行,有 n 多方法,除非你对 Logger 的设计非常熟悉,否则忘记重新实现 log() 方法,也不是不可能的。 Logger 可以被实例化,换句话说,我们可以 new 一个 Logger 出来,并且调用空的 log() 方法。这也增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,显然没有通过抽象类来的优雅。
接口解决问题
抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下 API 接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。
模拟接口
抽象类
class Strategy { // 用抽象类模拟接口 public: ~Strategy(); virtual void algorithm()=0; protected: Strategy(); };
抽象类 Strategy 没有定义任何属性,并且所有的方法都声明为 virtual 类型(等同于 Java 中的 abstract 关键字),这样,所有的方法都不能有代码实现,并且所有继承这个抽象类的子类,都要实现这些方法。
普通类
public class MockInteface { protected MockInteface() {} public void funcA() { throw new MethodUnSupportedException(); } }
让类中的方法抛出 MethodUnSupportedException 异常,来模拟不包含实现的接口,并且能强迫子类在继承这个父类的时候,都去主动实现父类的方法,否则就会在运行时抛出异常。那又如何避免这个类被实例化呢?实际上很简单,我们只需要将这个类的构造函数声明为 protected 访问权限就可以了。
如何决定该用抽象类还是接口?
实际上,判断的标准很简单。如果我们要表示一种 is-a 的关系,并且是为了解决代码复用的问题,我们就用抽象类;如果我们要表示 一种 has-a 关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口。 从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。
总结
1. 抽象类和接口的语法特性抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。 2. 抽象类和接口存在的意义抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。 3. 抽象类和接口的应用场景区别什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示 一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那我们就用接口。
精选总结
如果让我聊聊接口和抽象类,我会这么聊:定义、区别(是什么),存在意义(从哪来),应用(到哪去)。 1、定义: 抽象类:不允许实例化,只能被继承;可包含属性和方法,包含抽象方法;子类继承抽象类必须重写抽象方法。 接口:不允许实例化,只能被实现;不包含属性和普通方法,包含抽象方法、静态方法、default 方法;类实现接口时,必须实现抽象方法。 2、意义: 抽象类:解决复用问题,适用于is-a的关系。 接口:解决抽象问题,适用于has-a的关系。 3、应用: 例如: 解决复用问题:java中的子类FileInputStream和PipeInputStream等继承抽象类InputStream。重写了read(source)方法,InputStream 中还包含其他方法,FileInputStream继承抽象类复用了父类的其他方法。 解决抽象问题:抽象类InputStream实现了Closeable接口,该接口中包含close()抽象方法。Closeable这个接口还在很多其他类中实现了,例如Channel,Socket中都有close() 关闭这个功能,但具体实现每个类又各有不同的实现,这个就是抽象。 4、补充知识点(语法): Java接口中可以定义静态方法、default方法,枚举类型,接口中还可以定义接口(嵌套)。 public interface ILog { enum Type { LOW, MEDIUM, HIGH } interface InILog{ void initInLog(); } default void init() { Type t = Type.LOW; System.out.println(t.ordinal()); } static void OS() { System.out.println(System.getProperty("os.name", "linux")); } void log(OutputStream out); } class ConsoleLog implements ILog { @Override public void log(OutputStream out) { System.out.println("ConsoleLog..."); } }
为什么基于接口而非实现编程?有必要为每个类都定义接口吗?
如何解读原则中的“接口”二字?
从本质上来看,“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”,甚至是一组通信的协议都可以叫作“接口”。 越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。
如何将这条原则应用到实战中?
1. 函数的命名不能暴露任何实现细节。比如,前面提到的 uploadToAliyun() 就不符合要求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。 2. 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。 3. 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。
public interface ImageStore { String upload(Image image, String bucketName); Image download(String url); } public class AliyunImageStore implements ImageStore { //...省略属性、构造函数等... public String upload(Image image, String bucketName) { createBucketIfNotExisting(bucketName); String accessToken = generateAccessToken(); //...上传图片到阿里云... //...返回图片在阿里云上的地址(url)... } public Image download(String url) { String accessToken = generateAccessToken(); //...从阿里云下载图片... } private void createBucketIfNotExisting(String bucketName) { // ...创建bucket... // ...失败会抛出异常.. } private String generateAccessToken() { // ...根据accesskey/secrectkey等生成access token } } // 上传下载流程改变:私有云不需要支持access token public class PrivateImageStore implements ImageStore { public String upload(Image image, String bucketName) { createBucketIfNotExisting(bucketName); //...上传图片到私有云... //...返回图片的url... } public Image download(String url) { //...从私有云下载图片... } private void createBucketIfNotExisting(String bucketName) { // ...创建bucket... // ...失败会抛出异常.. } } // ImageStore的使用举例 public class ImageProcessingJob { private static final String BUCKET_NAME = "ai_images_bucket"; //...省略其他无关代码... public void process() { Image image = ...;//处理图片,并封装为Image对象 ImageStore imageStore = new PrivateImageStore(...); imagestore.upload(image, BUCKET_NAME); } }
总结一下,我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。在定义接口的时候,不要暴露任何实现细节。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。
是否需要为每个类定义接口?
将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。
从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。 除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投入不必要的开发时间。
总结
1.“基于接口而非实现编程”,这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性、扩展性、可维护性。 2. 我们在定义接口的时候,一方面,命名要足够通用,不能包含跟具体实现相关的字眼;另一方面,与特定实现有关的方法不要定义在接口中。 3.“基于接口而非实现编程”这条原则,不仅仅可以指导非常细节的编程开发,还能指导更加上层的架构设计、系统设计等。比如,服务端与客户端之间的“接口”设计、类库的“接口”设计。
为何说要多用组合少用继承?如何决定该用组合还是继承?
为什么不推荐使用继承?
继承层次过深、过复杂,也会影响到代码的可维护性
类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。
组合相比继承有哪些优势?
可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题。
public interface Flyable { void fly(); } public interface Tweetable { void tweet(); } public interface EggLayable { void layEgg(); } public class Ostrich implements Tweetable, EggLayable {//鸵鸟 //... 省略其他属性和方法... @Override public void tweet() { //... } @Override public void layEgg() { //... } } public class Sparrow impelents Flayable, Tweetable, EggLayable {//麻雀 //... 省略其他属性和方法... @Override public void fly() { //... } @Override public void tweet() { //... } @Override public void layEgg() { //... } }
接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 layEgg() 方法,并且实现逻辑是一样的,这就会导致代码重复的问题
public interface Flyable { void fly(); } public class FlyAbility implements Flyable { @Override public void fly() { //... } } //省略Tweetable/TweetAbility/EggLayable/EggLayAbility public class Ostrich implements Tweetable, EggLayable {//鸵鸟 private TweetAbility tweetAbility = new TweetAbility(); //组合 private EggLayAbility eggLayAbility = new EggLayAbility(); //组合 //... 省略其他属性和方法... @Override public void tweet() { tweetAbility.tweet(); // 委托 } @Override public void layEgg() { eggLayAbility.layEgg(); // 委托 } }
针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。然后,通过组合和委托技术来消除代码重复。 我们知道继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。
如何判断该用组合还是继承?
组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。
如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。
继承
继承可以实现代码复用。利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的
如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。
组合
,有的时候,从业务含义上,A 类和 B 类并不一定具有继承关系。比如,Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer 类继承同一个父类,而父类中定义的却只是 URL 相关的操作,会觉得这个代码写得莫名其妙,理解不了。这个时候,使用组合就更加合理、更加灵活。
public class Url { //...省略属性和方法 } public class Crawler { private Url url; // 组合 public Crawler() { this.url = new Url(); } //... } public class PageAnalyzer { private Url url; // 组合 public PageAnalyzer() { this.url = new Url(); } //.. }
总结
1. 为什么不推荐使用继承?继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,我们应该尽量少用,甚至不用继承。 2. 组合相比继承有哪些优势?继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成。除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。 3. 如何判断该用组合还是继承?尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。在实际的项目开发中,我们还是要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。反之,我们就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。
实战
业务开发常用的基于贫血模型的MVC架构违背OOP吗?
什么是基于贫血模型的传统开发模式?
在左代码实例中,像 UserBo 这样,只包含数据,不包含业务逻辑的类,就叫作贫血模型(Anemic Domain Model)。同理,UserEntity、UserVo 都是基于贫血模型设计的。这种贫血模型将数据与操作分离,破坏了面向对象的封装特性,是一种典型的面向过程的编程风格。
////////// Controller+VO(View Object) ////////// public class UserController { private UserService userService; //通过构造函数或者IOC框架注入 public UserVo getUserById(Long userId) { UserBo userBo = userService.getUserById(userId); UserVo userVo = [...convert userBo to userVo...]; return userVo; } } public class UserVo {//省略其他属性、get/set/construct方法 private Long id; private String name; private String cellphone; } ////////// Service+BO(Business Object) ////////// public class UserService { private UserRepository userRepository; //通过构造函数或者IOC框架注入 public UserBo getUserById(Long userId) { UserEntity userEntity = userRepository.getUserById(userId); UserBo userBo = [...convert userEntity to userBo...]; return userBo; } } public class UserBo {//省略其他属性、get/set/construct方法 private Long id; private String name; private String cellphone; } ////////// Repository+Entity ////////// public class UserRepository { public UserEntity getUserById(Long userId) { //... } } public class UserEntity {//省略其他属性、get/set/construct方法 private Long id; private String name; private String cellphone; }
什么是基于充血模型的 DDD 开发模式?
充血模型(Rich Domain Model)
充血模型(Rich Domain Model)正好相反,数据和对应的业务逻辑被封装到同一个类中。因此,这种充血模型满足面向对象的封装特性,是典型的面向对象编程风格。
领域驱动设计
领域驱动设计,即 DDD,主要是用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互。 除了监控、调用链追踪、API 网关等服务治理系统的开发之外,微服务还有另外一个更加重要的工作,那就是针对公司的业务,合理地做微服务拆分。而领域驱动设计恰好就是用来指导划分服务的。所以,微服务加速了领域驱动设计的盛行。
做好领域驱动设计的关键是,看你对自己所做业务的熟悉程度,而并不是对领域驱动设计这个概念本身的掌握程度。即便你对领域驱动搞得再清楚,但是对业务不熟悉,也并不一定能做出合理的领域设计。所以,不要把领域驱动设计当银弹,不要花太多的时间去过度地研究它。
为什么基于贫血模型的传统开发模式如此受欢迎?
三点原因
第一点原因是,大部分情况下,我们开发的系统业务可能都比较简单,简单到就是基于 SQL 的 CRUD 操作,所以,我们根本不需要动脑子精心设计充血模型,贫血模型就足以应付这种简单业务的开发工作。
第二点原因是,充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,我们就在 Service 层定义什么操作,不需要事先做太多设计。
第三点原因是,思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常。
什么项目应该考虑使用基于充血模型的 DDD 开发模式?
应用基于充血模型的 DDD 的开发模式,那对应的开发流程就完全不一样了。在这种开发模式下,我们需要事先理清楚所有的业务,定义领域模型所包含的属性和方法。领域模型相当于可复用的业务中间层。新功能需求的开发,都基于之前定义好的这些领域模型来完成。 我们知道,越复杂的系统,对代码的复用性、易维护性要求就越高,我们就越应该花更多的时间和精力在前期设计上。而基于充血模型的 DDD 开发模式,正好需要我们前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发。
总结
我们平时做 Web 项目的业务开发,大部分都是基于贫血模型的 MVC 三层架构,在专栏中我把它称为传统的开发模式。之所以称之为“传统”,是相对于新兴的基于充血模型的 DDD 开发模式来说的。基于贫血模型的传统开发模式,是典型的面向过程的编程风格。相反,基于充血模型的 DDD 开发模式,是典型的面向对象的编程风格。 不过,DDD 也并非银弹。对于业务不复杂的系统开发来说,基于贫血模型的传统开发模式简单够用,基于充血模型的 DDD 开发模式有点大材小用,无法发挥作用。相反,对于业务复杂的系统开发来说,基于充血模型的 DDD 开发模式,因为前期需要在设计上投入更多时间和精力,来提高代码的复用性和可维护性,所以相比基于贫血模型的开发模式,更加有优势。
评论
## 为什么贫血模型盛行 下面几项自己都中过招(环境问题和个人问题): ### 环境问题 ## * 近朱者赤,近墨者黑 * 大多数人都是模仿别人的代码,而别人的代码基本上都是 demo,没有复杂的业务逻辑,基本是贫血模型 * 找不到好的指导与学习对象 * 接触不到复杂业务项目 * 做 web 项目的,很大一部分就是简单的 CURD,贫血模型就能解决 * 公司以任务数来衡量个人价值 ### 个人问题 ### * 不考虑项目质量属性 * 只关心当前业务,没有意识去思考后期该如何维护和响应业务变更 * 求快不求质 * 个人以任务数来自我满足 * 没有 60 分和 100 分的概念 * 需求分析、设计、编码合为一体 ## 如何理解充血模型 先推荐一本书:整洁架构设计 先说一下充血模型中各组件的角色: * controller 主要服务于非业务功能,比如说数据验证 * service 服务于 use case,负责的是业务流程与对应规则 * Domain 服务于核心业务逻辑和核心业务数据 * rep 用于与外部交互数据 ---- 额外说一点,业务开发个人倾向于六边形架构,而非传统的三层架构。六边形架构更能体现当下 web 应用的场景 六边形项目结构(根据实际情况自行组织与定义): * InboundHandler 代替 controller * *WebController:处理 web 接口 * *WeChatController:处理微信公众号接口 * *AppController:处理 app 接口 * *MqListener:处理 消息 * *RpcController:处理子系统间的调用 * service 服务于 use case,负责的是业务流程与对应规则 * CQPS + SRP:读写分离和单一原则将 use case 分散到不同的 service 中,避免一个巨大的 service 类(碰到过 8000 行的 service) * Domain 服务于核心业务逻辑和核心业务数据 * 最高层组件,不会依赖底层组件 * 易测试 * outBoundhandle 代替 rep * MqProducer:发布消息 * Cache:从缓存获取数据 * sql:从数据库获取数据 * Rpc:从子系统获取数据 ---- 各层之间的数据模型不要共用,主要是因为稳定性不同,各层数据模型的变更原因和变更速率是不同的,离 IO 设备越近的的稳定性越差,比如说 controller 层的 VO,rep 层的 entity。Domain 层是核心业务逻辑和核心业务数据,稳定性是最高的 ---- 几个不太容易理解的点(我刚开始碰到的时候很费解): * use case 和 核心业务逻辑该如何定义与区分 * 哪些该放到 service 里面,哪些该放到 Domain 中 * rep 是依赖于 service 的,而不是 service 依赖 rep 层 * 业务逻辑是最高层组件(最稳定的),rep 层是底层组件 * 接口能反转依赖关系 ---- 一剂良药:所有的中间层都是为了解耦
如何利用基于充血模型的DDD开发一个虚拟钱包系统?
钱包业务背景介绍
很多具有支付、购买功能的应用(比如淘宝、滴滴出行、极客时间等)都支持钱包的功能。应用为每个用户开设一个系统内的虚拟钱包账户,支持用户充值、提现、支付、冻结、透支、转赠、查询账户余额、查询交易流水等操作。下图是一张典型的钱包功能界面,你可以直观地感受一下。
业务实现流程
1. 充值
三个主要的操作流程 第一个操作是从用户的银行卡账户转账到应用的公共银行卡账户; 第二个操作是将用户的充值金额加到虚拟钱包余额上; 第三个操作是记录刚刚这笔交易流水。
2. 支付
用户用钱包内的余额,支付购买应用内的商品。实际上,支付的过程就是一个转账的过程,从用户的虚拟钱包账户划钱到商家的虚拟钱包账户上。除此之外,我们也需要记录这笔支付的交易流水信息。
3. 提现
扣减用户虚拟钱包中的余额,并且触发真正的银行转账操作,从应用的公共银行账户转钱到用户的银行账户。同样,我们也需要记录这笔提现的交易流水信息。
4. 查询余额
查询余额功能比较简单,我们看一下虚拟钱包中的余额数字即可。
5. 查询交易流水
支持三种类型的交易流水:充值、支付、提现。在用户充值、支付、提现的时候,我们会记录相应的交易信息。在需要查询的时候,我们只需要将之前记录的交易流水,按照时间、类型等条件过滤之后,显示出来即可。
钱包系统的设计思路
整个钱包系统的业务划分为两部分, 其中一部分单纯跟应用内的虚拟钱包账户打交道, 另一部分单纯跟银行账户打交道。
支持钱包的这五个核心功能,虚拟钱包系统需要对应实现哪些操作
交易流水信息
对于支付这样的类似转账的操作,我们在操作两个钱包账户余额之前,先记录交易流水,并且标记为“待执行”,当两个钱包的加减金额都完成之后,我们再回过头来,将交易流水标记为“成功”。在给两个钱包加减金额的过程中,如果有任意一个操作失败,我们就将交易记录的状态标记为“失败”。我们通过后台补漏 Job,拉取状态为“失败”或者长时间处于“待执行”状态的交易记录,重新执行或者人工介入处理。
子主题
整个钱包系统分为两个子系统,上层钱包系统的实现,依赖底层虚拟钱包系统和三方支付系统。对于钱包系统来说,它可以感知充值、支付、提现等业务概念,所以,我们在钱包系统这一层额外再记录一条包含交易类型的交易流水信息,而在底层的虚拟钱包系统中记录不包含交易类型的交易流水信息。
基于贫血模型的传统开发模式
典型的 Web 后端项目的三层结构。其中,Controller 和 VO 负责暴露接口,Controller 中,接口实现比较简单,主要就是调用 Service 的方法,Service 和 BO 负责核心业务逻辑,Repository 和 Entity 负责数据存取
public class VirtualWalletController { // 通过构造函数或者IOC框架注入 private VirtualWalletService virtualWalletService; public BigDecimal getBalance(Long walletId) { ... } //查询余额 public void debit(Long walletId, BigDecimal amount) { ... } //出账 public void credit(Long walletId, BigDecimal amount) { ... } //入账 public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) { ...} //转账 }
基于充血模型的 DDD 开发模式
基于充血模型的 DDD 开发模式,跟基于贫血模型的传统开发模式的主要区别就在 Service 层,Controller 层和 Repository 层的代码基本上相同。所以,我们重点看一下,Service 层按照基于充血模型的 DDD 开发模式该如何来实现。
public class VirtualWallet { // Domain领域模型(充血模型) private Long id; private Long createTime = System.currentTimeMillis();; private BigDecimal balance = BigDecimal.ZERO; public VirtualWallet(Long preAllocatedId) { this.id = preAllocatedId; } public BigDecimal balance() { return this.balance; } public void debit(BigDecimal amount) { if (this.balance.compareTo(amount) < 0) { throw new InsufficientBalanceException(...); } this.balance = this.balance.subtract(amount); } public void credit(BigDecimal amount) { if (amount.compareTo(BigDecimal.ZERO) < 0) { throw new InvalidAmountException(...); } this.balance = this.balance.add(amount); } } public class VirtualWalletService { // 通过构造函数或者IOC框架注入 private VirtualWalletRepository walletRepo; private VirtualWalletTransactionRepository transactionRepo; public VirtualWallet getVirtualWallet(Long walletId) { VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId); VirtualWallet wallet = convert(walletEntity); return wallet; } public BigDecimal getBalance(Long walletId) { return walletRepo.getBalance(walletId); } public void debit(Long walletId, BigDecimal amount) { VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId); VirtualWallet wallet = convert(walletEntity); wallet.debit(amount); walletRepo.updateBalance(walletId, wallet.balance()); } public void credit(Long walletId, BigDecimal amount) { VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId); VirtualWallet wallet = convert(walletEntity); wallet.credit(amount); walletRepo.updateBalance(walletId, wallet.balance()); } public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) { //...跟基于贫血模型的传统开发模式的代码一样... } }
领域模型 VirtualWallet 类很单薄,包含的业务逻辑很简单。相对于原来的贫血模型的设计思路,这种充血模型的设计思路,貌似并没有太大优势。你说得没错!这也是大部分业务系统都使用基于贫血模型开发的原因。不过,如果虚拟钱包系统需要支持更复杂的业务逻辑,那充血模型的优势就显现出来了。比如,我们要支持透支一定额度和冻结部分余额的功能。 领域模型 VirtualWallet 类添加了简单的冻结和透支逻辑之后,功能看起来就丰富了很多,代码也没那么单薄了。如果功能继续演进,我们可以增加更加细化的冻结策略、透支策略、支持钱包账号(VirtualWallet id 字段)自动生成的逻辑(不是通过构造函数经外部传入 ID,而是通过分布式 ID 生成算法来自动生成 ID)等等。VirtualWallet 类的业务逻辑会变得越来越复杂,也就很值得设计成充血模型了。
public class VirtualWallet { private Long id; private Long createTime = System.currentTimeMillis();; private BigDecimal balance = BigDecimal.ZERO; private boolean isAllowedOverdraft = true; private BigDecimal overdraftAmount = BigDecimal.ZERO; private BigDecimal frozenAmount = BigDecimal.ZERO; public VirtualWallet(Long preAllocatedId) { this.id = preAllocatedId; } public void freeze(BigDecimal amount) { ... } public void unfreeze(BigDecimal amount) { ...} public void increaseOverdraftAmount(BigDecimal amount) { ... } public void decreaseOverdraftAmount(BigDecimal amount) { ... } public void closeOverdraft() { ... } public void openOverdraft() { ... } public BigDecimal balance() { return this.balance; } public BigDecimal getAvailableBalance() { BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount); if (isAllowedOverdraft) { totalAvaliableBalance += this.overdraftAmount; } return totalAvaliableBalance; } public void debit(BigDecimal amount) { BigDecimal totalAvaliableBalance = getAvailableBalance(); if (totoalAvaliableBalance.compareTo(amount) < 0) { throw new InsufficientBalanceException(...); } this.balance.subtract(amount); } public void credit(BigDecimal amount) { if (amount.compareTo(BigDecimal.ZERO) < 0) { throw new InvalidAmountException(...); } this.balance.add(amount); } }
辩证思考与灵活应用
第一个要讨论的问题是:在基于充血模型的 DDD 开发模式中,将业务逻辑移动到 Domain 中,Service 类变得很薄,但在我们的代码设计与实现中,并没有完全将 Service 类去掉,这是为什么?或者说,Service 类在这种情况下担当的职责是什么?哪些功能逻辑会放到 Service 类中?
区别于 Domain 的职责,Service 类主要有下面这样几个职责。 1.Service 类负责与 Repository 交流。在我的设计与代码实现中,VirtualWalletService 类负责与 Repository 层打交道,调用 Respository 类的方法,获取数据库中的数据,转化成领域模型 VirtualWallet,然后由领域模型 VirtualWallet 来完成业务逻辑,最后调用 Repository 类的方法,将数据存回数据库。这里我再稍微解释一下,之所以让 VirtualWalletService 类与 Repository 打交道,而不是让领域模型 VirtualWallet 与 Repository 打交道,那是因为我们想保持领域模型的独立性,不与任何其他层的代码(Repository 层的代码)或开发框架(比如 Spring、MyBatis)耦合在一起,将流程性的代码逻辑(比如从 DB 中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用。 2.Service 类负责跨领域模型的业务聚合功能。VirtualWalletService 类中的 transfer() 转账函数会涉及两个钱包的操作,因此这部分业务逻辑无法放到 VirtualWallet 类中,所以,我们暂且把转账业务放到 VirtualWalletService 类中了。当然,虽然功能演进,使得转账业务变得复杂起来之后,我们也可以将转账业务抽取出来,设计成一个独立的领域模型。 3.Service 类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的 RPC 接口等,都可以放到 Service 类中。
第二个要讨论问题是:在基于充血模型的 DDD 开发模式中,尽管 Service 层被改造成了充血模型,但是 Controller 层和 Repository 层还是贫血模型,是否有必要也进行充血领域建模呢?
答案是没有必要。Controller 层主要负责接口的暴露,Repository 层主要负责与数据库打交道,这两层包含的业务逻辑并不多,前面我们也提到了,如果业务逻辑比较简单,就没必要做充血建模,即便设计成充血模型,类也非常单薄,看起来也很奇怪。 就拿 Repository 的 Entity 来说,即便它被设计成贫血模型,违反面向对象编程的封装特性,有被任意代码修改数据的风险,但 Entity 的生命周期是有限的。一般来讲,我们把它传递到 Service 层之后,就会转化成 BO 或者 Domain 来继续后面的业务逻辑。Entity 的生命周期到此就结束了,所以也并不会被到处任意修改。 我们再来说说 Controller 层的 VO。实际上 VO 是一种 DTO(Data Transfer Object,数据传输对象)。它主要是作为接口的数据传输承载体,将数据发送给其他系统。从功能上来讲,它理应不包含业务逻辑、只包含数据。所以,我们将它设计成贫血模型也是比较合理的。
如何对接口鉴权这样一个功能开发做面向对象分析?
面向对象分析(OOA)、面向对象设计(OOD)、面向对象编程(OOP),是面向对象开发的三个主要环节。 对于“如何做需求分析,如何做职责划分?需要定义哪些类?每个类应该具有哪些属性、方法?类与类之间该如何交互?如何组装类成一个可执行的程序?”等等诸多问题,都没有清晰的思路,更别提利用成熟的设计原则、思想或者设计模式,开发出具有高内聚低耦合、易扩展、易读等优秀特性的代码了。
案例
从基础的需求分析、职责划分、类的定义、交互、组装运行讲起,将最基础的面向对象分析、设计、编程的套路给你讲清楚,为后面学习设计原则、设计模式打好基础。
描述: 假设,你正在参与开发一个微服务。微服务通过 HTTP 协议暴露接口给其他系统调用,说直白点就是,其他系统通过 URL 来调用微服务的接口。有一天,你的 leader 找到你说,“为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。我希望由你来负责这个任务的开发,争取尽快上线。”
无从下手,原因有两点
需求不明确
leader 给到的需求过于模糊、笼统,不够具体、细化,离落地到设计、编码还有一定的距离。而人的大脑不擅长思考这种过于抽象的问题。这也是真实的软件开发区别于应试教育的地方。应试教育中的考试题目,一般都是一个非常具体的问题,我们去解答就好了。而真实的软件开发中,需求几乎都不是很明确。 我们前面讲过,面向对象分析主要的分析对象是“需求”,因此,面向对象分析可以粗略地看成“需求分析”。实际上,不管是需求分析还是面向对象分析,我们首先要做的都是将笼统的需求细化到足够清晰、可执行。我们需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些是不用考虑做的。
2. 缺少锻炼
相比单纯的业务 CRUD 开发,鉴权这个开发任务,要更有难度。鉴权作为一个跟具体业务无关的功能,我们完全可以把它开发成一个独立的框架,集成到很多业务系统中。而作为被很多系统复用的通用框架,比起普通的业务代码,我们对框架的代码质量要求要更高。 开发这样通用的框架,对工程师的需求分析能力、设计能力、编码能力,甚至逻辑思维能力的要求,都是比较高的。如果你平时做的都是简单的 CRUD 业务开发,那这方面的锻炼肯定不会很多,所以,一旦遇到这种开发需求,很容易因为缺少锻炼,脑子放空,不知道从何入手,完全没有思路。
对案例进行需求分析
对案例进行需求分析
尽管针对框架、组件、类库等非业务系统的开发,我们一定要有组件化意识、框架意识、抽象意识,开发出来的东西要足够通用,不能局限于单一的某个业务需求,但这并不代表我们就可以脱离具体的应用场景,闷头拍脑袋做需求分析。多跟业务团队聊聊天,甚至自己去参与几个业务系统的开发,只有这样,我们才能真正知道业务系统的痛点,才能分析出最有价值的需求。
分析
先从最简单的方案想起,然后再优化。把整个的分析过程分为了循序渐进的四轮。每一轮都是对上一轮的迭代优化,最后形成一个可执行、可落地的需求列表。
1. 第一轮基础分析
对于如何做鉴权这样一个问题,最简单的解决方案就是,通过用户名加密码来做认证。我们给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用 ID、AppID)和一个对应的密码(或者叫秘钥)。调用方每次进行接口请求的时候,都携带自己的 AppID 和密码。微服务在接收到接口调用请求之后,会解析出 AppID 和密码,跟存储在微服务端的 AppID 和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝接口调用请求。
2. 第二轮分析优化
不过,这样的验证方式,每次都要明文传输密码。密码很容易被截获,是不安全的。那如果我们借助加密算法(比如 SHA),对密码进行加密之后,再传递到微服务端验证,是不是就可以了呢?实际上,这样也是不安全的,因为加密之后的密码及 AppID,照样可以被未认证系统(或者说黑客)截获,未认证系统可以携带这个加密之后的密码以及对应的 AppID,伪装成已认证系统来访问我们的接口。这就是典型的“重放攻击”。
提出问题,然后再解决问题,是一个非常好的迭代优化方法。对于刚刚这个问题,我们可以借助 OAuth 的验证思路来解决。调用方将请求接口的 URL 跟 AppID、密码拼接在一起,然后进行加密,生成一个 token。调用方在进行接口请求的的时候,将这个 token 及 AppID,随 URL 一块传递给微服务端。微服务端接收到这些数据之后,根据 AppID 从数据库中取出对应的密码,并通过同样的 token 生成算法,生成另外一个 token。用这个新生成的 token 跟调用方传递过来的 token 对比。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。
3. 第三轮分析优化
不过,这样的设计仍然存在重放攻击的风险,还是不够安全。每个 URL 拼接上 AppID、密码生成的 token 都是固定的。未认证系统截获 URL、token 和 AppID 之后,还是可以通过重放攻击的方式,伪装成认证系统,调用这个 URL 对应的接口。
为了解决这个问题,我们可以进一步优化 token 生成算法,引入一个随机变量,让每次接口请求生成的 token 都不一样。我们可以选择时间戳作为随机变量。原来的 token 是对 URL、AppID、密码三者进行加密生成的,现在我们将 URL、AppID、密码、时间戳四者进行加密来生成 token。调用方在进行接口请求的时候,将 token、AppID、时间戳,随 URL 一并传递给微服务端。
微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(比如一分钟)。如果超过一分钟,则判定 token 过期,拒绝接口请求。如果没有超过一分钟,则说明 token 没有过期,就再通过同样的 token 生成算法,在服务端生成新的 token,与调用方传递过来的 token 比对,看是否一致。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。
4. 第四轮分析优化
不过,你可能会说,这样还是不够安全啊。未认证系统还是可以在这一分钟的 token 失效窗口内,通过截获请求、重放请求,来调用我们的接口啊! 开发像鉴权这样的非业务功能,最好不要与具体的第三方系统有过度的耦合。 针对 AppID 和密码的存储,我们最好能灵活地支持各种不同的存储方式,比如 ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。
5. 最终确定需求
考虑到在接下来的面向对象设计环节中,我会基于文字版本的需求描述,来进行类、属性、方法、交互等的设计,所以,这里我给出的最终需求描述是文字版本的。
调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。
如何利用面向对象设计和编程开发接口鉴权功能?
针对面向对象分析产出的需求,如何来进行面向对象设计(OOD)和面向对象编程(OOP)。
如何进行面向对象设计?
面向对象分析的产出是详细的需求描述,那面向对象设计的产出就是类。
设计环节拆解细化一下,主要包含以下几个部分:
划分职责进而识别出有哪些类; 定义类及其属性和方法; 定义类与类之间的交互关系; 将类组装起来并提供执行入口
案例
需求描述
调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。 微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。 如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配。如果一致,则鉴权成功,允许接口调用;否则就拒绝接口调用。
1. 划分职责进而识别出有哪些类
方式 1.大多数讲面向对象的书籍中,还会讲到另外一种识别类的方法,那就是把需求描述中的名词罗列出来,作为可能的候选类,然后再进行筛选。 2.根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,是否应该归为同一个类。
分析 首先,我们要做的是逐句阅读上面的需求描述,拆解成小的功能点,一条一条罗列下来。注意,拆解出来的每个功能点要尽可能的小。每个功能点只负责做一件很小的事情(专业叫法是“单一职责”)
1. 把 URL、AppID、密码、时间戳拼接为一个字符串; 2. 对字符串通过加密算法加密生成 token; 3. 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL; 4. 解析 URL,得到 token、AppID、时间戳等信息; 5. 从存储中取出 AppID 和对应的密码; 6. 根据时间戳判断 token 是否过期失效; 7. 验证两个 token 是否匹配;
功能分析法:(简单需求) 从上面的功能列表中,我们发现,1、2、6、7 都是跟 token 有关,负责 token 的生成、验证;3、4 都是在处理 URL,负责 URL 的拼接、解析;5 是操作 AppID 和密码,负责从存储中读取 AppID 和密码。所以,我们可以粗略地得到三个核心的类:AuthToken、Url、CredentialStorage。AuthToken 负责实现 1、2、6、7 这四个操作;Url 负责 3、4 两个操作;CredentialStorage 负责 5 这个操作。
模块分析法:(针对复杂需求) 针对这种复杂的需求开发,我们首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块,然后再在模块内部,应用我们刚刚讲的方法,进行面向对象设计。而模块的划分和识别,跟类的划分和识别,是类似的套路。
2. 定义类及其属性和方法
识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。类比一下方法的识别,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。
AuthToken、 Url、 CredentialStorage
AuthToken 类相关的功能点有四个: 把 URL、AppID、密码、时间戳拼接为一个字符串; 对字符串通过加密算法加密生成 token; 根据时间戳判断 token 是否过期失效;验证两个 token 是否匹配。
第一个细节:并不是所有出现的名词都被定义为类的属性,比如 URL、AppID、密码、时间戳这几个名词,我们把它作为了方法的参数。 第二个细节:我们还需要挖掘一些没有出现在功能点描述中属性,比如 createTime,expireTimeInterval,它们用在 isExpired() 函数中,用来判定 token 是否过期。 第三个细节:我们还给 AuthToken 类添加了一个功能点描述中没有提到的方法 getToken()。
第一个细节告诉我们,从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类里。比如 URL、AppID 这些信息,从业务模型上来说,不应该属于 AuthToken,所以我们不应该放到这个类中。 第二、第三个细节告诉我们,在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法。这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备。
Url 类相关的功能点有两个: 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL; 解析 URL,得到 token、AppID、时间戳等信息。
CredentialStorage 类相关的功能点有一个: 从存储中取出 AppID 和对应的密码。
为了做到抽象封装具体的存储方式,我们将 CredentialStorage 设计成了接口,基于接口而非具体的实现编程。
3. 定义类与类之间的交互关系
UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。关系比较多,而且有些还比较相近,比如聚合和组合
泛化(Generalization)可以简单理解为继承关系。
public class A { ... } public class B extends A { ... }
实现(Realization)一般是指接口和实现类之间的关系
public interface A {...} public class B implements A { ... }
聚合(Aggregation)是一种包含关系,A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期,也就是说可以单独销毁 A 类对象而不影响 B 对象,比如课程与学生之间的关系。
public class A { private B b; public A(B b) { this.b = b; } }
组合(Composition)也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期依赖 A 类对象的生命周期,B 类对象不可单独存在,比如鸟与翅膀之间的关系。
public class A { private B b; public A() { this.b = new B(); } }
关联(Association)是一种非常弱的关系,包含聚合、组合两种关系。具体到代码层面,如果 B 类对象是 A 类的成员变量,那 B 类和 A 类就是关联关系。
public class A { private B b; public A(B b) { this.b = b; } } 或者 public class A { private B b; public A() { this.b = new B(); } }
依赖(Dependency)是一种比关联关系更加弱的关系,包含关联关系。不管是 B 类对象是 A 类对象的成员变量,还是 A 类的方法使用 B 类对象作为参数或者返回值、局部变量,只要 B 类对象和 A 类对象有任何使用关系,我们都称它们有依赖关系。
public class A { private B b; public A(B b) { this.b = b; } } 或者 public class A { private B b; public A() { this.b = new B(); } } 或者 public class A { public void func(B b) { ... } }
只保留了四个关系:泛化、实现、组合、依赖
4. 将类组装起来并提供执行入口
类定义好了,类之间必要的交互关系也设计好了,接下来我们要将所有的类组装在一起,提供一个执行入口。这个入口可能是一个 main() 函数,也可能是一组给外部用的 API 接口。通过这个入口,我们能触发整个代码跑起来。
接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的 ApiAuthenticator 接口类,暴露一组给外部调用者使用的 API 接口,作为触发执行鉴权逻辑的入口。
如何进行面向对象编程?
定义清晰了类、属性、方法、类之间的交互,并且将所有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作,就是将这些设计思路翻译成代码实现。有了前面的类图,这部分工作相对来说就比较简单了。所以,这里我只给出比较复杂的 ApiAuthenticator 的实现。
public interface ApiAuthenticator { void auth(String url); void auth(ApiRequest apiRequest); } public class DefaultApiAuthenticatorImpl implements ApiAuthenticator { private CredentialStorage credentialStorage; public DefaultApiAuthenticatorImpl() { this.credentialStorage = new MysqlCredentialStorage(); } public DefaultApiAuthenticatorImpl(CredentialStorage credentialStorage) { this.credentialStorage = credentialStorage; } @Override public void auth(String url) { ApiRequest apiRequest = ApiRequest.buildFromUrl(url); auth(apiRequest); } @Override public void auth(ApiRequest apiRequest) { String appId = apiRequest.getAppId(); String token = apiRequest.getToken(); long timestamp = apiRequest.getTimestamp(); String originalUrl = apiRequest.getOriginalUrl(); AuthToken clientAuthToken = new AuthToken(token, timestamp); if (clientAuthToken.isExpired()) { throw new RuntimeException("Token is expired."); } String password = credentialStorage.getPasswordByAppId(appId); AuthToken serverAuthToken = AuthToken.generate(originalUrl, appId, password, timestamp); if (!serverAuthToken.match(clientAuthToken)) { throw new RuntimeException("Token verfication failed."); } } }
辩证思考与灵活应用
在之前的讲解中,面向对象分析、设计、实现,每个环节的界限划分都比较清楚。而且,设计和实现基本上是按照功能点的描述,逐句照着翻译过来的。这样做的好处是先做什么、后做什么,非常清晰、明确,有章可循,即便是没有太多设计经验的初级工程师,都可以按部就班地参照着这个流程来做分析、设计和实现。 不过,在平时的工作中,大部分程序员往往都是在脑子里或者草纸上完成面向对象分析和设计,然后就开始写代码了,边写边思考边重构,并不会严格地按照刚刚的流程来执行。而且,说实话,即便我们在写代码之前,花很多时间做分析和设计,绘制出完美的类图、UML 图,也不可能把每个细节、交互都想得很清楚。在落实到代码的时候,我们还是要反复迭代、重构、打破重写。 毕竟,整个软件开发本来就是一个迭代、修修补补、遇到问题解决问题的过程,是一个不断重构的过程。我们没法严格地按照顺序执行各个步骤。这就类似你去学驾照,驾校教的都是比较正规的流程,先做什么,后做什么,你只要照着做就能顺利倒车入库,但实际上,等你开熟练了,倒车入库很多时候靠的都是经验和感觉。
总结
1. 划分职责进而识别出有哪些类根据需求描述,我们把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,可否归为同一个类。 2. 定义类及其属性和方法我们识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选出真正的方法,把功能点中涉及的名词,作为候选属性,然后同样再进行过滤筛选。 3. 定义类与类之间的交互关系UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。我们从更加贴近编程的角度,对类与类之间的关系做了调整,保留四个关系:泛化、实现、组合、依赖。 4. 将类组装起来并提供执行入口我们要将所有的类组装在一起,提供一个执行入口。这个入口可能是一个 main() 函数,也可能是一组给外部用的 API 接口。通过这个入口,我们能触发整个代码跑起来。
设计原则
对于单一职责原则,如何判定某个类的职责是否够“单一”?
我们开始学习一些经典的设计原则,其中包括,SOLID、KISS、YAGNI、DRY、LOD 等。
如何理解单一职责原则(SRP)? Single Responsibility Principle
SOLID 原则并非单纯的 1 个原则,而是由 5 个设计原则组成的,它们分别是:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则,依次对应 SOLID 中的 S、O、L、I、D 这 5 个英文字母
一个类或者模块只负责完成一个职责(或者功能)。A class or module should have a single responsibility。
这个原则描述的对象包含两个,一个是类(class),一个是模块(module)。有两种理解方式。 1. 把模块看作比类更加抽象的概念,类也可以看作模块。 2. 把模块看作比类更加粗粒度的代码块,模块中包含多个类,多个类组成一个模块。
一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。
例
比如,一个类里既包含订单的一些操作,又包含用户的一些操作。而订单和用户是两个独立的业务领域模型,我们将两个不相干的功能放到同一个类中,那就违反了单一职责原则。为了满足单一职责原则,我们需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。
如何判断类的职责是否足够单一?
public class UserInfo { private long userId; private String username; private String email; private String telephone; private long createTime; private long lastLoginTime; private String avatarUrl; private String provinceOfAddress; // 省 private String cityOfAddress; // 市 private String regionOfAddress; // 区 private String detailedAddress; // 详细地址 // ...省略其他属性和方法... }
两种不同观点
1. 一种观点是,UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则; 2. 另一种观点是,地址信息在 UserInfo 类中,所占的比重比较高,可以继续拆分成独立的 UserAddress 类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类的职责更加单一。
哪种观点更对呢?实际上,要从中做出选择,我们不能脱离具体的应用场景。如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。但是,如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。
总结
不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。
我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构
小技巧判断原则
1. 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分; 2. 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分; 3. 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性; 4. 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰; 5. 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。
类的职责是否设计得越单一越好?
/** * Protocol format: identifier-string;{gson string} * For example: UEUEUE;{"a":"A","b":"B"} */ public class Serialization { private static final String IDENTIFIER_STRING = "UEUEUE;"; private Gson gson; public Serialization() { this.gson = new Gson(); } public String serialize(Map<String, String> object) { StringBuilder textBuilder = new StringBuilder(); textBuilder.append(IDENTIFIER_STRING); textBuilder.append(gson.toJson(object)); return textBuilder.toString(); } public Map<String, String> deserialize(String text) { if (!text.startsWith(IDENTIFIER_STRING)) { return Collections.emptyMap(); } String gsonStr = text.substring(IDENTIFIER_STRING.length()); return gson.fromJson(gsonStr, Map.class); } }
Serialization 类实现了一个简单协议的序列化和反序列功能
拆分:由之前一个类变两个类 如果我们想让类的职责更加单一,我们对 Serialization 类进一步拆分,拆分成一个只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类。 问题: 虽然经过拆分之后,Serializer 类和 Deserializer 类的职责更加单一了,但也随之带来了新的问题。如果我们修改了协议的格式,数据标识从“UEUEUE”改为“DFDFDF”,或者序列化方式从 JSON 改为了 XML,那 Serializer 类和 Deserializer 类都需要做相应的修改,代码的内聚性显然没有原来 Serialization 高了。而且,如果我们仅仅对 Serializer 类做了协议修改,而忘记了修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,拆分之后,代码的可维护性变差了。 实际上,不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。
总结
1. 如何理解单一职责原则(SRP)?一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。 2. 如何判断类的职责是否足够单一?不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则: 类中的代码行数、函数或者属性过多; 类依赖的其他类过多,或者依赖类的其他类过多; 私有方法过多; 比较难给类起一个合适的名字; 类中大量的方法都是集中操作类中的某几个属性。 3. 类的职责是否设计得越单一越好?单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
1,内聚和耦合其实是对一个意思(即合在一块)从相反方向的两种阐述。 2,内聚是从功能相关来谈,主张高内聚。把功能高度相关的内容不必要地分离开,就降低了内聚性,成了低内聚。 3,耦合是从功能无关来谈,主张低耦合。把功能明显无关的内容随意地结合起来,就增加了耦合性,成了高耦合。
如何做到“对扩展开放、修改关闭”?扩展和修改各指什么?
开闭原则是 SOLID 中最难理解、最难掌握,同时也是最有用的一条原则。
如何理解“对扩展开放、修改关闭”?
软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。
添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
例
public class Alert { private AlertRule rule; private Notification notification; public Alert(AlertRule rule, Notification notification) { this.rule = rule; this.notification = notification; } public void check(String api, long requestCount, long errorCount, long durationOfSeconds) { long tps = requestCount / durationOfSeconds; if (tps > rule.getMatchedRule(api).getMaxTps()) {//TPS超过预设最大值 notification.notify(NotificationEmergencyLevel.URGENCY, "..."); } if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {//接口请求出错数最大值 notification.notify(NotificationEmergencyLevel.SEVERE, "..."); } } }
AlertRule 存储告警规则,可以自由设置。Notification 是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度,包括 SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。
需求变动 ---> 我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知
基于修改方式,遇到的问题是 ---> 一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。 另一方面,修改了 check() 函数,相应的单元测试都需要修改
public class Alert { // ...省略AlertRule/Notification属性和构造函数... // 改动一:添加参数timeoutCount public void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) { long tps = requestCount / durationOfSeconds; if (tps > rule.getMatchedRule(api).getMaxTps()) { notification.notify(NotificationEmergencyLevel.URGENCY, "..."); } if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) { notification.notify(NotificationEmergencyLevel.SEVERE, "..."); } // 改动二:添加接口超时处理逻辑 long timeoutTps = timeoutCount / durationOfSeconds; if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) { notification.notify(NotificationEmergencyLevel.URGENCY, "..."); } } }
基于扩展方式 ---> 采用策略模式 添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
public class Alert { private List<AlertHandler> alertHandlers = new ArrayList<>(); public void addAlertHandler(AlertHandler alertHandler) { this.alertHandlers.add(alertHandler); } public void check(ApiStatInfo apiStatInfo) { for (AlertHandler handler : alertHandlers) { handler.check(apiStatInfo); } } } public class ApiStatInfo {//省略constructor/getter/setter方法 private String api; private long requestCount; private long errorCount; private long durationOfSeconds; } ////----------------------HANDLER Abstact----------------- public abstract class AlertHandler { protected AlertRule rule; protected Notification notification; public AlertHandler(AlertRule rule, Notification notification) { this.rule = rule; this.notification = notification; } public abstract void check(ApiStatInfo apiStatInfo); } //----------------------TPS HANDLER----------------- public class TpsAlertHandler extends AlertHandler { public TpsAlertHandler(AlertRule rule, Notification notification) { super(rule, notification); } @Override public void check(ApiStatInfo apiStatInfo) { long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds(); if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) { notification.notify(NotificationEmergencyLevel.URGENCY, "..."); } } } //----------------------ERROR HANDLER----------------- public class ErrorAlertHandler extends AlertHandler { public ErrorAlertHandler(AlertRule rule, Notification notification){ super(rule, notification); } @Override public void check(ApiStatInfo apiStatInfo) { if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) { notification.notify(NotificationEmergencyLevel.SEVERE, "..."); } } }
ApplicationContext 是一个单例类,负责 Alert 的创建、组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作
public class ApplicationContext { private AlertRule alertRule; private Notification notification; private Alert alert; public void initializeBeans() { alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码 notification = new Notification(/*.省略参数.*/); //省略一些初始化代码 alert = new Alert(); alert.addAlertHandler(new TpsAlertHandler(alertRule, notification)); alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification)); } public Alert getAlert() { return alert; } // 饿汉式单例 private static final ApplicationContext instance = new ApplicationContext(); private ApplicationContext() { initializeBeans(); } public static ApplicationContext getInstance() { return instance; } } public class Demo { public static void main(String[] args) { ApiStatInfo apiStatInfo = new ApiStatInfo(); // ...省略设置apiStatInfo数据值的代码 ApplicationContext.getInstance().getAlert().check(apiStatInfo); } }
改动之后的代码如下所示------------------------------------------------ public class Alert { // 代码未改动... } public class ApiStatInfo {//省略constructor/getter/setter方法 private String api; private long requestCount; private long errorCount; private long durationOfSeconds; private long timeoutCount; // 改动一:添加新字段 } public abstract class AlertHandler { //代码未改动... } public class TpsAlertHandler extends AlertHandler {//代码未改动...} public class ErrorAlertHandler extends AlertHandler {//代码未改动...} // 改动二:添加新的handler public class TimeoutAlertHandler extends AlertHandler {//省略代码...} public class ApplicationContext { private AlertRule alertRule; private Notification notification; private Alert alert; public void initializeBeans() { alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码 notification = new Notification(/*.省略参数.*/); //省略一些初始化代码 alert = new Alert(); alert.addAlertHandler(new TpsAlertHandler(alertRule, notification)); alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification)); // 改动三:注册handler alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification)); } //...省略其他未改动代码... } public class Demo { public static void main(String[] args) { ApiStatInfo apiStatInfo = new ApiStatInfo(); // ...省略apiStatInfo的set字段代码 apiStatInfo.setTimeoutCount(289); // 改动四:设置tiemoutCount值 ApplicationContext.getInstance().getAlert().check(apiStatInfo); }
分析
分析一下改动一:往 ApiStatInfo 类中添加新的属性 timeoutCount。
软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。从定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为“修改”,在细代码粒度下,又可以被认定为“扩展”。比如,改动一,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。
我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。
分析一下改动三和改动四:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler;在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。
在重构之后的 Alert 代码中,我们的核心逻辑集中在 Alert 类及其各个 handler 中,当我们在添加新的告警逻辑的时候,Alert 类完全不需要修改,而只需要扩展一个新 handler 类。如果我们把 Alert 类及各个 handler 类合起来看作一个“模块”,那模块本身在添加新的功能的时候,完全满足开闭原则。
如何做到“对扩展开放、修改关闭”?
我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。 在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。 我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。 在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。
// 这一部分体现了抽象意识 public interface MessageQueue { //... } public class KafkaMessageQueue implements MessageQueue { //... } public class RocketMQMessageQueue implements MessageQueue {//...} public interface MessageFromatter { //... } public class JsonMessageFromatter implements MessageFromatter {//...} public class ProtoBufMessageFromatter implements MessageFromatter {//...} public class Demo { private MessageQueue msgQueue; // 基于接口而非实现编程 public Demo(MessageQueue msgQueue) { // 依赖注入 this.msgQueue = msgQueue; } // msgFormatter:多态、依赖注入 public void sendNotification(Notification notification, MessageFormatter msgFormatter) { //... } }
如何在项目中灵活应用开闭原则?
写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点
如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。
“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。 最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。
总结
对拓展开放是为了应对变化(需求),对修改关闭是为了保证已有代码的稳定性;最终结果是为了让系统更有弹性
1. 如何理解“对扩展开放、对修改关闭”?添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。 2. 如何做到“对扩展开放、修改关闭”?我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
里式替换(LSP)跟多态有何区别?哪些代码违背了LSP?
如何理解“里式替换原则”?
子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。 多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
例
public class Transporter { private HttpClient httpClient; public Transporter(HttpClient httpClient) { this.httpClient = httpClient; } public Response sendRequest(Request request) { // ...use httpClient to send request } } public class SecurityTransporter extends Transporter { private String appId; private String appToken; public SecurityTransporter(HttpClient httpClient, String appId, String appToken) { super(httpClient); this.appId = appId; this.appToken = appToken; } @Override public Response sendRequest(Request request) { if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) { request.addPayload("app-id", appId); request.addPayload("app-token", appToken); } return super.sendRequest(request); } } public class Demo { public void demoFunction(Transporter transporter) { Reuqest request = new Request(); //...省略设置request中数据值的代码... Response response = transporter.sendRequest(request); //...省略其他逻辑... } } // 里式替换原则 Demo demo = new Demo(); demo.demofunction(new SecurityTransporter(/*省略参数*/););
子类 SecurityTransporter 的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。
再次改造
// 改造前: public class SecurityTransporter extends Transporter { //...省略其他代码.. @Override public Response sendRequest(Request request) { if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) { request.addPayload("app-id", appId); request.addPayload("app-token", appToken); } return super.sendRequest(request); } } // 改造后: public class SecurityTransporter extends Transporter { //...省略其他代码.. @Override public Response sendRequest(Request request) { if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) { throw new NoAuthorizationRuntimeException(...); } request.addPayload("app-id", appId); request.addPayload("app-token", appToken); return super.sendRequest(request); } }
对 SecurityTransporter 类中 sendRequest() 函数稍加改造一下。改造前,如果 appId 或者 appToken 没有设置,我们就不做校验;改造后,如果 appId 或者 appToken 没有设置,则直接抛出 NoAuthorizationRuntimeException 未授权异常。 在改造之后的代码中,如果传递进 demoFunction() 函数的是父类 Transporter 对象,那 demoFunction() 函数并不会有异常抛出,但如果传递给 demoFunction() 函数的是子类 SecurityTransporter 对象,那 demoFunction() 有可能会有异常抛出。尽管代码中抛出的是运行时异常(Runtime Exception),我们可以不在代码中显式地捕获处理,但子类替换父类传递进 demoFunction 函数之后,整个程序的逻辑行为有了改变。 虽然改造之后的代码仍然可以通过 Java 的多态语法,动态地用子类 SecurityTransporter 来替换父类 Transporter,也并不会导致程序编译或者运行报错。但是,从设计思路上来讲,SecurityTransporter 的设计是不符合里式替换原则的。
子主题
哪些代码明显违背了 LSP?
里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”。 子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。
违反里式替换原则的例子
1. 子类违背父类声明要实现的功能
父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。
2. 子类违背父类对输入、输出、异常的约定
在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。 在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。 在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。
3. 子类违背父类注释中所罗列的任何特殊说明
父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。 以上便是三种典型的违背里式替换原则的情况。除此之外,判断子类的设计实现是否违背里式替换原则,还有一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。 实际上,你有没有发现,里式替换这个原则是非常宽松的。一般情况下,我们写的代码都不怎么会违背它。所以,只要你能看懂我今天讲的这些,这个原则就不难掌握,也不难应用。
LSP意义
一、改进已有实现。例如程序最开始实现时采用了低效的排序算法,改进时使用LSP实现更高效的排序算法。 二、指导程序开发。告诉我们如何组织类和子类(subtype),子类的方法(非私有方法)要符合contract。 三、改进抽象设计。如果一个子类中的实现违反了LSP,那么是不是考虑抽象或者设计出了问题。
总结
里式替换原则是用来指导,继承关系中子类该如何设计的一个原则。理解里式替换原则,最核心的就是理解“design by contract,按照协议来设计”这几个字。父类定义了函数的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。这里的约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。 理解这个原则,我们还要弄明白里式替换原则跟多态的区别。虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。
接口隔离原则有哪三种应用?原则中的“接口”该如何理解?
如何理解“接口隔离原则”?
Clients should not be forced to depend upon interfaces that they do not use。 客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。
理解接口隔离原则的关键,就是理解其中的“接口”二字。在这条原则中,我们可以把“接口”理解为下面三种东西: 1. 一组 API 接口集合 2. 单个 API 接口或函数 3. OOP 中的接口概念
把“接口”理解为一组 API 接口集合
public interface UserService { boolean register(String cellphone, String password); boolean login(String cellphone, String password); UserInfo getUserInfoById(long id); UserInfo getUserInfoByCellphone(String cellphone); } public class UserServiceImpl implements UserService { //... }
后台管理系统要实现删除用户的功能,希望用户系统提供一个删除用户的接口。 不加限制地被其他业务系统调用,就有可能导致误删用户。 解决方案 1. 从架构设计的层面,通过接口鉴权的方式来限制接口的调用。 2. 从代码设计的层面,尽量避免接口被误用。参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。
public interface UserService { boolean register(String cellphone, String password); boolean login(String cellphone, String password); UserInfo getUserInfoById(long id); UserInfo getUserInfoByCellphone(String cellphone); } public interface RestrictedUserService { boolean deleteUserByCellphone(String cellphone); boolean deleteUserById(long id); } public class UserServiceImpl implements UserService, RestrictedUserService { // ...省略实现代码... }
在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。
把“接口”理解为单个 API 接口或函数
把接口理解为单个接口或函数(以下为了方便讲解,我都简称为“函数”)。那接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。
public class Statistics { private Long max; private Long min; private Long average; private Long sum; private Long percentile99; private Long percentile999; //...省略constructor/getter/setter等方法... } public Statistics count(Collection<Long> dataSet) { Statistics statistics = new Statistics(); //...省略计算逻辑... return statistics; }
count() 函数的功能不够单一,包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。按照接口隔离原则,我们应该把 count() 函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。
public Long max(Collection<Long> dataSet) { //... } public Long min(Collection<Long> dataSet) { //... } public Long average(Colletion<Long> dataSet) { //... } // ...省略其他统计函数...
接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
把“接口”理解为 OOP 中的接口概念
把“接口”理解为 OOP 中的接口概念,比如 Java 中的 interface。
假设我们的项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个 Configuration 类:RedisConfig、MysqlConfig、KafkaConfig。
public class RedisConfig { private ConfigSource configSource; //配置中心(比如zookeeper) private String address; private int timeout; private int maxTotal; //省略其他配置: maxWaitMillis,maxIdle,minIdle... public RedisConfig(ConfigSource configSource) { this.configSource = configSource; } public String getAddress() { return this.address; } //...省略其他get()、init()方法... public void update() { //从configSource加载配置到address/timeout/maxTotal... } } public class KafkaConfig { //...省略... } public class MysqlConfig { //...省略... }
新功能需求 希望支持 Redis 和 Kafka 配置信息的热更新。所谓“热更新(hot update)”就是,如果在配置中心中更改了配置信息,我们希望在不用重启系统的情况下,能将最新的配置信息加载到内存中(也就是 RedisConfig、KafkaConfig 类中)
public interface Updater { void update(); } public class RedisConfig implemets Updater { //...省略其他属性和方法... @Override public void update() { //... } } public class KafkaConfig implements Updater { //...省略其他属性和方法... @Override public void update() { //... } } public class MysqlConfig { //...省略其他属性和方法... } public class ScheduledUpdater { private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();; private long initialDelayInSeconds; private long periodInSeconds; private Updater updater; public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long periodInSeconds) { this.updater = updater; this.initialDelayInSeconds = initialDelayInSeconds; this.periodInSeconds = periodInSeconds; } public void run() { executor.scheduleAtFixedRate(new Runnable() { @Override public void run() { updater.update(); } }, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS); } } public class Application { ConfigSource configSource = new ZookeeperConfigSource(/*省略参数*/); public static final RedisConfig redisConfig = new RedisConfig(configSource); public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource); public static final MySqlConfig mysqlConfig = new MysqlConfig(configSource); public static void main(String[] args) { ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300); redisConfigUpdater.run(); ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60); redisConfigUpdater.run(); } }
另一个新功能需求 一个新的监控功能需求。通过命令行来查看 Zookeeper 中的配置信息是比较麻烦的。所以,我们希望能有一种更加方便的配置信息查看方式。 在项目中开发一个内嵌的 SimpleHttpServer,输出项目的配置信息到一个固定的 HTTP 地址,比如:http://127.0.0.1:2389/config 。我们只需要在浏览器中输入这个地址,就可以显示出系统的配置信息。我们只想暴露 MySQL 和 Redis 的配置信息,不想暴露 Kafka 的配置信息。
public interface Updater { void update(); } public interface Viewer { String outputInPlainText(); Map<String, String> output(); } public class RedisConfig implemets Updater, Viewer { //...省略其他属性和方法... @Override public void update() { //... } @Override public String outputInPlainText() { //... } @Override public Map<String, String> output() { //...} } public class KafkaConfig implements Updater { //...省略其他属性和方法... @Override public void update() { //... } } public class MysqlConfig implements Viewer { //...省略其他属性和方法... @Override public String outputInPlainText() { //... } @Override public Map<String, String> output() { //...} } public class SimpleHttpServer { private String host; private int port; private Map<String, List<Viewer>> viewers = new HashMap<>(); public SimpleHttpServer(String host, int port) {//...} public void addViewers(String urlDirectory, Viewer viewer) { if (!viewers.containsKey(urlDirectory)) { viewers.put(urlDirectory, new ArrayList<Viewer>()); } this.viewers.get(urlDirectory).add(viewer); } public void run() { //... } } public class Application { ConfigSource configSource = new ZookeeperConfigSource(); public static final RedisConfig redisConfig = new RedisConfig(configSource); public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource); public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource); public static void main(String[] args) { ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300); redisConfigUpdater.run(); ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60); redisConfigUpdater.run(); SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389); simpleHttpServer.addViewer("/config", redisConfig); simpleHttpServer.addViewer("/config", mysqlConfig); simpleHttpServer.run(); } }
设计了两个功能非常单一的接口:Updater 和 Viewer。ScheduledUpdater 只依赖 Updater 这个跟热更新相关的接口,不需要被强迫去依赖不需要的 Viewer 接口,满足接口隔离原则。同理,SimpleHttpServer 只依赖跟查看信息相关的 Viewer 接口,不依赖不需要的 Updater 接口,也满足接口隔离原则。
总结
Updater、Viewer 职责更加单一,单一就意味了通用、复用性好。
总结
1. 如何理解“接口隔离原则”?理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。 2. 接口隔离原则与单一职责原则的区别单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
控制反转、依赖反转、依赖注入,这三者有何区别和联系?
控制反转(IOC)
所有的流程都由程序员来控制
public class UserServiceTest { public static boolean doTest() { // ... } public static void main(String[] args) {//这部分逻辑可以放到框架中 if (doTest()) { System.out.println("Test succeed."); } else { System.out.println("Test failed."); } } }
测试框架引入到工程中之后,我们只需要在框架预留的扩展点,也就是 TestCase 类中的 doTest() 抽象函数中,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的 main() 函数了
public abstract class TestCase { public void run() { if (doTest()) { System.out.println("Test succeed."); } else { System.out.println("Test failed."); } } public abstract boolean doTest(); } public class JunitApplication { private static final List<TestCase> testCases = new ArrayList<>(); public static void register(TestCase testCase) { testCases.add(testCase); } public static final void main(String[] args) { for (TestCase case: testCases) { case.run(); } }
public class UserServiceTest extends TestCase { @Override public boolean doTest() { // ... } } // 注册操作还可以通过配置的方式来实现,不需要程序员显示调用register() JunitApplication.register(new UserServiceTest();
就是典型的通过框架来实现“控制反转”的例子。框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。 这里的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架。 控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。
依赖注入(DI)
依赖注入跟控制反转恰恰相反,它是一种具体的编码技巧。依赖注入的英文翻译是 Dependency Injection,缩写为 DI
那到底什么是依赖注入呢?我们用一句话来概括就是:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。
用依赖注入和非依赖注入两种方式来实现
// 非依赖注入实现方式------------------------------ public class Notification { private MessageSender messageSender; public Notification() { this.messageSender = new MessageSender(); //此处有点像hardcode } public void sendMessage(String cellphone, String message) { //...省略校验逻辑等... this.messageSender.send(cellphone, message); } } public class MessageSender { public void send(String cellphone, String message) { //.... } } // 使用Notification Notification notification = new Notification(); // 依赖注入的实现方式------------------------------ public class Notification { private MessageSender messageSender; // 通过构造函数将messageSender传递进来 public Notification(MessageSender messageSender) { this.messageSender = messageSender; } public void sendMessage(String cellphone, String message) { //...省略校验逻辑等... this.messageSender.send(cellphone, message); } } //使用Notification MessageSender messageSender = new MessageSender(); Notification notification = new Notification(messageSender);
通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类。这一点在我们之前讲“开闭原则”的时候也提到过
public class Notification { private MessageSender messageSender; public Notification(MessageSender messageSender) { this.messageSender = messageSender; } public void sendMessage(String cellphone, String message) { this.messageSender.send(cellphone, message); } } //定义成接口---------------------------- public interface MessageSender { void send(String cellphone, String message); } // 短信发送类 public class SmsSender implements MessageSender { @Override public void send(String cellphone, String message) { //.... } } // 站内信发送类 public class InboxSender implements MessageSender { @Override public void send(String cellphone, String message) { //.... } } //使用Notification MessageSender messageSender = new SmsSender(); Notification notification = new Notification(messageSender);
掌握刚刚举的这个例子,就等于完全掌握了依赖注入。尽管依赖注入非常简单,但却非常有用
依赖注入框架(DI Framework)
在实际的软件开发中,一些项目可能会涉及几十、上百、甚至几百个类,类对象的创建和依赖注入会变得非常复杂。如果这部分工作都是靠程序员自己写代码来完成,容易出错且开发成本也比较高。而对象创建和依赖注入的工作,本身跟具体的业务无关,我们完全可以抽象成框架来自动完成。
这个框架就是“依赖注入框架”。我们只需要通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
Spring 框架的控制反转主要是通过依赖注入来实现的。不过这点区分并不是很明显
依赖反转原则(DIP)
高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。
所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。实际上,这条原则主要还是用来指导框架层面的设计,跟前面讲到的控制反转类似。我们拿 Tomcat 这个 Servlet 容器作为例子来解释一下。
Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。
总结
1. 控制反转 实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。 2. 依赖注入 依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。我们不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。 3. 依赖注入框架 我们通过依赖注入框架提供的扩展点,简单配置一下所有需要的类及其类与类之间依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。 4. 依赖反转原则 依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。
精选
“基于接口而非实现编程”与“依赖注入”的联系是二者都是从外部传入依赖对象而不是在内部去new一个出来。 区别是“基于接口而非实现编程”强调的是“接口”,强调依赖的对象是接口,而不是具体的实现类;而“依赖注入”不强调这个,类或接口都可以,只要是从外部传入不是在内部new出来都可以称为依赖注入。
1⃣️控制反转是一种编程思想,把控制权交给第三方。依赖注入是实现控制反转最典型的方法。 2⃣️依赖注入(对象)的方式要采用“基于接口而非实现编程”的原则,说白了就是依赖倒转。 3⃣️低层的实现要符合里氏替换原则。子类的可替换性,使得父类模块或依赖于抽象的高层模块无需修改,实现程序的可扩展性。
我为何说KISS、YAGNI原则看似简单,却经常被用错?
如何理解“KISS 原则”?
Keep It Simple and Stupid. Keep It Short and Simple. Keep It Simple and Straightforward.
尽量保持简单。
而 KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。
代码行数越少就越“简单”吗?
三段代码分析
// 第一种实现方式: 使用正则表达式 public boolean isValidIpAddressV1(String ipAddress) { if (StringUtils.isBlank(ipAddress)) return false; String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$"; return ipAddress.matches(regex); } 第一种实现方式利用的是正则表达式,只用三行代码就把这个问题搞定了。它的代码行数最少,那是不是就最符合 KISS 原则呢?答案是否定的。虽然代码行数最少,看似最简单,实际上却很复杂。这正是因为它使用了正则表达式。
// 第二种实现方式: 使用现成的工具类 public boolean isValidIpAddressV2(String ipAddress) { if (StringUtils.isBlank(ipAddress)) return false; String[] ipUnits = StringUtils.split(ipAddress, '.'); if (ipUnits.length != 4) { return false; } for (int i = 0; i < 4; ++i) { int ipUnitIntValue; try { ipUnitIntValue = Integer.parseInt(ipUnits[i]); } catch (NumberFormatException e) { return false; } if (ipUnitIntValue < 0 || ipUnitIntValue > 255) { return false; } if (i == 0 && ipUnitIntValue == 0) { return false; } } return true; } 第二种实现方式使用了 StringUtils 类、Integer 类提供的一些现成的工具函数,来处理 IP 地址字符串。第三种实现方式,不使用任何工具函数,而是通过逐一处理 IP 地址中的字符,来判断是否合法。从代码行数上来说,这两种方式差不多。但是,第三种要比第二种更加有难度,更容易写出 bug。从可读性上来说,第二种实现方式的代码逻辑更清晰、更好理解。所以,在这两种实现方式中,第二种实现方式更加“简单”,更加符合 KISS 原则。
// 第三种实现方式: 不使用任何工具类 public boolean isValidIpAddressV3(String ipAddress) { char[] ipChars = ipAddress.toCharArray(); int length = ipChars.length; int ipUnitIntValue = -1; boolean isFirstUnit = true; int unitsCount = 0; for (int i = 0; i < length; ++i) { char c = ipChars[i]; if (c == '.') { if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false; if (isFirstUnit && ipUnitIntValue == 0) return false; if (isFirstUnit) isFirstUnit = false; ipUnitIntValue = -1; unitsCount++; continue; } if (c < '0' || c > '9') { return false; } if (ipUnitIntValue == -1) ipUnitIntValue = 0; ipUnitIntValue = ipUnitIntValue * 10 + (c - '0'); } if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false; if (unitsCount != 3) return false; return true; } 第三种实现方式性能更高些,但我还是更倾向于选择第二种实现方法。那是因为第三种实现方式实际上是一种过度优化。除非 isValidIpAddress() 函数是影响系统性能的瓶颈代码,否则,这样优化的投入产出比并不高,增加了代码实现的难度、牺牲了代码的可读性,性能上的提升却并不明显。
代码逻辑复杂就违背 KISS 原则吗?
一段代码的逻辑复杂、实现难度大、可读性也不太好,是不是就一定违背 KISS 原则呢?
// KMP algorithm: a, b分别是主串和模式串;n, m分别是主串和模式串的长度。 public static int kmp(char[] a, int n, char[] b, int m) { int[] next = getNexts(b, m); int j = 0; for (int i = 0; i < n; ++i) { while (j > 0 && a[i] != b[j]) { // 一直找到a[i]和b[j] j = next[j - 1] + 1; } if (a[i] == b[j]) { ++j; } if (j == m) { // 找到匹配模式串的了 return i - m + 1; } } return -1; } // b表示模式串,m表示模式串的长度 private static int[] getNexts(char[] b, int m) { int[] next = new int[m]; next[0] = -1; int k = -1; for (int i = 1; i < m; ++i) { while (k != -1 && b[k + 1] != b[i]) { k = next[k]; } if (b[k + 1] == b[i]) { ++k; } next[i] = k; } return next; }
本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。
如何写出满足 KISS 原则的代码?
不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
评判代码是否简单,还有一个很有效的间接方法,那就是 code review。如果在 code review 的时候,同事对你的代码有很多疑问,那就说明你的代码有可能不够“简单”,需要优化啦。 一定不要过度设计,不要觉得简单的东西就没有技术含量。实际上,越是能用简单的方法解决复杂的问题,越能体现一个人的能力
YAGNI 跟 KISS 说的是一回事吗?
You Ain’t Gonna Need It。直译就是:你不会需要它。 它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。
比如,我们的系统暂时只用 Redis 存储配置信息,以后可能会用到 ZooKeeper。根据 YAGNI 原则,在未用到 ZooKeeper 之前,我们没必要提前编写这部分代码。当然,这并不是说我们就不需要考虑代码的扩展性。我们还是要预留好扩展点,等到需要的时候,再去实现 ZooKeeper 存储配置信息这部分代码。 再比如,我们不要在项目中提前引入不需要依赖的开发包。对于 Java 程序员来说,我们经常使用 Maven 或者 Gradle 来管理依赖的类库(library)。我发现,有些同事为了避免开发中 library 包缺失而频繁地修改 Maven 或者 Gradle 配置文件,提前往项目里引入大量常用的 library 包。实际上,这样的做法也是违背 YAGNI 原则的。
从刚刚的分析我们可以看出,YAGNI 原则跟 KISS 原则并非一回事儿。KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)。
总结
KISS 原则是保持代码可读和可维护的重要手段。KISS 原则中的“简单”并不是以代码行数来考量的。代码行数越少并不代表代码越简单,我们还要考虑逻辑复杂度、实现难度、代码的可读性等。而且,本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。除此之外,同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了。 对于如何写出满足 KISS 原则的代码,我还总结了下面几条指导原则: 不要使用同事可能不懂的技术来实现代码; 不要重复造轮子,要善于使用已经有的工具类库; 不要过度优化。
重复的代码就一定违背DRY吗?如何提高代码的复用性?
Don’t Repeat Yourself。中文直译为:不要重复自己。将它应用在编程中,可以理解为:不要写重复的代码。
DRY 原则(Don’t Repeat Yourself)
实现逻辑重复
一段代码是否违反了 DRY 原则。如果违反了,你觉得应该如何重构,才能让它满足 DRY 原则?如果没有违反,那又是为什么呢?
public class UserAuthenticator { public void authenticate(String username, String password) { if (!isValidUsername(username)) { // ...throw InvalidUsernameException... } if (!isValidPassword(password)) { // ...throw InvalidPasswordException... } //...省略其他代码... } private boolean isValidUsername(String username) { // check not null, not empty if (StringUtils.isBlank(username)) { return false; } // check length: 4~64 int length = username.length(); if (length < 4 || length > 64) { return false; } // contains only lowcase characters if (!StringUtils.isAllLowerCase(username)) { return false; } // contains only a~z,0~9,dot for (int i = 0; i < length; ++i) { char c = username.charAt(i); if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') { return false; } } return true; }
private boolean isValidPassword(String password) { // check not null, not empty if (StringUtils.isBlank(password)) { return false; } // check length: 4~64 int length = password.length(); if (length < 4 || length > 64) { return false; } // contains only lowcase characters if (!StringUtils.isAllLowerCase(password)) { return false; } // contains only a~z,0~9,dot for (int i = 0; i < length; ++i) { char c = password.charAt(i); if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') { return false; } } return true; } }
改造后
public class UserAuthenticatorV2 { public void authenticate(String userName, String password) { if (!isValidUsernameOrPassword(userName)) { // ...throw InvalidUsernameException... } if (!isValidUsernameOrPassword(password)) { // ...throw InvalidPasswordException... } } private boolean isValidUsernameOrPassword(String usernameOrPassword) { //省略实现逻辑 //跟原来的isValidUsername()或isValidPassword()的实现逻辑一样... return true; } }
分析 在代码中,有两处非常明显的重复的代码片段,看起来明显违反 DRY 原则。 改造后 合并之后的 isValidUserNameOrPassword() 函数,负责两件事情:验证用户名和验证密码,违反了“单一职责原则”和“接口隔离原则”。实际上,即便将两个函数合并成 isValidUserNameOrPassword(),代码仍然存在问题。 因为 isValidUserName() 和 isValidPassword() 两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情 尽管代码的实现逻辑是相同的,但语义不同,我们判定它并不违反 DRY 原则。对于包含重复代码的问题,我们可以通过抽象成更细粒度函数的方式来解决。比如将校验只包含 a~z、0~9、dot 的逻辑封装成 boolean onlyContains(String str, String charlist); 函数。
功能语义重复
在同一个项目代码中有下面两个函数:isValidIp() 和 checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法的。 在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了 DRY 原则 假设我们不统一实现思路,那有些地方调用了 isValidIp() 函数,有些地方又调用了 checkIfIpValid() 函数,这就会导致代码看起来很奇怪,相当于给代码“埋坑”,给不熟悉这部分代码的同事增加了阅读的难度。同事有可能研究了半天,觉得功能是一样的,但又有点疑惑,觉得是不是有更高深的考量,才定义了两个功能类似的函数,最终发现居然是代码设计的问题。
public boolean isValidIp(String ipAddress) { if (StringUtils.isBlank(ipAddress)) return false; String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$"; return ipAddress.matches(regex); } public boolean checkIfIpValid(String ipAddress) { if (StringUtils.isBlank(ipAddress)) return false; String[] ipUnits = StringUtils.split(ipAddress, '.'); if (ipUnits.length != 4) { return false; } for (int i = 0; i < 4; ++i) { int ipUnitIntValue; try { ipUnitIntValue = Integer.parseInt(ipUnits[i]); } catch (NumberFormatException e) { return false; } if (ipUnitIntValue < 0 || ipUnitIntValue > 255) { return false; } if (i == 0 && ipUnitIntValue == 0) { return false; } } return true; }
代码执行重复
UserService 中 login() 函数用来校验用户登录是否成功。如果失败,就返回异常;如果成功,就返回用户信息。 这段代码,既没有逻辑重复,也没有语义重复,但仍然违反了 DRY 原则。这是因为代码中存在“执行重复”。我们一块儿来看下,到底哪些代码被重复执行了? 重复执行最明显的一个地方,就是在 login() 函数中,email 的校验逻辑被执行了两次。一次是在调用 checkIfUserExisted() 函数的时候,另一次是调用 getUserByEmail() 函数的时候。这个问题解决起来比较简单,我们只需要将校验逻辑从 UserRepo 中移除,统一放到 UserService 中就可以了。 除此之外,代码中还有一处比较隐蔽的执行重复,不知道你发现了没有?实际上,login() 函数并不需要调用 checkIfUserExisted() 函数,只需要调用一次 getUserByEmail() 函数,从数据库中获取到用户的 email、password 等信息,然后跟用户输入的 email、password 信息做对比,依次判断是否登录成功。
public class UserService { private UserRepo userRepo;//通过依赖注入或者IOC框架注入 public User login(String email, String password) { boolean existed = userRepo.checkIfUserExisted(email, password); if (!existed) { // ... throw AuthenticationFailureException... } User user = userRepo.getUserByEmail(email); return user; } } public class UserRepo { public boolean checkIfUserExisted(String email, String password) { if (!EmailValidation.validate(email)) { // ... throw InvalidEmailException... } if (!PasswordValidation.validate(password)) { // ... throw InvalidPasswordException... } //...query db to check if email&password exists... } public User getUserByEmail(String email) { if (!EmailValidation.validate(email)) { // ... throw InvalidEmailException... } //...query db to get user by email... } }
重构后
public class UserService { private UserRepo userRepo;//通过依赖注入或者IOC框架注入 public User login(String email, String password) { if (!EmailValidation.validate(email)) { // ... throw InvalidEmailException... } if (!PasswordValidation.validate(password)) { // ... throw InvalidPasswordException... } User user = userRepo.getUserByEmail(email); if (user == null || !password.equals(user.getPassword()) { // ... throw AuthenticationFailureException... } return user; } } public class UserRepo { public boolean checkIfUserExisted(String email, String password) { //...query db to check if email&password exists } public User getUserByEmail(String email) { //...query db to get user by email... } }
代码复用性(Code Reusability)
什么是代码的复用性?
代码复用性(Code Reusability)、代码复用(Code Resue)和 DRY 原则。
代码复用表示一种行为:我们在开发新功能的时候,尽量复用已经存在的代码。代码的可复用性表示一段代码可被复用的特性或能力:我们在编写代码的时候,让代码尽量可复用。DRY 原则是一条原则:不要写重复的代码。
首先,“不重复”并不代表“可复用”。在一个项目代码中,可能不存在任何重复的代码,但也并不表示里面有可复用的代码,不重复和可复用完全是两个概念。所以,从这个角度来说,DRY 原则跟代码的可复用性讲的是两回事。 其次,“复用”和“可复用性”关注角度不同。代码“可复用性”是从代码开发者的角度来讲的,“复用”是从代码使用者的角度来讲的。比如,A 同事编写了一个 UrlUtils 类,代码的“可复用性”很好。B 同事在开发新功能的时候,直接“复用”A 同事编写的 UrlUtils 类。 “复用”这个概念不仅可以指导细粒度的模块、类、函数的设计开发,实际上,一些框架、类库、组件等的产生也都是为了达到复用的目的。比如,Spring 框架、Google Guava 类库、UI 组件等等。
怎么提高代码复用性?
7条
减少代码耦合
对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。
满足单一职责原则
如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。
模块化
这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。
业务与非业务逻辑分离
越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。
通用代码下沉
从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。
继承、多态、抽象、封装
在讲面向对象特性的时候,我们讲到,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。
应用模板等设计模式
一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。
辩证思考和灵活应用
实际上,除非有非常明确的复用需求,否则,为了暂时用不到的复用需求,花费太多的时间、精力,投入太多的开发成本,并不是一个值得推荐的做法。这也违反我们之前讲到的 YAGNI 原则。 除此之外,有一个著名的原则,叫作“Rule of Three”。这条原则可以用在很多行业和场景中,你可以自己去研究一下。如果把这个原则用在这里,那就是说,我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后我们开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用。
总结
1.DRY 原则 我们今天讲了三种代码重复的情况:实现逻辑重复、功能语义重复、代码执行重复。实现逻辑重复,但功能语义不重复的代码,并不违反 DRY 原则。实现逻辑不重复,但功能语义重复的代码,也算是违反 DRY 原则。除此之外,代码执行重复也算是违反 DRY 原则。 2. 代码复用性 今天,我们讲到提高代码可复用性的一些方法,有以下 7 点。减少代码耦合满足单一职责原则模块化业务与非业务逻辑分离通用代码下沉继承、多态、抽象、封装应用模板等设计模式实际上,除了上面讲到的这些方法之外,复用意识也非常重要。在设计每个模块、类、函数的时候,要像设计一个外部 API 一样去思考它的复用性。我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用。相比于代码的可复用性,DRY 原则适用性更强一些。我们可以不写可复用的代码,但一定不能写重复的代码。
规范与重构
为了保证重构不出错,有哪些非常能落地的技术手段?
很多程序员对重构这种做法还是非常认同的,面对项目中的烂代码,也想重构一下,但又担心重构之后出问题,出力不讨好。确实,如果你要重构的代码是别的同事开发的,你不是特别熟悉,在没有任何保障的情况下,重构引入 bug 的风险还是很大的。 那如何保证重构不出错呢?你需要熟练掌握各种设计原则、思想、模式,还需要对所重构的业务和代码有足够的了解。除了这些个人能力因素之外,最可落地执行、最有效的保证重构不出错的手段应该就是单元测试(Unit Testing)了。当重构完成之后,如果新的代码仍然能通过单元测试,那就说明代码原有逻辑的正确性未被破坏,原有的外部可见行为未变,符合上一节课中我们对重构的定义。
什么是单元测试?
集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。
public class Text { private String content; public Text(String content) { this.content = content; } /** * 将字符串转化成数字,忽略字符串中的首尾空格; * 如果字符串中包含除首尾空格之外的非数字字符,则返回null。 */ public Integer toNumber() { if (content == null || content.isEmpty()) { return null; } //...省略代码实现... return null; } }
为了保证测试的全面性,针对 toNumber() 函数,我们需要设计下面这样几个测试用例。如果字符串只包含数字:“123”,toNumber() 函数输出对应的整数:123。如果字符串是空或者 null,toNumber() 函数返回:null。如果字符串包含首尾空格:“ 123”,“123 ”,“ 123 ”,toNumber() 返回对应的整数:123。如果字符串包含多个首尾空格:“ 123 ”,toNumber() 返回对应的整数:123;如果字符串包含非数字字符:“123a4”,“123 4”,toNumber() 返回 null;
public class Assert { public static void assertEquals(Integer expectedValue, Integer actualValue) { if (actualValue != expectedValue) { String message = String.format( "Test failed, expected: %d, actual: %d.", expectedValue, actualValue); System.out.println(message); } else { System.out.println("Test succeeded."); } } public static boolean assertNull(Integer actualValue) { boolean isNull = actualValue == null; if (isNull) { System.out.println("Test succeeded."); } else { System.out.println("Test failed, the value is not null:" + actualValue); } return isNull; } } public class TestCaseRunner { public static void main(String[] args) { System.out.println("Run testToNumber()"); new TextTest().testToNumber(); System.out.println("Run testToNumber_nullorEmpty()"); new TextTest().testToNumber_nullorEmpty(); System.out.println("Run testToNumber_containsLeadingAndTrailingSpaces()"); new TextTest().testToNumber_containsLeadingAndTrailingSpaces(); System.out.println("Run testToNumber_containsMultiLeadingAndTrailingSpaces()"); new TextTest().testToNumber_containsMultiLeadingAndTrailingSpaces(); System.out.println("Run testToNumber_containsInvalidCharaters()"); new TextTest().testToNumber_containsInvalidCharaters(); } } public class TextTest { public void testToNumber() { Text text = new Text("123"); Assert.assertEquals(123, text.toNumber()); } public void testToNumber_nullorEmpty() { Text text1 = new Text(null); Assert.assertNull(text1.toNumber()); Text text2 = new Text(""); Assert.assertNull(text2.toNumber()); } public void testToNumber_containsLeadingAndTrailingSpaces() { Text text1 = new Text(" 123"); Assert.assertEquals(123, text1.toNumber()); Text text2 = new Text("123 "); Assert.assertEquals(123, text2.toNumber()); Text text3 = new Text(" 123 "); Assert.assertEquals(123, text3.toNumber()); } public void testToNumber_containsMultiLeadingAndTrailingSpaces() { Text text1 = new Text(" 123"); Assert.assertEquals(123, text1.toNumber()); Text text2 = new Text("123 "); Assert.assertEquals(123, text2.toNumber()); Text text3 = new Text(" 123 "); Assert.assertEquals(123, text3.toNumber()); } public void testToNumber_containsInvalidCharaters() { Text text1 = new Text("123a4"); Assert.assertNull(text1.toNumber()); Text text2 = new Text("123 4"); Assert.assertNull(text2.toNumber()); } }
为什么要写单元测试?
单元测试除了能有效地为重构保驾护航之外,也是保证代码质量最有效的两个手段之一(另一个是 Code Review)。
1. 单元测试能有效地帮你发现代码中的 bug
得益于此,我写的代码几乎是 bug free 的。这也节省了我很多 fix 低级 bug 的时间,能够有时间去做其他更有意义的事情,我也因此在工作上赢得了很多人的认可
2. 写单元测试能帮你发现代码设计上的问题
代码的可测试性是评判代码质量的一个重要标准
3. 单元测试是对集成测试的有力补充
尽管单元测试无法完全替代集成测试,但如果我们能保证每个类、每个函数都能按照我们的预期来执行,底层 bug 少了,那组装起来的整个系统,出问题的概率也就相应减少了。
4. 写单元测试的过程本身就是代码重构的过程
要把持续重构作为开发的一部分来执行,那写单元测试实际上就是落地执行持续重构的一个有效途径。
5. 阅读单元测试能帮助你快速熟悉代码
阅读代码最有效的手段,就是先了解它的业务背景和设计思路,然后再去看代码,这样代码读起来就会轻松很多。
6. 单元测试是 TDD 可落地执行的改进方案
单元测试正好是对 TDD 的一种改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码。这个开发流程更加容易被接受,更加容易落地执行,而且又兼顾了 TDD 的优点。
如何编写单元测试?
比如,Java 中比较出名的单元测试框架有 Junit、TestNG、Spring Test 等。这些框架提供了通用的执行流程(比如执行测试用例的 TestCaseRunner)和工具类库(比如各种 Assert 判断函数)等。借助它们,我们在编写测试代码的时候,只需要关注测试用例本身的编写即可。
几点总结
1. 写单元测试真的是件很耗时的事情吗? 尽管单元测试的代码量可能是被测代码本身的 1~2 倍,写的过程很繁琐,但并不是很耗时。毕竟我们不需要考虑太多代码设计上的问题,测试代码实现起来也比较简单。不同测试用例之间的代码差别可能并不是很大,简单 copy-paste 改改就行。 2. 对单元测试的代码质量有什么要求吗? 单元测试毕竟不会在产线上运行,而且每个类的测试代码也比较独立,基本不互相依赖。所以,相对于被测代码,我们对单元测试代码的质量可以放低一些要求。命名稍微有些不规范,代码稍微有些重复,也都是没有问题的。 3. 单元测试只要覆盖率高就够了吗? 单元测试覆盖率是比较容易量化的指标,常常作为单元测试写得好坏的评判标准。有很多现成的工具专门用来做覆盖率统计,比如,JaCoCo、Cobertura、Emma、Clover。覆盖率的计算方式有很多种,比较简单的是语句覆盖,稍微高级点的有:条件覆盖、判定覆盖、路径覆盖。不管覆盖率的计算方式如何高级,将覆盖率作为衡量单元测试质量的唯一标准是不合理的。实际上,更重要的是要看测试用例是否覆盖了所有可能的情况,特别是一些 corner case。 4. 写单元测试需要了解代码的实现逻辑吗? 单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。我们切不可为了追求覆盖率,逐行阅读代码,然后针对实现逻辑编写单元测试。否则,一旦对代码进行重构,在代码的外部行为不变的情况下,对代码的实现逻辑进行了修改,那原本的单元测试都会运行失败,也就起不到为重构保驾护航的作用了,也违背了我们写单元测试的初衷。 5. 如何选择单元测试框架? 写单元测试本身不需要太复杂的技术,大部分单元测试框架都能满足。在公司内部,起码团队内部需要统一单元测试框架。如果自己写的代码用已经选定的单元测试框架无法测试,那多半是代码写得不够好,代码的可测试性不够好。这个时候,我们要重构自己的代码,让其更容易测试,而不是去找另一个更加高级的单元测试框架。
单元测试为何难落地执行?
写单元测试确实是一件考验耐心的活儿。一般情况下,单元测试的代码量要大于被测试代码量,甚至是要多出好几倍。很多人往往会觉得写单元测试比较繁琐,并且没有太多挑战,而不愿意去做。有很多团队和项目在刚开始推行单元测试的时候,还比较认真,执行得比较好。但当开发任务紧了之后,就开始放低对单元测试的要求,一旦出现破窗效应,慢慢的,大家就都不写了,这种情况很常见。
除此之外,还有人觉得,有了测试团队,写单元测试就是浪费时间,没有必要。程序员这一行业本该是智力密集型的,但现在很多公司把它搞成劳动密集型的,包括一些大厂,在开发过程中,既没有单元测试,也没有 Code Review 流程。即便有,做的也是差强人意。写好代码直接提交,然后丢给黑盒测试狠命去测,测出问题就反馈给开发团队再修改,测不出的问题就留在线上出了问题再修复。
总结
1. 什么是单元测试? 单元测试是代码层面的测试,由研发自己来编写,用于测试“自己”编写的代码的逻辑的正确性。单元测试顾名思义是测试一个“单元”,有别于集成测试,这个“单元”一般是类或函数,而不是模块或者系统。 2. 为什么要写单元测试? 写单元测试的过程本身就是代码 Code Review 和重构的过程,能有效地发现代码中的 bug 和代码设计上的问题。除此之外,单元测试还是对集成测试的有力补充,还能帮助我们快速熟悉代码,是 TDD 可落地执行的改进方案。 3. 如何编写单元测试? 写单元测试就是针对代码设计各种测试用例,以覆盖各种输入、异常、边界情况,并将其翻译成代码。我们可以利用一些测试框架来简化单元测试的编写。除此之外,对于单元测试,我们需要建立以下正确的认知:编写单元测试尽管繁琐,但并不是太耗时;我们可以稍微放低对单元测试代码质量的要求;覆盖率作为衡量单元测试质量的唯一标准是不合理的;单元测试不要依赖被测代码的具体实现逻辑;单元测试框架无法测试,多半是因为代码的可测试性不好。 4. 单元测试为何难落地执行? 一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写;另一方面,国内研发比较偏向“快、糙、猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾。最后,关键问题还是团队没有建立对单元测试正确的认识,觉得可有可无,单靠督促很难执行得很好。
什么是代码的可测试性?如何写出可测试性好的代码?
编写可测试代码案例实战
Transaction 是经过我抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。Transaction 类中的 execute() 函数负责执行转账操作,将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用 WalletRpcService RPC 服务来完成的。除此之外,代码中还涉及一个分布式锁 DistributedLock 单例类,用来避免 Transaction 并发执行,导致用户的钱被重复转出。
public class Transaction { private String id; private Long buyerId; private Long sellerId; private Long productId; private String orderId; private Long createTimestamp; private Double amount; private STATUS status; private String walletTransactionId; // ...get() methods... public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) { if (preAssignedId != null && !preAssignedId.isEmpty()) { this.id = preAssignedId; } else { this.id = IdGenerator.generateTransactionId(); } if (!this.id.startWith("t_")) { this.id = "t_" + preAssignedId; } this.buyerId = buyerId; this.sellerId = sellerId; this.productId = productId; this.orderId = orderId; this.status = STATUS.TO_BE_EXECUTD; this.createTimestamp = System.currentTimestamp(); } public boolean execute() throws InvalidTransactionException { if ((buyerId == null || (sellerId == null || amount < 0.0) { throw new InvalidTransactionException(...); } if (status == STATUS.EXECUTED) return true; boolean isLocked = false; try { isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id); if (!isLocked) { return false; // 锁定未成功,返回false,job兜底执行 } if (status == STATUS.EXECUTED) return true; // double check long executionInvokedTimestamp = System.currentTimestamp(); if (executionInvokedTimestamp - createdTimestap > 14days) { this.status = STATUS.EXPIRED; return false; } WalletRpcService walletRpcService = new WalletRpcService(); String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount); if (walletTransactionId != null) { this.walletTransactionId = walletTransactionId; this.status = STATUS.EXECUTED; return true; } else { this.status = STATUS.FAILED; return false; } } finally { if (isLocked) { RedisDistributedLock.getSingletonIntance().unlockTransction(id); } } } }
上面案例的问题
1. 需要搭建 Redis 服务和 Wallet RPC 服务。搭建和维护的成本比较高。 2. 需要保证将伪造的 transaction 数据发送给 Wallet RPC 服务之后,能够正确返回我们期望的结果,然而 Wallet RPC 服务有可能是第三方(另一个团队开发维护的)的服务,并不是我们可控的。换句话说,并不是我们想让它返回什么数据就返回什么。 3. Transaction 的执行跟 Redis、RPC 服务通信,需要走网络,耗时可能会比较长,对单元测试本身的执行性能也会有影响。 4. 网络的中断、超时、Redis、RPC 服务的不可用,都会影响单元测试的执行。
解决上面案例问题
单元测试主要是测试程序员自己编写的代码逻辑的正确性,并非是端到端的集成测试,它不需要测试所依赖的外部系统(分布式锁、Wallet RPC 服务)的逻辑正确性。这种解依赖的方法就叫作“mock”。所谓的 mock 就是用一个“假”的服务替换真正的服务。mock 的服务完全在我们的控制之下,模拟输出我们想要的数据。
RPC调用问题
通过继承 WalletRpcService 类,并且重写其中的 moveMoney() 函数的方式来实现 mock。具体的代码实现如下所示。通过 mock 的方式,我们可以让 moveMoney() 返回任意我们想要的数据,完全在我们的控制范围内,并且不需要真正进行网络通信。 【依赖注入是实现代码可测试性的最有效的手段】
public class MockWalletRpcServiceOne extends WalletRpcService { public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) { return "123bac"; } } public class MockWalletRpcServiceTwo extends WalletRpcService { public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) { return null; } } public class Transaction { //... // 添加一个成员变量及其set方法 private WalletRpcService walletRpcService; public void setWalletRpcService(WalletRpcService walletRpcService) { this.walletRpcService = walletRpcService; } // ... public boolean execute() { // ... // 删除下面这一行代码 // WalletRpcService walletRpcService = new WalletRpcService(); // ... } }
public void testExecute() { Long buyerId = 123L; Long sellerId = 234L; Long productId = 345L; Long orderId = 456L; Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId); // 使用mock对象来替代真正的RPC服务 transaction.setWalletRpcService(new MockWalletRpcServiceOne()): boolean executedResult = transaction.execute(); assertTrue(executedResult); assertEquals(STATUS.EXECUTED, transaction.getStatus()); }
Redis锁问题
如果 RedisDistributedLock 是我们自己维护的,可以自由修改、重构,那我们可以将其改为非单例的模式,或者定义一个接口,比如 IDistributedLock,让 RedisDistributedLock 实现这个接口。这样我们就可以像前面 WalletRpcService 的替换方式那样,替换 RedisDistributedLock 为 MockRedisDistributedLock 了。
public class TransactionLock { public boolean lock(String id) { return RedisDistributedLock.getSingletonIntance().lockTransction(id); } public void unlock() { RedisDistributedLock.getSingletonIntance().unlockTransction(id); } } public class Transaction { //... private TransactionLock lock; public void setTransactionLock(TransactionLock lock) { this.lock = lock; } public boolean execute() { //... try { isLocked = lock.lock(); //... } finally { if (isLocked) { lock.unlock(); } } //... } }
public void testExecute() { Long buyerId = 123L; Long sellerId = 234L; Long productId = 345L; Long orderId = 456L; TransactionLock mockLock = new TransactionLock() { public boolean lock(String id) { return true; } public void unlock() {} }; Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId); transaction.setWalletRpcService(new MockWalletRpcServiceOne()); transaction.setTransactionLock(mockLock); boolean executedResult = transaction.execute(); assertTrue(executedResult); assertEquals(STATUS.EXECUTED, transaction.getStatus()); }
过期问题
我们将 transaction 的创建时间 createdTimestamp 设置为 14 天前,也就是说,当单元测试代码运行的时候,transaction 一定是处于过期状态。但是,如果在 Transaction 类中,并没有暴露修改 createdTimestamp 成员变量的 set 方法(也就是没有定义 setCreatedTimestamp() 函数)呢?
public void testExecute_with_TransactionIsExpired() { Long buyerId = 123L; Long sellerId = 234L; Long productId = 345L; Long orderId = 456L; Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId); transaction.setCreatedTimestamp(System.currentTimestamp() - 14days); boolean actualResult = transaction.execute(); assertFalse(actualResult); assertEquals(STATUS.EXPIRED, transaction.getStatus()); }
就是代码中包含跟“时间”有关的“未决行为”逻辑。我们一般的处理方式是将这种未决行为逻辑重新封装。针对 Transaction 类,我们只需要将交易是否过期的逻辑,封装到 isExpired() 函数中即可
public class Transaction { protected boolean isExpired() { long executionInvokedTimestamp = System.currentTimestamp(); return executionInvokedTimestamp - createdTimestamp > 14days; } public boolean execute() throws InvalidTransactionException { //... if (isExpired()) { this.status = STATUS.EXPIRED; return false; } //... } } //重构后 public void testExecute_with_TransactionIsExpired() { Long buyerId = 123L; Long sellerId = 234L; Long productId = 345L; Long orderId = 456L; Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) { protected boolean isExpired() { return true; } }; boolean actualResult = transaction.execute(); assertFalse(actualResult); assertEquals(STATUS.EXPIRED, transaction.getStatus()); }
总结
可测试性差的代码,本身代码设计得也不够好,很多地方都没有遵守我们之前讲到的设计原则和思想,比如“基于接口而非实现编程”思想、依赖反转原则等。重构之后的代码,不仅可测试性更好,而且从代码设计的角度来说,也遵从了经典的设计原则和思想。 代码的可测试性可以从侧面上反应代码设计是否合理
其他常见的 Anti-Patterns
1. 未决行为
所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。对于这一点,在刚刚的实战案例中我们已经讲到,你可以利用刚才讲到的方法,试着重构一下下面的代码,并且为它编写单元测试。
public class Demo { public long caculateDelayDays(Date dueTime) { long currentTimestamp = System.currentTimeMillis(); if (dueTime.getTime() >= currentTimestamp) { return 0; } long delayTime = currentTimestamp - dueTime.getTime(); long delayDays = delayTime / 86400; return delayDays; } }
2. 全局变量
全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难。
public class RangeLimiter { private static AtomicInteger position = new AtomicInteger(0); public static final int MAX_LIMIT = 5; public static final int MIN_LIMIT = -5; public boolean move(int delta) { int currentPos = position.addAndGet(delta); boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT); return betweenRange; } } public class RangeLimiterTest { public void testMove_betweenRange() { RangeLimiter rangeLimiter = new RangeLimiter(); assertTrue(rangeLimiter.move(1)); assertTrue(rangeLimiter.move(3)); assertTrue(rangeLimiter.move(-5)); } public void testMove_exceedRange() { RangeLimiter rangeLimiter = new RangeLimiter(); assertFalse(rangeLimiter.move(6)); } }
3. 静态方法
静态方法跟全局变量一样,也是一种面向过程的编程思维。在代码中调用静态方法,有时候会导致代码不易测试。主要原因是静态方法也很难 mock。但是,这个要分情况来看。只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下,我们才需要在单元测试中 mock 这个静态方法。除此之外,如果只是类似 Math.abs() 这样的简单静态方法,并不会影响代码的可测试性,因为本身并不需要 mock。
4. 复杂继承
如果我们利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可。
5. 高耦合代码
如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那我们在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。不管是从代码设计的角度来说,还是从编写单元测试的角度来说,这都是不合理的。
总结
1. 什么是代码的可测试性? 粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。 2. 编写可测试性代码的最有效手段依赖注入是编写可测试性代码的最有效手段。 通过依赖注入,我们在编写单元测试的时候,可以通过 mock 的方法解依赖外部服务,这也是我们在编写单元测试的过程中最有技术挑战的地方。 3. 常见的 Anti-Patterns 常见的测试不友好的代码有下面这 5 种: 代码中包含未决行为逻辑 滥用可变全局变量 滥用静态方法 使用复杂的继承关系 高度耦合的代码
如何通过封装、抽象、模块化、中间层等解耦代码?
“解耦”为何如此重要?
如果说重构是保证代码质量不至于腐化到无可救药地步的有效手段,那么利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。 “高内聚、松耦合”的特性可以让我们聚焦在某一模块或类中,不需要了解太多其他模块或类的代码,让我们的焦点不至于过于发散。 代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差
代码是否需要“解耦”?
那就是把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。 如果依赖关系复杂、混乱,那从代码结构上来讲,可读性和可维护性肯定不是太好,那我们就需要考虑是否可以通过解耦的方法,让依赖关系变得清晰、简单。当然,这种判断还是有比较强的主观色彩,但是可以作为一种参考和梳理依赖的手段,配合间接的衡量标准一块来使用。
如何给代码“解耦”?
1. 封装与抽象
封装和抽象作为两个非常通用的设计思想,可以应用在很多设计场景中,比如系统、模块、lib、组件、接口、类等等的设计。封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。
2. 中间层
引入中间层能简化模块或类之间的依赖关系。
引入中间层前后的依赖关系对比图。在引入数据存储中间层之前,A、B、C 三个模块都要依赖内存一级缓存、Redis 二级缓存、DB 持久化存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。从图可以看出,中间层的引入明显地简化了依赖关系,让代码结构更加清晰。
在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。比如,某个接口设计得有问题,我们需要修改它的定义,同时,所有调用这个接口的代码都要做相应的改动。如果新开发的代码也用到这个接口,那开发就跟重构冲突了。为了让重构能小步快跑,我们可以分下面四个阶段来完成接口的修改。
第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。 第二阶段:新开发的代码依赖中间层提供的新接口。 第三阶段:将依赖老接口的代码改为调用新接口。 第四阶段:确保所有的代码都调用新接口之后,删除掉老的接口。
3. 模块化
对于一个大型复杂系统来说,没有人能掌控所有的细节。之所以我们能搭建出如此复杂的系统,并且能维护得了,最主要的原因就是将系统划分成各个独立的模块,让不同的人负责不同的模块。 聚焦到软件开发上面,很多大型软件(比如 Windows)之所以能做到几百、上千人有条不紊地协作开发,也归功于模块化做得好。不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统。 在开发代码的时候,一定要有模块化意识,将每个模块都当作一个独立的 lib 一样来开发,只提供封装了内部实现细节的接口给其他模块使用 实际上,从刚刚的讲解中我们也可以发现,模块化的思想无处不在,像 SOA、微服务、lib 库、系统内模块划分,甚至是类、函数的设计,都体现了模块化思想。如果追本溯源,模块化思想更加本质的东西就是分而治之。
4. 其他设计思想和原则
“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。
单一职责原则 我们前面提到,内聚性和耦合性并非独立的。高内聚会让代码更加松耦合,而实现高内聚的重要指导原则就是单一职责原则。模块或者类的职责设计得单一,而不是大而全,那依赖它的类和它依赖的类就会比较少,代码耦合也就相应的降低了。 基于接口而非实现编程 基于接口而非实现编程能通过接口这样一个中间层,隔离变化和具体的实现。这样做的好处是,在有依赖关系的两个模块或类之间,一个模块或者类的改动,不会影响到另一个模块或类。实际上,这就相当于将一种强依赖关系(强耦合)解耦为了弱依赖关系(弱耦合)。 依赖注入 跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。尽管依赖注入无法将本应该有依赖关系的两个类,解耦为没有依赖关系,但可以让耦合关系没那么紧密,容易做到插拔替换。 多用组合少用继承 我们知道,继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活,所以,对于继承结构比较复杂的代码,利用组合来替换继承,也是一种解耦的有效手段。 迪米特法则 迪米特法则讲的是,不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。从定义上,我们明显可以看出,这条原则的目的就是为了实现代码的松耦合。至于如何应用这条原则来解耦代码,你可以回过头去阅读一下第 22 讲,这里我就不赘述了。 除了上面讲到的这些设计思想和原则之外,还有一些设计模式也是为了解耦依赖,比如观察者模式,有关这一部分的内容,我们留在设计模式模块中慢慢讲解。
总结
1.“解耦”为何如此重要? 过于复杂的代码往往在可读性、可维护性上都不友好。解耦保证代码松耦合、高内聚,是控制代码复杂度的有效手段。代码高内聚、松耦合,也就是意味着,代码结构清晰、分层模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。 2. 代码是否需要“解耦”? 间接的衡量标准有很多,比如,看修改代码是否牵一发而动全身。直接的衡量标准是把模块与模块、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。 3. 如何给代码“解耦”? 给代码解耦的方法有:封装与抽象、中间层、模块化,以及一些其他的设计思想与原则,比如:单一职责原则、基于接口而非实现编程、依赖注入、多用组合少用继承、迪米特法则等。当然,还有一些设计模式,比如观察者模式。
创建型
创建型模式主要解决对象的创建问题,封装复杂的创建过程,解耦对象的创建代码和使用代码。
单例模式
简单
多线程问题不一致
多线程问题,加锁后还是new了多个
工厂模式
大部分工厂类都是以“Factory”这个单词结尾的
分类
简单工厂(Simple Factory)
public class RuleConfigParserFactory { private static final Map<String, RuleConfigParser> cachedParsers = new HashMap<>(); static { cachedParsers.put("json", new JsonRuleConfigParser()); cachedParsers.put("xml", new XmlRuleConfigParser()); cachedParsers.put("yaml", new YamlRuleConfigParser()); cachedParsers.put("properties", new PropertiesRuleConfigParser()); } public static IRuleConfigParser createParser(String configFormat) { if (configFormat == null || configFormat.isEmpty()) { return null;//返回null还是IllegalArgumentException全凭你自己说了算 } IRuleConfigParser parser = cachedParsers.get(configFormat.toLowerCase()); return parser; } }
工厂方法(Factory Method)
当我们新增一种 parser 的时候,只需要新增一个实现了 IRuleConfigParserFactory 接口的 Factory 类即可。所以,工厂方法模式比起简单工厂模式更加符合开闭原则。
public interface IRuleConfigParserFactory { IRuleConfigParser createParser(); } public class JsonRuleConfigParserFactory implements IRuleConfigParserFactory { @Override public IRuleConfigParser createParser() { return new JsonRuleConfigParser(); } } public class XmlRuleConfigParserFactory implements IRuleConfigParserFactory { @Override public IRuleConfigParser createParser() { return new XmlRuleConfigParser(); } } public class YamlRuleConfigParserFactory implements IRuleConfigParserFactory { @Override public IRuleConfigParser createParser() { return new YamlRuleConfigParser(); } } public class PropertiesRuleConfigParserFactory implements IRuleConfigParserFactory { @Override public IRuleConfigParser createParser() { return new PropertiesRuleConfigParser(); } }
public class RuleConfigSource { public RuleConfig load(String ruleConfigFilePath) { String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); IRuleConfigParserFactory parserFactory = RuleConfigParserFactoryMap.getParserFactory(ruleConfigFileExtension); if (parserFactory == null) { throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath); } IRuleConfigParser parser = parserFactory.createParser(); String configText = ""; //从ruleConfigFilePath文件中读取配置文本到configText中 RuleConfig ruleConfig = parser.parse(configText); return ruleConfig; } private String getFileExtension(String filePath) { //...解析文件名获取扩展名,比如rule.json,返回json return "json"; } } //因为工厂类只包含方法,不包含成员变量,完全可以复用, //不需要每次都创建新的工厂类对象,所以,简单工厂模式的第二种实现思路更加合适。 public class RuleConfigParserFactoryMap { //工厂的工厂 private static final Map<String, IRuleConfigParserFactory> cachedFactories = new HashMap<>(); static { cachedFactories.put("json", new JsonRuleConfigParserFactory()); cachedFactories.put("xml", new XmlRuleConfigParserFactory()); cachedFactories.put("yaml", new YamlRuleConfigParserFactory()); cachedFactories.put("properties", new PropertiesRuleConfigParserFactory()); } public static IRuleConfigParserFactory getParserFactory(String type) { if (type == null || type.isEmpty()) { return null; } IRuleConfigParserFactory parserFactory = cachedFactories.get(type.toLowerCase()); return parserFactory; } }
使用场景
基于这个设计思想,当对象的创建逻辑比较复杂,不只是简单的 new 一下就可以,而是要组合其他类对象,做各种初始化操作的时候,我们推荐使用工厂方法模式,将复杂的创建逻辑拆分到多个工厂类中,让每个工厂类都不至于过于复杂。而使用简单工厂模式,将所有的创建逻辑都放到一个工厂类中,会导致这个工厂类变得很复杂。
总结
当创建逻辑比较复杂,是一个“大工程”的时候,我们就考虑使用工厂模式,封装对象的创建过程,将对象的创建和使用相分离。何为创建逻辑比较复杂呢?我总结了下面两种情况。 第一种情况:类似规则配置解析的例子,代码中存在 if-else 分支判断,动态地根据不同的类型创建不同的对象。针对这种情况,我们就考虑使用工厂模式,将这一大坨 if-else 创建对象的代码抽离出来,放到工厂类中。 还有一种情况,尽管我们不需要根据不同的类型创建不同的对象,但是,单个对象本身的创建过程比较复杂,比如前面提到的要组合其他类对象,做各种初始化操作。在这种情况下,我们也可以考虑使用工厂模式,将对象的创建过程封装到工厂类中。 对于第一种情况,当每个对象的创建逻辑都比较简单的时候,我推荐使用简单工厂模式,将多个对象的创建逻辑放到一个工厂类中。当每个对象的创建逻辑都比较复杂的时候,为了避免设计一个过于庞大的简单工厂类,我推荐使用工厂方法模式,将创建逻辑拆分得更细,每个对象的创建逻辑独立到各自的工厂类中。同理,对于第二种情况,因为单个对象本身的创建逻辑就比较复杂,所以,我建议使用工厂方法模式。 除了刚刚提到的这几种情况之外,如果创建对象的逻辑并不复杂,那我们就直接通过 new 来创建对象就可以了,不需要使用工厂模式。 现在,我们上升一个思维层面来看工厂模式,它的作用无外乎下面这四个。这也是判断要不要使用工厂模式的最本质的参考标准。 封装变化:创建逻辑有可能变化,封装成工厂类之后,创建逻辑的变更对调用者透明。 代码复用: 创建代码抽离到独立的工厂类之后可以复用。 隔离复杂性:封装复杂的创建逻辑,调用者无需了解如何创建对象。 控制复杂度:将创建代码抽离出来,让原本的函数或类职责更单一,代码更简洁。
评论
- 不用工厂模式,if-else 逻辑、创建逻辑和业务代码耦合在一起 - 简单工厂是将不同创建逻辑放到一个工厂类中,if-else 逻辑在这个工厂类中 - 工厂方法是将不同创建逻辑放到不同工厂类中,先用一个工厂类的工厂来来得到某个工厂,再用这个工厂来创建,if-else 逻辑在工厂类的工厂中
依赖注入容器
DI 容器底层最基本的设计思路就是基于工厂模式的。DI 容器相当于一个大的工厂类,负责在程序启动的时候,根据配置(要创建哪些类对象,每个类对象的创建需要依赖哪些其他类对象)事先创建好对象。当应用程序需要使用某个类对象的时候,直接从容器中获取即可。正是因为它持有一堆对象,所以这个框架才被称为“容器”。
一个工厂类只负责某个类对象或者某一组相关类对象(继承自同一抽象类或者接口的子类)的创建,而 DI 容器负责的是整个应用中所有类对象的创建。
DI 容器的核心功能有哪些?
配置解析、对象创建和对象生命周期管理。
核心逻辑主要包括:配置文件解析,以及根据配置文件通过“反射”语法来创建对象。其中,创建对象的过程就应用到了我们在学的工厂模式。对象创建、组装、管理完全有 DI 容器来负责,跟具体业务代码解耦,让程序员聚焦在业务代码的开发上。
建造者模式 (Builder 模式)
详解构造函数、set方法、建造者模式三种对象创建方式
为什么需要建造者模式?
在平时的开发中,创建一个对象最常用的方式是,使用 new 关键字调用类的构造函数来完成。问题是,什么情况下这种方式就不适用了,就需要采用建造者模式来创建对象呢?
例
定义一个资源池配置类 ResourcePoolConfig。这里的资源池,你可以简单理解为线程池、连接池、对象池等。见例子
public class ResourcePoolConfig { private static final int DEFAULT_MAX_TOTAL = 8; private static final int DEFAULT_MAX_IDLE = 8; private static final int DEFAULT_MIN_IDLE = 0; private String name; private int maxTotal = DEFAULT_MAX_TOTAL; private int maxIdle = DEFAULT_MAX_IDLE; private int minIdle = DEFAULT_MIN_IDLE; public ResourcePoolConfig(String name, Integer maxTotal, Integer maxIdle, Integer minIdle) { if (StringUtils.isBlank(name)) { throw new IllegalArgumentException("name should not be empty."); } this.name = name; if (maxTotal != null) { if (maxTotal <= 0) { throw new IllegalArgumentException("maxTotal should be positive."); } this.maxTotal = maxTotal; } if (maxIdle != null) { if (maxIdle < 0) { throw new IllegalArgumentException("maxIdle should not be negative."); } this.maxIdle = maxIdle; } if (minIdle != null) { if (minIdle < 0) { throw new IllegalArgumentException("minIdle should not be negative."); } this.minIdle = minIdle; } } //...省略getter方法... }
产生出的问题
ResourcePoolConfig 只有 4 个可配置项,对应到构造函数中,也只有 4 个参数,参数的个数不多。但是,如果可配置项逐渐增多,变成了 8 个、10 个,甚至更多,那继续沿用现在的设计思路,构造函数的参数列表会变得很长,代码在可读性和易用性上都会变差。 // 参数太多,导致可读性差、参数可能传递错误 ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool", 16, null, 8, null, false , true, 10, 20,false, true);
构造方法 + set() 方法解决
通过构造函数设置必填项,通过 set() 方法设置可选配置项,就能实现我们的设计需求 // ResourcePoolConfig使用举例 ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool"); config.setMaxTotal(16); config.setMaxIdle(8);
例子难度加大
1. name 是必填的,所以,我们把它放到构造函数中,强制创建对象的时候就设置。如果必填的配置项有很多,把这些必填配置项都放到构造函数中设置,那构造函数就又会出现参数列表很长的问题。如果我们把必填项也通过 set() 方法设置,那校验这些必填项是否已经填写的逻辑就无处安放了。
2. 除此之外,假设配置项之间有一定的依赖关系,比如,如果用户设置了 maxTotal、maxIdle、minIdle 其中一个,就必须显式地设置另外两个;或者配置项之间有一定的约束条件,比如,maxIdle 和 minIdle 要小于等于 maxTotal。如果我们继续使用现在的设计思路,那这些配置项之间的依赖关系或者约束条件的校验逻辑就无处安放了。
3. 希望 ResourcePoolConfig 类对象是不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值。要实现这个功能,我们就不能在 ResourcePoolConfig 类中暴露 set() 方法。
建造者模式解决
可以把校验逻辑放置到 Builder 类中,先创建建造者,并且通过 set() 方法设置建造者的变量值,然后在使用 build() 方法真正创建对象之前,做集中的校验,校验通过之后才会创建对象。除此之外,我们把 ResourcePoolConfig 的构造函数改为 private 私有权限。这样我们就只能通过建造者来创建 ResourcePoolConfig 类对象。并且,ResourcePoolConfig 没有提供任何 set() 方法,这样我们创建出来的对象就是不可变对象了。见例子
public class ResourcePoolConfig { private String name; private int maxTotal; private int maxIdle; private int minIdle; private ResourcePoolConfig(Builder builder) { this.name = builder.name; this.maxTotal = builder.maxTotal; this.maxIdle = builder.maxIdle; this.minIdle = builder.minIdle; } //...省略getter方法... //我们将Builder类设计成了ResourcePoolConfig的内部类。 //我们也可以将Builder类设计成独立的非内部类ResourcePoolConfigBuilder。 public static class Builder { private static final int DEFAULT_MAX_TOTAL = 8; private static final int DEFAULT_MAX_IDLE = 8; private static final int DEFAULT_MIN_IDLE = 0; private String name; private int maxTotal = DEFAULT_MAX_TOTAL; private int maxIdle = DEFAULT_MAX_IDLE; private int minIdle = DEFAULT_MIN_IDLE; public ResourcePoolConfig build() { // 校验逻辑放到这里来做,包括必填项校验、依赖关系校验、约束条件校验等 if (StringUtils.isBlank(name)) { throw new IllegalArgumentException("..."); } if (maxIdle > maxTotal) { throw new IllegalArgumentException("..."); } if (minIdle > maxTotal || minIdle > maxIdle) { throw new IllegalArgumentException("..."); } return new ResourcePoolConfig(this); } public Builder setName(String name) { if (StringUtils.isBlank(name)) { throw new IllegalArgumentException("..."); } this.name = name; return this; } public Builder setMaxTotal(int maxTotal) { if (maxTotal <= 0) { throw new IllegalArgumentException("..."); } this.maxTotal = maxTotal; return this; } public Builder setMaxIdle(int maxIdle) { if (maxIdle < 0) { throw new IllegalArgumentException("..."); } this.maxIdle = maxIdle; return this; } public Builder setMinIdle(int minIdle) { if (minIdle < 0) { throw new IllegalArgumentException("..."); } this.minIdle = minIdle; return this; } } } // 这段代码会抛出IllegalArgumentException,因为minIdle>maxIdle ResourcePoolConfig config = new ResourcePoolConfig.Builder() .setName("dbconnectionpool") .setMaxTotal(16) .setMaxIdle(10) .setMinIdle(12) .build();
与工厂模式区别
工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。建造者模式是用来创建一种类型的复杂对象,通过设置不同的可选参数,“定制化”地创建不同的对象。 只有了解了这些最本质的东西,我们才能不生搬硬套,才能灵活应用,甚至可以混用各种模式创造出新的模式,来解决特定场景的问题。
例子解释两者区别 顾客走进一家餐馆点餐,我们利用工厂模式,根据用户不同的选择,来制作不同的食物,比如披萨、汉堡、沙拉。对于披萨来说,用户又有各种配料可以定制,比如奶酪、西红柿、起司,我们通过建造者模式根据用户选择的不同配料来制作披萨。
总结
我们把类的必填属性放到构造函数中,强制创建对象的时候就设置。如果必填的属性有很多,把这些必填属性都放到构造函数中设置,那构造函数就又会出现参数列表很长的问题。如果我们把必填属性通过 set() 方法设置,那校验这些必填属性是否已经填写的逻辑就无处安放了。 如果类的属性之间有一定的依赖关系或者约束条件,我们继续使用构造函数配合 set() 方法的设计思路,那这些依赖关系或约束条件的校验逻辑就无处安放了。 如果我们希望创建不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值,要实现这个功能,我们就不能在类中暴露 set() 方法。构造函数配合 set() 方法来设置属性值的方式就不适用了。
一、使用场景: 1)类的构造函数必填属性很多,通过set设置,没有办法校验必填属性 2)如果类的属性之间有一定的依赖关系,构造函数配合set方式,无法进行依赖关系和约束条件校验 3)需要创建不可变对象,不能暴露set方法。 (前提是需要传递很多的属性,如果属性很少,可以不需要建造者模式) 二、实现方式: 把构造函数定义为private,定义public static class Builder 内部类,通过Builder 类的set方法设置属性,调用build方法创建对象。 三、和工厂模式的区别: 1)工厂模式:创建不同的同一类型对象(集成同一个父类或是接口的一组子类),由给定的参数来创建哪种类型的对象; 2)建造者模式:创建一种类型的复杂对象,通过很多可设置参数,“定制化”的创建对象
结构型
代理模式
通过引入代理类来给原始类附加功能
代理在RPC、缓存、监控等场景中的应用
例
一个 MetricsCollector 类,用来收集接口请求的原始数据,比如访问时间、处理时长等
public class UserController { //...省略其他属性和方法... private MetricsCollector metricsCollector; // 依赖注入 public UserVo login(String telephone, String password) { long startTimestamp = System.currentTimeMillis(); // ... 省略login逻辑... long endTimeStamp = System.currentTimeMillis(); long responseTime = endTimeStamp - startTimestamp; RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp); metricsCollector.recordRequest(requestInfo); //...返回UserVo数据... } public UserVo register(String telephone, String password) { long startTimestamp = System.currentTimeMillis(); // ... 省略register逻辑... long endTimeStamp = System.currentTimeMillis(); long responseTime = endTimeStamp - startTimestamp; RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp); metricsCollector.recordRequest(requestInfo); //...返回UserVo数据... } }
问题
第一,性能计数器框架代码侵入到业务代码中,跟业务代码高度耦合。如果未来需要替换这个框架,那替换的成本会比较大。 第二,收集接口请求的代码跟业务代码无关,本就不应该放到一个类中。业务类最好职责更加单一,只聚焦业务处理。
解决方案
接口解决
UserController 类只负责业务功能。代理类 UserControllerProxy 负责在业务代码执行前后附加其他逻辑代码,并通过委托的方式调用原始类来执行业务代码。
public interface IUserController { UserVo login(String telephone, String password); UserVo register(String telephone, String password); } public class UserController implements IUserController { //...省略其他属性和方法... @Override public UserVo login(String telephone, String password) { //...省略login逻辑... //...返回UserVo数据... } @Override public UserVo register(String telephone, String password) { //...省略register逻辑... //...返回UserVo数据... } } public class UserControllerProxy implements IUserController { private MetricsCollector metricsCollector; private UserController userController; public UserControllerProxy(UserController userController) { this.userController = userController; this.metricsCollector = new MetricsCollector(); } @Override public UserVo login(String telephone, String password) { long startTimestamp = System.currentTimeMillis(); // 委托 UserVo userVo = userController.login(telephone, password); long endTimeStamp = System.currentTimeMillis(); long responseTime = endTimeStamp - startTimestamp; RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp); metricsCollector.recordRequest(requestInfo); return userVo; } @Override public UserVo register(String telephone, String password) { long startTimestamp = System.currentTimeMillis(); UserVo userVo = userController.register(telephone, password); long endTimeStamp = System.currentTimeMillis(); long responseTime = endTimeStamp - startTimestamp; RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp); metricsCollector.recordRequest(requestInfo); return userVo; } } //UserControllerProxy使用举例 //因为原始类和代理类实现相同的接口,是基于接口而非实现编程 //将UserController类对象替换为UserControllerProxy类对象,不需要改动太多代码 IUserController userController = new UserControllerProxy(new UserController());
继承解决
原始类并没有定义接口,并且原始类代码并不是我们开发维护的(比如它来自一个第三方的类库),采用继承的方式。代理类继承原始类,然后扩展附加功能。
public class UserControllerProxy extends UserController { private MetricsCollector metricsCollector; public UserControllerProxy() { this.metricsCollector = new MetricsCollector(); } public UserVo login(String telephone, String password) { long startTimestamp = System.currentTimeMillis(); UserVo userVo = super.login(telephone, password); long endTimeStamp = System.currentTimeMillis(); long responseTime = endTimeStamp - startTimestamp; RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp); metricsCollector.recordRequest(requestInfo); return userVo; } public UserVo register(String telephone, String password) { long startTimestamp = System.currentTimeMillis(); UserVo userVo = super.register(telephone, password); long endTimeStamp = System.currentTimeMillis(); long responseTime = endTimeStamp - startTimestamp; RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp); metricsCollector.recordRequest(requestInfo); return userVo; } } //UserControllerProxy使用举例 UserController userController = new UserControllerProxy();
动态代理的原理解析
例子中,在代理类中,将原始类中的所有的方法,都重新实现一遍,并且为每个方法都附加相似的代码逻辑。另一方面,如果要添加的附加功能的类有不止一个,我们需要针对每个类都创建一个代理类。 可以使用动态代理来解决这个问题。所谓动态代理(Dynamic Proxy),就是我们不事先为每个原始类编写代理类,而是在运行的时候,动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类。
public class MetricsCollectorProxy { private MetricsCollector metricsCollector; public MetricsCollectorProxy() { this.metricsCollector = new MetricsCollector(); } public Object createProxy(Object proxiedObject) { Class<?>[] interfaces = proxiedObject.getClass().getInterfaces(); DynamicProxyHandler handler = new DynamicProxyHandler(proxiedObject); return Proxy.newProxyInstance(proxiedObject.getClass().getClassLoader(), interfaces, handler); } private class DynamicProxyHandler implements InvocationHandler { private Object proxiedObject; public DynamicProxyHandler(Object proxiedObject) { this.proxiedObject = proxiedObject; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long startTimestamp = System.currentTimeMillis(); Object result = method.invoke(proxiedObject, args); long endTimeStamp = System.currentTimeMillis(); long responseTime = endTimeStamp - startTimestamp; String apiName = proxiedObject.getClass().getName() + ":" + method.getName(); RequestInfo requestInfo = new RequestInfo(apiName, responseTime, startTimestamp); metricsCollector.recordRequest(requestInfo); return result; } } } //MetricsCollectorProxy使用举例 MetricsCollectorProxy proxy = new MetricsCollectorProxy(); IUserController userController = (IUserController) proxy.createProxy(new UserController());
应用场景
1. 业务系统的非功能性需求开发
在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志。我们将这些附加功能与业务功能解耦,放到代理类中统一处理,让程序员只需要关注业务方面的开发。
2. 代理模式在 RPC、缓存中的应用
通过远程代理,将网络通信、数据编解码等细节隐藏起来。客户端在使用 RPC 服务的时候,就像使用本地函数一样,无需了解跟服务器交互的细节。除此之外,RPC 服务的开发者也只需要开发业务逻辑,就像开发本地使用的函数一样,不需要关注跟客户端的交互细节。
代理模式在缓存中的应用
代理模式就能派上用场了,确切地说,应该是动态代理。如果是基于 Spring 框架来开发的话,那就可以在 AOP 切面中完成接口缓存的功能。在应用启动的时候,我们从配置文件中加载需要支持缓存的接口,以及相应的缓存策略(比如过期时间)等。当请求到来的时候,我们在 AOP 切面中拦截请求,如果请求中带有支持缓存的字段(比如 http://…?..&cached=true),我们便从缓存(内存缓存或者 Redis 缓存等)中获取数据直接返回。
总结
1. 代理模式的原理与实现
在不改变原始类(或叫被代理类)的情况下,通过引入代理类来给原始类附加功能。一般情况下,我们让代理类和原始类实现同样的接口。但是,如果原始类并没有定义接口,并且原始类代码并不是我们开发维护的。在这种情况下,我们可以通过让代理类继承原始类的方法来实现代理模式。
2. 动态代理的原理与实现
静态代理需要针对每个类都创建一个代理类,并且每个代理类中的代码都有点像模板式的“重复”代码,增加了维护成本和开发成本。对于静态代理存在的问题,我们可以通过动态代理来解决。我们不事先为每个原始类编写代理类,而是在运行的时候动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类。
3. 代理模式的应用场景
代理模式常用在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志。我们将这些附加功能与业务功能解耦,放到代理类统一处理,让程序员只需要关注业务方面的开发。除此之外,代理模式还可以用在 RPC、缓存等应用场景中。
JVM
JVM架构
总体概述
组成部分
类装载子系统
运行时数据区
栈管运行,堆管存储。JVM调优主要是优化Java堆和方法区。
执行引擎
垃圾收集
体系结构
1. 类装载子系统
ClassLoader类加载器负责加载.class文件(class文件在文件开头有特定的文件标识),将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构,Class只负责加载class文件的加载,至于它是否可以运行,则由Execution Engine决定。
2. 运行时数据区
1.元空间(存放在本地内存)
方法区是各线程共享的内存区域,它用于存储已被JVM加载的类信息、常量、静态变量、运行时常量池等数据。
2.Java堆
Java堆是各线程共享的内存区域,在JVM启动时创建,这块区域是JVM中最大的, 用于存储应用的对象和数组,也是GC主要的回收区,一个 JVM 实例只存在一个堆内存,堆内存的大小是可以调节的。 类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,以方便执行器执行,堆内存分为三部分:新生代、老年代、永久代(1.8中改为元空间)。
3.Java栈
栈是什么
Java栈是线程私有的,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致。基本类型的变量和对象的引用变量都是在函数的栈内存中分配。
栈存储什么
每个方法执行的时候都会创建一个栈帧,栈帧中主要存储3类数据: 局部变量表:输入参数和输出参数以及方法内的变量; 栈操作:记录出栈和入栈的操作; 栈帧数据:包括类文件、方法等等。
栈运行原理
栈中的数据都是以栈帧的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据的数据集。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在栈中从入栈到出栈的过程。
4.本地方法栈
本地方法栈和JVM栈发挥的作用非常相似,也是线程私有的,区别是JVM栈为JVM执行Java方法(也就是字节码)服务,而本地方法栈为JVM使用到的Native方法服务。它的具体做法是在本地方法栈中登记native方法,在执行引擎执行时加载Native Liberies.有的虚拟机(比如Sun Hotpot)直接把两者合二为一。
5.程序计数器
程序计数器是一块非常小的内存空间,几乎可以忽略不计,每个线程都有一个程序计算器,是线程私有的,可以看作是当前线程所执行的字节码的行号指示器,指向方法区中的方法字节码(下一个将要执行的指令代码),由执行引擎读取下一条指令。
3. 执行引擎
执行引擎执行包在装载类的方法中的指令,也就是方法。执行引擎以指令为单位读取Java字节码。它就像一个CPU一样,一条一条地执行机器指令。每个字节码指令都由一个1字节的操作码和附加的操作数组成。执行引擎取得一个操作码,然后根据操作数来执行任务,完成后就继续执行下一条操作码。 不过Java字节码是用一种人类可以读懂的语言编写的,而不是用机器可以直接执行的语言。因此,执行引擎必须把字节码转换成可以直接被JVM执行的语言。字节码可以通过以下两种方式转换成合适的语言:
1. 解释器: 一条一条地读取,解释并执行字节码执行,所以它可以很快地解释字节码,但是执行起来会比较慢。这是解释执行语言的一个缺点。 2. 即时编译器:用来弥补解释器的缺点,执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即时编译器把整段字节码编译成本地代码。然后,执行引擎就没有必要再去解释执行方法了,它可以直接通过本地代码去执行。执行本地代码比一条一条进行解释执行的速度快很多,编译后的代码可以执行的很快,因为本地代码是保存在缓存里的。
类装载子系统
类加载器
引导类加载器
BootStrap加载被虚拟机识别的类库(如jre/lib下的rt.jar,所有的java.*开头的类)
扩展类加载器
sun.misc.Launcher$ExtClassLoader加载lib/ext目录中,如如javax.*开头的类
应用程序加载器
sun.misc.Launcher$AppClassLoader加载用户类路径(ClassPath)所指定的类
类加载机制
全盘负责
当前线程的类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用CLassLoader.loadClass()指定类加载器来载入
双亲委派机制 (向上委托,向下加载)
先让父类加载器寻找目标类,在父类无法加载该类时,在尝试从自己的类路径中加载该类。
优势
沙箱安全机制
自己写的String.class类不会被加载,这样便可以防止核心类API库被随意篡改。
避免类的重复加载
当父类已经加载了该类时,就没有必要子ClassLoader再加载一次。 -系统类防止内存中出现多份同样的字节码
类加载过程
JVM将javac编译好的class字节码文件加载到内存中,并对该数据进行验证、解析和初始化、形成JVM可以直接使用的JAVA类,最终回收(卸载)的过程。
字节码来源:网络下载.class文件、jar等归档文件中加载.class文件、专有数据库中提取.class文件、java源文件动态编译为.class文件
1.加载
用默认或指定类加载器加载类.class文件,并将静态存储结构转化为方法区的运行时数据结构,且在java堆中生成一个代表这个类的Class对象,作为方法区中这些数据的访问入口。
2.链接
1.验证
确保.class文件信息符合虚拟机要求
2.准备
在方法区为类变量(static变量)分配内存并设置类变量初始值
例如:public static int flag=1;该阶段初始化值为0。
3.解析
虚拟机将常量池中的符号引用替换为直接引用的过程
3.初始化
初始化为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。 初始化阶段就是执行类构造器()的过程,类构造器()是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。
4.使用
程序使用JVM加载的类
5.卸载
执行了System.exit()方法 JVM垃圾回收机制触发回收 程序正常执行结束 程序在执行过程中遇到了异常或错误而异常终止 由于操作系统出现错误而导致Java虚拟机进程终止
JVM性能调优监控工具
jps
-- java进程查看工具 列出当前机器上正在运行的虚拟机进程,JPS 从操作系统的临时目录上去找。
-q :仅仅显示进程, -m:输出主函数传入的参数. 下的 hello 就是在执行程序时从命令行输入的参数 -l: 输出应用程序主类完整 package 名称或 jar 完整名称. -v: 列出 jvm 参数, -Xms20m -Xmx50m 是启动程序指定的 jvm 参数
jinfo
-- 查看和修改虚拟机的参数
Usage: jinfo [option] <pid> (to connect to running process) jinfo [option] <executable <core> (to connect to a core file) jinfo [option] [server_id@]<remote server IP or hostname> (to connect to remote debug server) where <option> is one of: -flag <name> to print the value of the named VM flag -flag [+|-]<name> to enable or disable the named VM flag -flag <name>=<value> to set the named VM flag to the given value -flags to print VM flags -sysprops to print Java system properties <no option> to print both of the above -h | -help to print this help message
jstat
-- 运行状态工具 用于监视虚拟机各种运行状态信息的命令行工具。 它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据。 命令: jstat [ generalOption | outputOptions vmid [ interval[s|ms] [ count ] ] interval默认是毫秒 count是次数
outputOptions如下: -class (类加载器) -compiler (JIT) -gc (GC 堆状态) -gccapacity (各区大小) -gccause (最近一次 GC 统计和原因) -gcnew (新区统计) -gcnewcapacity (新区大小) -gcold (老区统计) -gcoldcapacity (老区大小) -gcpermcapacity (永久区大小) -gcutil (GC 统计汇总) -printcompilation (HotSpot 编译统计)
假设需要每 250 毫秒查询一次进程 13616 垃圾收集状况,一共查询 10 次,那命令应当是:jstat -gc 13616 250 10 常用参数:
https://docs.oracle.com/javase/8/docs/technotes/tools/windows/jstat.html#BEHIGDGJ
jmap
-- dump快照工具 用于生成堆转储快照(一般称为 heapdump 或 dump 文件)
jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalize 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等
jmap -dump:live,format=b,file=heap.bin <pid>
jstack
-- 线程工具 jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照。 命令: jstack [ options ] pid
线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。在代码中可以用 java.lang.Thread 类的 getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement 对象。使用这个方法可以通过简单的几行代码就完成 jstack 的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈。
jhat
jvisualvm
可视化分析工具
GC
内存的分配与回收
堆与元空间
为什么要这样划分
为了节省内存和解决内存碎片的问题
对象优先分配到Eden区分配
对象在新生代中Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
新生代GC(Minor GC) [ˈmaɪnər]
发生新生代的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快
年老代GC(Major GC/Full GC) [ˈmeɪdʒər]
发生在老年代的GC,出现的Major GC经常会伴随至少一次的Minor GC(并非绝对)
长期存活的对象将进入老年代
采用了分代收集的思想管理内存,内存回收必须能识别那些对象应放在新生代,那些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄计数器。 如果对象在Eden出生并经过一次Minor GC后仍然能够存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor中每熬过一次Minor GC,年龄就增加1,当它的年龄增加到一定程度(默认15),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置 动态年龄进入老年代: 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。 PretenureSizeThreshold当创建的对象超过指定大小时直接把对象分配在老年代 MaxTenuringThreshold设定对象在Survivor复制的最大年龄阈值
如何判断对象可以被回收
引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用。目前主流的虚拟机并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题
可达性分析算法
通过一系列的称为GC Roots的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的。 注:即使在可达性分析算法中,该对象没有任何引用链,也并非是“非死不可”,它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,到少要经历再次标记过程(见下finalize())。
GC Roots根节点:类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈的变量等等
finalize()方法最终判断对象是否存活 -前提是对象是可达性分析后未与GC Root相连的引用链
第一次标记
筛选的条件是此象是否有必要执行finalize()方法。 当对象没有覆盖finalize方法,或者finalize()方法已经被虚拟机调用过,虚拟机将两种情况都视为“没有必要执行”,对象被回收。 注:finalize是对象的一个方法,当对象无任何引用时触发该方法。
第二次标记
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。 finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己---只要重新与引用链上的任何一个对象建立关联即可。
垃圾收集
垃圾收集算法 -采用分代收集算法,根据对象存活周期的不同将内存分几块。一般分为新生代和老年代,根据各个年代的特点选择合适的垃圾收集算法。 如: 在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。 而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,必须选择 “标记-清除” 或 “标记-整理” 算法进行垃圾收集。
标记-复制算法
可用于新生代 内存分大小相同的两块区域,交互进行复制
分两部分 1.左边是对象存放部分,右边保留内存-绿色 2.GC时将存活对象压缩放入绿色中 3.两部分是两边轮循做保留内存用
标记-清除算法
可用于老年代
标记存活的对象, 统一回收所有未被标记的对象 清理后,内存碎片严重,效率也慢
问题: 1 效率问题 (如果需要标记的对象太多,效率不高) 2 空间问题(标记清除后会产生大量不连续的碎片)
标记整理算法
可用于老年代
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
分代收集算法
新生代
每次收集都会有大量对象死去,可用 “复制算法”,付出少量对象的复制成本。
老年代
老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,可用 “标记-清除” 或 “标记-整理” 算法进行垃圾收集
垃圾收集器
并行
多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发
用户线程与垃圾收集线程同时执行,用户程序在继续运行,而垃圾收集器运行在另一个CPU上。适合web应用
Serial收集器(单线程) ------------ [ˈsɪriəl] -新生代 -复制算法 -老年代 -标记整理
“单线程”的意义不仅仅意味着只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程(Stop The World),直到它收集结束 优点: 简单而高效(与其他收集器的单线程相比)
单线程收集工作
Serial Old -Serial收集器的老年代版本
同样是单线程收集器。 一种用途是在JDK1.5以及以前的版权中与Parallel Scavenge收集器搭配使用 一种用途是作为CMS收集器的后备方案
ParNew收集器 (Serial的多线程版本) ------------------ -新生代 -复制算法
Serial的多线程版本 除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略)和Serial收集器完全一样。 场景:在Server模式下的首要选择,一般与CMS收集器配合工作。
Serial多线程版本
Parallel Scavenge收集器 (并行清除) ---------- [ˈpærəlel][ˈskævɪndʒ] -新生代 -复制算法 -老年代 -标记整理
JDK8默认收集器 关注点是吞吐量(高效率的利用CPU) 提供了很多参数供用户找到合适的停顿时间或最大吞吐量。
类似于ParNew
Parallel Old收集器 (标记-整理) -Parallel Scavenge收集器的老年代版本
使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑Parallel Scavenge收集器和Parallel Old收集器
CMS收集器(并发) Concurrent Mark Sweep -----[kənˈkɜːrənt][swiːp] -老年代 -标记-清理
-分段处理,在每个小段做特殊处理 一种以获取最短回收停顿时间为目标的收集器。在注重用户体验的应用上使用,它是真正意义上的并发收集器,实现了让垃圾收集线程与用户线程同时工作。 缺点: 1.抢CPU资源(会和服务抢资源) 2.无法处理浮动垃圾(在并发清理段) 3.标记-清除算法,会有大量空间碎片 优点:并发收集、低停顿 默认配合:-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
标记-清除算法
参数
真正的并发执行
1. 初始标记(STW)
暂停所有的其他线程(STW),并记录下直接与root相连的对象,速度很快
2. 并发标记
同时开启GC和用户线程,用一个闭包结构去记录可达对象(从GCRoots的直接关联对象开始遍历整个对象图的过程)。这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能不断的更新引用域,所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
3. 重新标记(SWT)
重新标记阶段就是为了修正并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。
4. 并发清除
开启用户线程,同时GC线程开始对未标记的区域做清扫
5. 并发重置
重置本次GC过程中的标记数据。
G1收集器 -大对象
描述: 面向服务器的垃圾收集器,主要针对配备多个处理器及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
G1内存结构 不采用原有内存模型 而采用独立区域
描述: G1将Java堆划分为多个大小相等的独立区域(Region),虽保留新生代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合
Eden
Survivor
Old
年老代
Humongous [hjuːˈmʌŋɡəs]
分配大对象, 专门存放短期巨型对象,不直接进入老年代,减少Full GC。不会因为无法找到连续空间而提前触发下一次GC 老年代满了也会收集H 优点:老年代满的机率小
G1收集运行示意图 -G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也是Garbage-First的由来)
初始标记
G1 GC对根进行标记。该阶段与常规的年轻代垃圾回收密切相关
并发标记
G1 GC在整个堆中查找可访问的(存活的)对象
最终标记
该阶段是SWT回收,帮助完成标记周期
筛选回收
筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
收集分类
YoungGC
1. 新对象进入Eden区 2. 存活对象拷到Survivor区 3. 存活时间达到年龄阈值时,对象升入Old区
MixedGC [mɪkst]混合的
1.不是FullGC,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序) 2. global concurrent marking(全局并发标记) a. initial marking phase:标记GC Root,SWT b. Root regions scanning phase:标记存活Region c. Concurrent marking phase:标记存活的对象 d. Remark phase:重新标记SWT
特点
并行与并发
G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或CPU核心)来缩短STW停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
分代收集
虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念
空间整合
与CMS的“标记-清理” 算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制” 算法实现的。
可预测的停顿
这是G1相对CMS的另一个大优势,降低停顿时间是G1和GMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定一个长度为M毫秒的时间片段内完成垃圾收集。
CMS与G1
1. CMS以最小停顿时间为目标 G1自定义回收停顿时间 2. CMS标记清理 G1标记整理 3. CMS最少4G内存 G1适合8G以上内存
实战调优/排查
两个指标
停顿时间
垃圾收集器做垃圾回收中断应用执行的时间。--XX:MaxGCPauseMillis
吞吐量
垃圾收集的时间和总时间的占比:1/(1+n),吞吐量为1-1(1+n)。-XX:GCTimeRatio=n - n可设置99,剩下的1就是垃圾用的占比
参数
收集器设置
垃圾回收统计信息
堆栈设置
G1
G1
配置G1收集器
CMS
CMS
配置CMS收集器
Parallel
Parallel
工具
GCeasy
调优
几点见意
1. 为了打印日志方便排查问题最好开启GC日志,开启GC日志对性能影响微乎其微,但是能帮助我们快速排查定位问题 -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc gc.log 2. 一般设置-Xms=-Xmx,这样可以获得固定大小的堆内存,减少GC的次数和耗时,可以使得堆相对稳定 3. -XX:+HeapDumpOnOutOfMemoryError让JVM在发生内存溢出的时候自动生成内存快照,方便排查问题。 4. -Xmn设置新生代的大小,太小会增加YGC,太大会减小老年代大小,一般设置为整个堆的1/4到1/3。 5. 设置-XX:+DisableExplicitGC禁止系统System.gc(),防止手动误触发FGC造成问题
GC调优步骤
java -jar -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:./gc.log microservice-eureka-server.jar
G1
Parallel Scavenge 并行清除
排查
频繁FGC如何排查
可能内存分配不合理,比如Eden区太小,导致对象频进入老年代,这时候通过启动参数配置就能看出来,另外有可能就是存在内存泄露
排查步骤 1. jstat -gcutil或查看gc.log日志 从图里面看是否进行FGC,FGC的时间花费多长,GC后老年代
年轻代内存是否有减少,得到一些初步的情况来做出判断
dump出内存文件在具体分析 jmap -dump format=b,file=dumpfile pid 导出之后再通过Eclipse Memory Analyzer等工具进行分析,定位到代码,修复
CPU飙高
1. 找到当前进程的pid,top -p pid -H查看资源占用 2. printf "%x\n" pid,把线程pid转为16进制 3. jstack pid | grep -A 10 0x32d 查看线程的堆栈日志 4. dump出内存文件用MAT等工具进行分析,定位到代码
进入老年代
动态年龄进入老年代: 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。 PretenureSizeThreshold当创建的对象超过指定大小时直接把对象分配在老年代 MaxTenuringThreshold设定对象在Survivor复制的最大年龄阈值 MaxDirectMemorySize当DirectByteBuffer分配的堆外内存到达指定大小后,即触发FullGC
数据结构和算法
认识与思路
分析问题,一定要有递归的思想,自顶向下,从抽象到具体。你上来就列出这么多(如散列表、栈、队列、堆、树、图等),那些都属于「上层建筑」,而数组和链表才是「结构基础」。因为那些多样化的数据结构,究其源头,都是在链表或者数组上的特殊操作,API 不同而已。
数据结构的存储方式
数组(顺序存储)
由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);而且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 O(N)。
链表(链式存储)
因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间。
数据结构的基本操作
对于任何数据结构,其基本操作无非遍历 + 访问,再具体一点就是:增删查改。 数据结构种类很多,但它们存在的目的都是在不同的应用场景,尽可能高效地增删查改。话说这不就是数据结构的使命么? 如何遍历 + 访问?我们仍然从最高层来看,各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的。 线性就是 for/while 迭代为代表,非线性就是递归为代表。
提炼
1. 任何数据结构,基本操作是遍历 + 访问
2. 访问的形式,线性的 和 非线性的
3. 线性用for/while迭代、非线性用递归
几种框架
数组遍历框架
void traverse(int[] arr) { for (int i = 0; i < arr.length; i++) { // 迭代访问 arr[i] } }
链表遍历,兼具迭代和递归结构
/* 基本的单链表节点 */ class ListNode { int val; ListNode next; }
void traverse(ListNode head) { for (ListNode p = head; p != null; p = p.next) { // 迭代访问(线性) p.val } } void traverse(ListNode head) { // 递归访问 head.val traverse(head.next) }
二叉树遍历框架,典型的非线性递归遍历结构:
/* 基本的二叉树节点 */ class TreeNode { int val; TreeNode left, right; } void traverse(TreeNode root) { traverse(root.left) traverse(root.right) }
分析
看二叉树的递归遍历方式和链表的递归遍历方式,相似不?再看看二叉树结构和单链表结构,相似不?如果再多几条叉,N 叉树你会不会遍历?
N叉树的遍历框架
/* 基本的 N 叉树节点 */ class TreeNode { int val; TreeNode[] children; } void traverse(TreeNode root) { for (TreeNode child : root.children) traverse(child) }
图
图就是好几 N 叉棵树的结合体。你说图是可能出现环的?这个很好办,用个布尔数组 visited 做标记就行了,这里就不写代码了。
总结
所谓框架,就是套路。不管增删查改,这些代码都是永远无法脱离的结构,你可以把这个结构作为大纲,根据具体问题在框架上添加代码就行了,
数据结构是工具,算法是通过合适的工具解决特定问题的方法
动态规划
描述:动态规划问题的一般形式就是求最值
核心问题
求解动态规划的核心问题是穷举
最优子结构
通过子问题的最值得到原问题的最值。
缺点
如果暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。
三要素
重叠子问题、最优子结构、状态转移方程
斐波那契数列
时间复杂度
递归算法的时间复杂度怎么计算? 子问题个数乘以解决一个子问题需要的时间。
1.暴力递归
int fib(int N) { if (N == 1 || N == 2) return 1; return fib(N - 1) + fib(N - 2); }
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。 这就是动态规划问题的第一个性质:重叠子问题。下面,我们想办法解决这个问题。
时间复杂度
1.子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。 2.解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操作,时间为 O(1)。 3.所以,这个算法的时间复杂度为 O(2^n),指数级别,爆炸。
2.带备忘录的递归解法
明确了问题,其实就已经把问题解决了一半。即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典),思想都是一样的。
代码
int fib(int N) { if (N < 1) return 0; // 备忘录全初始化为 0 vector<int> memo(N + 1, 0); // 初始化最简情况 return helper(memo, N); } int helper(vector<int>& memo, int n) { // base case if (n == 1 || n == 2) return 1; // 已经计算过 if (memo[n] != 0) return memo[n]; memo[n] = helper(memo, n - 1) + helper(memo, n - 2); return memo[n]; }
时间复杂度
1.子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是 f(1), f(2), f(3) ... f(20),数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。 2.解决一个子问题的时间,同上,没有什么循环,时间为 O(1)。 3.所以,本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击。
3.dp数组的迭代解法
代码
int fib(int N) { vector<int> dp(N + 1, 0); // base case dp[1] = dp[2] = 1; for (int i = 3; i <= N; i++) dp[i] = dp[i - 1] + dp[i - 2]; return dp[N]; }
凑零钱问题
先看下题目:给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。算法的函数签名如下: // coins 中是可选硬币面值,amount 是目标金额 int coinChange(int[] coins, int amount); 比如说 k = 3,面值分别为 1,2,5,总金额 amount = 11。那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。
数据结构
数组
链表
队列和栈
这两种数据结构既可以使用链表也可以使用数组实现。用数组实现,就要处理扩容缩容的问题;用链表实现,没有这个问题,但需要更多的内存空间存储节点指针。
哈希表
图
两种表示方法,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但是很多操作的效率上肯定比不过邻接矩阵。
散列表
就是通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些。
树
描述
用数组实现就是「堆」,因为「堆」是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单;用链表实现就是很常见的那种「树」,因为不一定是完全二叉树,所以不适合用数组存储。为此,在这种链表「树」结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL 树、红黑树、区间树、B 树等等,以应对不同的问题。
二叉树
二叉搜索树(BST)
描述: 二叉查找树就是左结点小于根节点,右结点大于根节点的一种排序树,也叫二叉搜索树。也叫BST,英文Binary Sort Tree。
查找步骤
在二叉搜索树b中查找x的过程为: 若b是空树,则搜索失败,否则: 若x等于b的根节点的数据域之值,则查找成功;否则: 若x小于b的根节点的数据域之值,则搜索左子树;否则: 查找右子树
极端情况
二叉查找树比普通树查找更快,查找、插入、删除的时间复杂度为O(logN)。但是二叉查找树有一种极端的情况,就是会变成一种线性链表似的结构。此时时间复杂度就变味了O(N),为了解决这种情况,出现了二叉平衡树
平衡二叉树(AVL)
平衡二叉树全称平衡二叉搜索树,也叫AVL树。是一种自平衡的树。

AVL树也规定了左结点小于根节点,右结点大于根节点。并且还规定了左子树和右子树的高度差不得超过1。这样保证了它不会成为线性的链表。AVL树的查找稳定,查找、插入、删除的时间复杂度都为O(logN),但是由于要维持自身的平衡,所以进行插入和删除结点操作的时候,需要对结点进行频繁的旋转。
AVL树不仅是一颗二叉查找树,它还有其他的性质。如果我们按照一般的二叉查找树的插入方式可能会破坏AVL树的平衡性。同理,在删除的时候也有可能会破坏树的平衡性,所以我们要做一些特殊的处理,包括:单旋转和双旋转!
右旋
向右旋转成为L的右结点,同时,Y放到T的左孩子上。这样即可得到一颗新的AVL树
在T结点的左结点的左子树上插入一个元素,在插入之前树是一颗AVL树,而插入之后结点T的左右子树高度差的绝对值不再 < 1,此时AVL树的平衡性被破坏,我们要对其进行旋转
双旋
左右旋
我们在T结点的左结点的右子树上插入一个元素时,会使得根为T的树的左右子树高度差的绝对值不再 < 1,如果只是进行简单的右旋,得到的树仍然是不平衡的。我们应该按照如图所示进行二次旋转:
在T结点的左结点的右子树上插入一个元素
左右旋
右左旋
我们在T结点的右结点的左子树上插入一个元素时,会使得根为T的树的左右子树高度差的绝对值不再 < 1,如果只是进行简单的左旋,得到的树仍然是不平衡的。我们应该按照如图所示进行二次旋转:
T结点的右结点的左子树上插入一个元素
缺点
AVL树每一个节点只能存放一个元素,并且每个节点只有两个子节点。当进行查找时,就需要多次磁盘IO,(数据是存放在磁盘中的,每次查询是将磁盘中的一页数据加入内存,树的每一层节点存放在一页中,不同层数据存放在不同页。)这样如果需要多层查询就需要多次磁盘IO。为了解决AVL树的这个问题,就出现了B树。但是在学B树之前,我们需要看一下多路查找树。
AVL 树是严格平衡的,适用于查找密集型应用程序,因为在频繁插入或删除结点的场景下,它花费在树旋转的代价太高。
多路查找树
描述: 多路查找树的每一个节点的孩子树可以多于两个,且每个节点处可以存储多个元素。多路查找树是一种特殊的查找树,所以其元素之间存在某种特定的排序关系。
2-3树 (自平衡树)
描述: 每一个节点都具有两个孩子,称它为2节点 每一个节点具有三个孩子,称它为3节点
性质
满足二叉树的性质
一个2节点包含一个元素和两个孩子 (只能包含两个孩子或没有孩子,不能出现有一个孩子的情况),且与二叉排序树类似,左子树包含的元素小于该元素,右子树包含的元素大于该元素。
一个3节点包含一小一大两个元素和三个孩子 (只能包含三个孩子或没有孩子,不能出现有一个孩子或有两个孩子的情况)。如果某个3节点有孩子,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。
一颗完美平衡的2-3查找树中的所有空链接到根结点的距离都是相同的。
演示
操作
查找
要判断查找的键值是否在树中,我们先将它和根结点中的键比较。如果它和其中的任何一个相等,查找命中。否则我们就根据比较的结果找到指向相应区间的链接,并在其指向的子树中递归地继续查找。如果这是个空链接,查找未命中。
插入
规则: 加入新节点时,不会往空的位置添加节点,而是添加到最后一个叶子节点上 四节点可以被分解三个2-节点组成的树,并且分解后新树的根节点需要向上和父节点融合
1.对于空树,插入一个2节点即可 2.插入节点到一个2节点的叶子上 3.由于其本身只有一个元素,所以只需要将其升级为3节点即可。
对于空树,插入一个2节点
往3节点中插入一个新数据 因为3节点本省就是2-3树的最大容量(已经有两个元素),因此需要拆分。分情况讨论如下所示: 只有一个3-结点的树,向其插入一个新键 这棵树唯一的结点中已经没有可插入的空间了。我们又不能把新键插在其空结点上(破坏了完美平衡)。为了将新键插入,我们先临时将新键存入该结点中,使之成为一个4-结点。创建一个4-结点很方便,因为很容易将它转换为一颗由3个2-结点组成的2-3树(如图所示),这棵树既是一颗含有3个结点的二叉查找树,同时也是一颗完美平衡的2-3树,其中所有空链接到根结点的距离都相等
往3节点中插入一个新数据
向一个父节点为2节点的3节点中插入数据 假设未命中的查找结束于一个3-结点,而它的父结点是一个2-结点。在这种情况下我们需要在维持树的完美平衡的前提下为新键腾出空间。 我们先像刚才一样构造一个临时的4-结点并将其分解,但此时我们不会为中键创建一个新结点,而是将其移动至原来的父结点中。
这次转换并没有影响2-3树的主要性质,树仍然是有序的,因为中键被移动到父节点中去了。 向一个父节点为3节点的3节点中插入数据 假设未命中的查找结束于一个3-结点,而它的父结点是一个3-结点。 我们再次和刚才一样构造一个临时的4-结点并分解它,然后将它的中键插入它的父结点中。但父结点也是一个3-结点,因此我们再用这个中键构造一个新的临时4-结点,然后在这个结点上进行相同的变换,即分解这个父结点并将它的中键插入到它的父结点中去。 我们就这样一直向上不断分解临时的4-结点并将中键插入更高的父结点,直至遇到一个2-结点并将它替换为一个不需要继续分解的3-结点,或者是到达3-结点的根。
子主题
红黑树的产生
既然2-3树已经能够保持自平衡,为什么我们还需要一棵红黑树呢,这是因为 2-3树这种每个节点储存1~2个元素以及拆分节点向上融合的性质不便于代码操作,因此我们希望通过一些规则,将2-3树转换成二叉树,且转换后的二叉树依然能保持平衡性。
2-3树和红黑树的等价性
1. 对于2-3树中的2-节点来说,本身就和二叉搜索树的节点无异,可以直接转换为红黑树的一个黑节点。 2. 对于3-节点来说,我们需要进行一点小转换: 1. 将3-节点拆开,成为一棵树,并且3-节点的左元素作为右元素的子树 2. 将原来的左元素标记为红色(表示红色节点与其父节点在2-3树中曾是平级的关系)
基本性质分析
(1) 每个节点或者是黑色,或者是红色
(2) 根节点是黑色
根节点要么对应2-3树的2-节点或者3-节点,而这两者的根节点都是黑色的,因而根节点必然是黑色。从上图2-3树节点和红黑树节点对应关系就能很容易看出来
(3) 每个叶子节点是黑色
注意,这里的叶子是指的为空的叶子节点,上图的红黑树的完整形式应该是这样的:
(4) 如果一个节点是红色的,则它的子节点必须是黑色的
由于红黑树的每个节点都由2-3树转换而来,红色节点连接的节点必然是一个2-节点或者3-节点,而无论是2-节点还是3-节点,其根节点都是黑色的,因此红色节点的子节点必然是黑色的
(5) 从任意一个节点到叶子节点,经过的黑色节点是一样多的
这是红黑树最重要的一条性质,也是红黑树的价值所在。由于红黑树是由2-3树转换而来,因此每一个黑色节点必然对应2-3树的某个2-节点或者3-节点,因此红黑树的黑节点也能拥有2-3树的平衡性。
红黑树的创建
描述: 如何由2-3树转换一棵红黑树,下面我们就来看看如何不经过2-3树直接创建一棵红黑树,毕竟我们写代码的时候不能先创建一棵2-3树再转化成红黑树吧。 2-3树的创建规则: 规则1. 加入新节点时,不会往空的位置添加节点,而是添加到最后一个叶子节点上 规则2. 四节点可以被分解三个2-节点组成的树,并且分解后新树的根节点需要向上和父节点融合 简单来说,2-3树的创建分为「融合」和「拆分」两步,为了实现这两步,我们需要在创建二叉树的基础操作上增加另外几个操作,分别是: 1. 保持根节点黑色 2. 左旋转 3. 右旋转 4. 颜色翻转
保持根节点黑色和左旋转
由于我们往2-3树插入节点时做的都是融合,因此新加入的节点和原位置的节点是平级关系,所以我们往红黑树里增加节点的时候,增加的都是红色节点。
我们插入第一个红色节点42,哦吼,第一步就与红黑树的性质2「根节点是黑色」冲突,为了解决这种冲突,我们将根节点变成黑色。
这里我们要思考一下,如果我们颠倒顺序,先插入 37 再插入 42 呢,是直接把 42 加到 37 的右子树上么,这显然是错误的,因为在前边2-3树转红黑树的过程中,我们已经了解到,所有的红色节点都只会出现在左子树上,因此我们需要进行左旋转,将节点的位置和颜色旋转过来。
上面是两个独立的节点,如果节点拥有左右子树,在旋转后仍然能满足二叉搜索树的性质吗,我们需要推广到一般情形。
B树(Blance-Tree)
描述: B树,在写法上通常是B-树,这不是减号的意思,只是一种表达方式,它是一种能够存储数据、对数据进行排序并允许以O(log n)的时间复杂度运行进行查找、顺序读取、插入和删除的数据结构。概括来说是一个节点可以拥有多于2个节点的二叉查找树。
特点
1. B树根节点至少有两个节点,每个节点可以有多个子树 2. 每个中间节点都包含k-1个元素和k个子树,其中 m/2 ⇐ k ⇐ m 3. 所有的叶子结点都位于同一层 4. 每个节点中的元素从小到大排列,节点当中k-1个元素正好是k个孩子包含的元素的值域分划。
优点
优点在于,由于B树的每一个节点都包含key和value,因此经常访问的元素可能离根节点更近,因此访问也更迅速
缺点
B树在提高了IO性能的同时并没有解决元素遍历的我效率低下的问题,正是为了解决这个问题,B+树应用而生。B+树只需要去遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低。
每个节点中的元素从小到大排列,节点当中k-1个元素正好是k个孩子包含的元素的值域分划。
B+树
描述: B+树是对B树的一种变形树,它与B树的差异在于:
特点
1. 有k个子结点的结点必然有k个关键码; 2. 非叶结点仅具有索引作用,跟记录有关的信息均存放在叶结点中。 3. 树的所有叶结点构成一个有序链表,可以按照关键码排序的次序遍历全部记录。
插入
B和B+树的区别在于,B+树的非叶子结点只包含导航信息,不包含实际的值,所有的叶子结点和相连的节点使用链表相连,便于区间查找和遍历。
优点
1. 由于B+树在内部节点上不好含数据信息,因此在内存页中能够存放更多的key。 数据存放的更加紧密,具有更好的空间局部性。因此访问叶子节点上关联的数据也具有更好的缓存命中率。 2. B+树的叶子结点都是相链的,因此对整棵树的遍历只需要一次线性遍历叶子结点即可。而且由于数据顺序排列并且相连,所以便于区间查找和搜索。而B树则需要进行每一层的递归遍历。相邻的元素可能在内存中不相邻,所以缓存命中性没有B+树好。
B树与B+树区别
数据库索引 用B+树
1. B+树的磁盘读写代价更低:B+树的内部节点并没有指向关键字具体信息的指针,因此其内部节点相对B树更小,如果把所有同一内部节点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多,一次性读入内存的需要查找的关键字也就越多,相对IO读写次数就降低了。 2. B+树的查询效率更加稳定:由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。 3. 由于B+树的数据都存储在叶子结点中,分支结点均为索引,方便扫库,只需要扫一遍叶子结点即可,但是B树因为其分支结点同样存储着数据,我们要找到具体的数据,需要进行一次中序遍历按序来扫,所以B+树更加适合在区间查询的情况,所以通常B+树用于数据库索引。
红黑树
红黑树也叫RB树,RB-Tree。是一种自平衡的二叉查找树,它的节点的颜色为红色和黑色。它不严格控制左、右子树高度或节点数之差小于等于1。也是一种解决二叉查找树极端情况的数据结构。
红黑树在查找方面和AVL树操作几乎相同。但是在插入和删除操作上,AVL树每次插入删除会进行大量的平衡度计算,红黑树是牺牲了严格的高度平衡的优越条件为代价,它只要求部分地达到平衡要求,结合变色,降低了对旋转的要求,从而提高了性能。红黑树能够以O(log2 n)的时间复杂度进行搜索、插入、删除操作。此外,由于它的设计,任何不平衡都会在三次旋转之内解决。
相比于BST,因为红黑树可以能确保树的最长路径不大于两倍的最短路径的长度,所以可以看出它的查找效果是有最低保证的。在最坏的情况下也可以保证O(logN)的,这是要好于二叉查找树的。因为二叉查找树最坏情况可以让查找达到O(N)。
红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高,所以在插入和删除中所做的后期维护操作肯定会比红黑树要耗时好多,但是他们的查找效率都是O(logN),所以红黑树应用还是高于AVL树的. 实际上插入 AVL 树和红黑树的速度取决于你所插入的数据.如果你的数据分布较好,则比较宜于采用 AVL树(例如随机产生系列数),但是如果你想处理比较杂乱的情况,则红黑树是比较快的。 红黑树广泛用于TreeMap、TreeSet,以及jdk1.8后的HashMap(hash冲突链表超过8就转换成红黑树)。
规定
节点是红色或黑色。 根节点是黑色。 每个叶子节点都是黑色的空节点(NIL节点)。 每个红色节点的两个子节点都是黑色。也就是说从每个叶子到根的所有路径上不能有两个连续的红色节点)。 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
哈夫曼树
算法
算法分析计算
时间复杂度
空间复杂度
经典算法
排序
归并
快排
思想
通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
是否稳定
高级算法
贪婪
回溯
递归的过程 递归的下面就是回溯
纯暴力搜索算法
解决问题
组合:1234,组合12,13等 切割:字符串的切割 子集:1234的子集12,23等 排列:有序 棋盘:
如何理解
抽象树型结构
N叉树
树宽度:集合的大小。 每个节点所处理的集合的大小
循环处理
树的深度:递归的深度
递归处理
没有返回值 void backTracking(参数){ if(终止条件){ 收集结果 return; } 单层搜索 for(集合元素集){ 处理节点,如12 递归函数 回溯操作(撤消操作) } }
例:1234
子主题
模块
子主题
剪枝
动态规划
大数据算法
Hash分桶
统计
算法思想
递推
递归
定义方法的参数 一层一层的向下找
应用场景:文件夹的遍历
穷举
贪心
迭代
解决问题
10个100M日志文件,如何排序一个文件
思路:可用10个线程,读取文件,读取每个文件第一行,将其10条记录比较,最小的放到新文件中
线性排序
问题
如何根据年龄给100万用户数据排序
三个排序描述
桶排序、计数排序、基数排序。因为这些排序算法的时间复杂度是线性的,之所以能做到线性的时间复杂度,主要原因是,这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。
1.桶排序
核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序的核心思想
要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。
如果分到每个桶中的数据不均,大的桶内的数据无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。
场景
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中
2.计数排序
描述
计数排序其实是桶排序的一种特殊情况。 当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
场景
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
解决问题
考生按分数排序
描述: 考生的满分是900分,最小是0分,这个数据的范围很小,所以可以分成901个桶,对应的分数从0分到900分。
分析
50万考生划分到901个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了50万考生的排序。 只涉及扫描遍历操作,所以时间复杂度是O(n)
算法实现
假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8] 中,它们分别是:2,5,3,0,2,3,0,3。 考生的成绩从0到5分,我们使用大小为6的数组C[6]表示桶,其中下标对应分数。不过,C[6]内存储的并不是考生,而是对应的考生个数。只需要遍历一遍考生分数,就可以得到C[6]的值
下标内的值是考生个数 下标是考生分数
R是排序之后的有序数组 成绩为3的考生会保存下标4,5,6的位置
我们对C[6]数组顺序求和,C[K]里存储小于等于分数k的考生个数
我们从后到前依次扫描数组 A。比如,当扫描到 3 时我们可以从数组 C 中取出下标为 3 的值 7,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3] 要减 1,变成 6。 当我们扫描到第 2 个分数为 3 的考生的时候,就会把它放入数组 R 中的第 6 个元素的位置(也就是下标为 5 的位置)。当我们扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了。
3.基数排序
基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
容器
docker
https://blog.csdn.net/CleverCode/article/details/83863925
子主题
子主题
命令
容器
查看容器
docker container ps -a docker ps -a
进入容器
docker container exec -it tom /bin/bash docker exec -it 9df70f9a0714 /bin/bash
运行
docker run --name mysql -d -p 6666:3306 mysql:5.7
docker run --name elasticsearch -p 9200:9200 -p 9300:9300 -v /Users/duwei/software/docker/elasticsearch/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml -v /Users/duwei/software/docker/elasticsearch/data:/usr/share/elasticsearch/data -v /Users/duwei/software/docker/elasticsearch/plugins:/usr/share/elasticsearch/plugins -d 5acf0e8da90b
停止
docker stop 容器ID
卸载
docker rm -f 容器ID
查看容器日志
docker logs 容器ID
镜像
查看
docker images
删除
docker rmi [OPTIONS] IMAGE [IMAGE...]
-f, --force Force removal of the image --no-prune Do not delete untagged parents
搜索
docker search java
拉镜像
docker pull java:8
提交新镜像
docker commit -m="描述信息" -a="作者" 容器ID或容器名 镜像名:TAG
docker run --name tom -p 8080:8080 -d abc/tomcat:v1.0
安装
1.Dockerfile构建镜像
build
docker build -t mytomcat .
run
docker run --name mytomcat -d -p 8080:8080 mytomcat
docker run --name tomcat -p 8080:8080 -v $PWD/test:/usr/local/tomcat/webapps/test -d tomcat
-p 8080:8080:将主机的 8080 端口映射到容器的 8080 端口。 -v $PWD/test:/usr/local/tomcat/webapps/test:将主机中当前目录下的 test 挂载到容器的 /test。
例
#指定创建镜像的基础镜像 FROM centos #指定镜像的版本号信息 ARG myname #指定作者和元数据标签 MAINTAINER duwei LABEL author="duwei" #复制文件到镜像 ADD jdk-8u271-linux-x64.tar.gz /usr/local ADD apache-tomcat-9.0.39.tar.gz /usr/local #配置环境变量 ENV JAVA_HOME /usr/local/jdk1.8.0_271 ENV CLASS_PATH .:$JAVA_HOME/lib ENV CATALINA_HOME /usr/local/apache-tomcat-9.0.39 ENV PATH $PATH:$JAVA_HOME/bin:$CATALINA_HOME/bin #工作目录,类似于cd命令 WORKDIR $CATALINA_HOME #运行Linux系统的命令使用. RUN用于指定镜像构建时所要执行的命令 RUN yum -y install vim #用于持久化目录 VOLUME ['/data1'] #公开端口 EXPOSE 8080 #用于指定在容器启动时所要执行的命令 CMD ls CMD pwd #配置容器,使其可执行化。配合CMD可省去“application”,只使用参数 # command param1 param2 ENTRYPOINT ["/usr/local/apache-tomcat-9.0.39/bin/catalina.sh", "run"]
2.pull
docker pull tomcat
挂载
查看
docker container inspect
挂载
docker cp 容器名:SRC路径 目标路径
docker cp mysql:/etc/mysql/mysql.conf.d/mysqld.cnf /my/mysql/conf/
复制
COPY
用于从宿主机复制文件到创建的新镜像文件
ADD
基本用法和COPY指令一样,ADD支持使用TAR文件和URL路径
网络
k8s
YAML
描述
YAML 文件的方式,即:把容器的定义、参数、配置,统统记录在一个 YAML 文件中,然后用这样一句指令把它运行起来: $ kubectl create -f 我的配置文件
Deployment
所谓 Deployment,是一个定义多副本应用(即多个副本 Pod)的对象。 Deployment 还负责在 Pod 定义发生变化时,对每个副本进行滚动更新(Rolling Update)。
分为
Metadata
描述
存放的是这个对象的元数据,对所有 API 对象来说,这一部分的字段和格式基本上是一样的;
这个字段就是 API 对象的“标识”,即元数据,它也是我们从 Kubernetes 里找到这个对象的主要依据。
Labels
Labels 就是一组 key-value 格式的标签。而像 Deployment 这样的控制器对象,就可以通过这个 Labels 字段从 Kubernetes 中过滤出它所关心的被控制对象。 比如,这个 YAML 文件中,Deployment 会把所有正在运行的、携带“app: nginx”标签的 Pod 识别为被管理的对象,并确保这些 Pod 的总数严格等于两个。 而这个过滤规则的定义,是在 Deployment 的“spec.selector.matchLabels”字段。我们一般称之为:Label Selector。
Spec
描述
属于这个对象独有的定义,用来描述它所要表达的功能。
Pod
Pod 就是 Kubernetes 世界里的“应用”;而一个应用,可以由多个容器组成。 Pod 扮演的是传统部署环境里“虚拟机”的角色。这样的设计,是为了使用户从传统环境(虚拟机环境)向 Kubernetes(容器环境)的迁移,更加平滑。
理解
把 Pod 看成传统环境里的“机器”、把容器看作是运行在这个“机器”里的“用户程序”,那么很多关于 Pod 对象的设计就非常容易理解了。比如,凡是调度、网络、存储,以及安全相关的属性,基本上是 Pod 级别的。
重要字段
NodeSelector
是一个供用户将 Pod 与 Node 进行绑定的字段
apiVersion: v1 kind: Pod ... spec: nodeSelector: disktype: ssd
意味着这个 Pod 永远只能运行在携带了“disktype: ssd”标签(Label)的节点上;否则,它将调度失败。
NodeName
这个字段一般由调度器负责设置,但用户也可以设置它来“骗过”调度器,当然这个做法一般是在测试或者调试的时候才会用到。
HostAliases
定义了 Pod 的 hosts 文件(比如 /etc/hosts)里的内容
apiVersion: v1 kind: Pod ... spec: hostAliases: - ip: "10.1.2.3" hostnames: - "foo.remote" - "bar.remote" ...
在这个 Pod 的 YAML 文件中,我设置了一组 IP 和 hostname 的数据。这样,这个 Pod 启动后,/etc/hosts 文件的内容 cat /etc/hosts # Kubernetes-managed hosts file. 127.0.0.1 localhost ... 10.244.135.10 hostaliases-pod 10.1.2.3 foo.remote 10.1.2.3 bar.remote
需要指出的是,在 Kubernetes 项目中,如果要设置 hosts 文件里的内容,一定要通过这种方法。否则,如果直接修改了 hosts 文件的话,在 Pod 被删除重建之后,kubelet 会自动覆盖掉被修改的内容。
Containers
介绍过“Init Containers”。其实,这两个字段都属于 Pod 对容器的定义,内容也完全相同,只是 Init Containers 的生命周期,会先于所有的 Containers,并且严格按照定义的顺序执行。
Image(镜像)、Command(启动命令)、workingDir(容器的工作目录)、Ports(容器要开发的端口),以及 volumeMounts(容器要挂载的 Volume)都是构成 Kubernetes 项目中 Container 的主要字段
ImagePullPolicy
默认是 Always,即每次创建 Pod 都重新拉取一次镜像。另外,当容器的镜像是类似于 nginx 或者 nginx:latest 这样的名字时,ImagePullPolicy 也会被认为 Always。 而如果它的值被定义为 Never 或者 IfNotPresent,则意味着 Pod 永远不会主动拉取这个镜像,或者只在宿主机上不存在这个镜像时才拉取。
Lifecycle
定义的是 Container Lifecycle Hooks。顾名思义,Container Lifecycle Hooks 的作用,是在容器状态发生变化时触发一系列“钩子”
apiVersion: v1 kind: Pod metadata: name: lifecycle-demo spec: containers: - name: lifecycle-demo-container image: nginx lifecycle: postStart: exec: command: ["/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"] preStop: exec: command: ["/usr/sbin/nginx","-s","quit"]
先说 postStart 吧。它指的是,在容器启动后,立刻执行一个指定的操作。需要明确的是,postStart 定义的操作,虽然是在 Docker 容器 ENTRYPOINT 执行之后,但它并不严格保证顺序。也就是说,在 postStart 启动时,ENTRYPOINT 有可能还没有结束。 当然,如果 postStart 执行超时或者错误,Kubernetes 会在该 Pod 的 Events 中报出该容器启动失败的错误信息,导致 Pod 也处于失败的状态。
preStop 发生的时机,则是容器被杀死之前(比如,收到了 SIGKILL 信号)。而需要明确的是,preStop 操作的执行,是同步的。所以,它会阻塞当前的容器杀死流程,直到这个 Hook 定义操作完成之后,才允许容器被杀死,这跟 postStart 不一样。
我们在容器成功启动之后,在 /usr/share/message 里写入了一句“欢迎信息”(即 postStart 定义的操作)。而在这个容器被删除之前,我们则先调用了 nginx 的退出指令(即 preStop 定义的操作),从而实现了容器的“优雅退出”。
Status
除了 Metadata 和 Spec 之外的第三个重要字段。其中,pod.status.phase,就是 Pod 的当前状态,它有如下几种可能的情况:
Pending。这个状态意味着,Pod 的 YAML 文件已经提交给了 Kubernetes,API 对象已经被创建并保存在 Etcd 当中。但是,这个 Pod 里有些容器因为某种原因而不能被顺利创建。比如,调度不成功。 Running。这个状态下,Pod 已经调度成功,跟一个具体的节点绑定。它包含的容器都已经创建成功,并且至少有一个正在运行中。 Succeeded。这个状态意味着,Pod 里的所有容器都正常运行完毕,并且已经退出了。这种情况在运行一次性任务时最为常见。 Failed。这个状态下,Pod 里至少有一个容器以不正常的状态(非 0 的返回码)退出。这个状态的出现,意味着你得想办法 Debug 这个容器的应用,比如查看 Pod 的 Events 和日志。 Unknown。这是一个异常状态,意味着 Pod 的状态不能持续地被 kubelet 汇报给 kube-apiserver,这很有可能是主从节点(Master 和 Kubelet)间的通信出现了问题。
Conditions
这些细分状态的值包括:PodScheduled、Ready、Initialized,以及 Unschedulable。它们主要用于描述造成当前 Status 的具体原因是什么。 比如,Pod 当前的 Status 是 Pending,对应的 Condition 是 Unschedulable,这就意味着它的调度出现了问题。
发现
你可能也会发现,凡是跟容器的 Linux Namespace 相关的属性,也一定是 Pod 级别的。这个原因也很容易理解:Pod 的设计,就是要让它里面的容器尽可能多地共享 Linux Namespace,仅保留必要的隔离和限制能力。这样,Pod 模拟出的效果,就跟虚拟机里程序间的关系非常类似了。
例子
apiVersion: v1 kind: Pod metadata: name: nginx spec: shareProcessNamespace: true containers: - name: nginx image: nginx - name: shell image: busybox stdin: true tty: true
这就意味着这个 Pod 里的容器要共享 PID Namespace。 定义了两个容器:一个是 nginx 容器,一个是开启了 tty 和 stdin 的 shell 容器。
可以直接认为 tty 就是 Linux 给用户提供的一个常驻小程序,用于接收用户的标准输入,返回操作系统的标准输出。当然,为了能够在 tty 中输入信息,你还需要同时开启 stdin(标准输入流)。
这个 Pod 被创建后,你就可以使用 shell 容器的 tty 跟这个容器进行交互了 $ kubectl create -f nginx.yaml 使用 kubectl attach 命令,连接到 shell 容器的 tty 上 $ kubectl attach -it nginx -c shell 就可以在 shell 容器里执行 ps 指令,查看所有正在运行的进程 ps ax
凡是 Pod 中的容器要共享宿主机的 Namespace,也一定是 Pod 级别的定义
apiVersion: v1 kind: Pod metadata: name: nginx spec: hostNetwork: true hostIPC: true hostPID: true containers: - name: nginx image: nginx - name: shell image: busybox stdin: true tty: true
定义了共享宿主机的 Network、IPC 和 PID Namespace。这就意味着,这个 Pod 里的所有容器,会直接使用宿主机的网络、直接与宿主机进行 IPC 通信、看到宿主机里正在运行的所有进程。
留言
对于 Pod 状态是 Ready,实际上不能提供服务的情况能想到几个例子: 1. 程序本身有 bug,本来应该返回 200,但因为代码问题,返回的是500; 2. 程序因为内存问题,已经僵死,但进程还在,但无响应; 3. Dockerfile 写的不规范,应用程序不是主进程,那么主进程出了什么问题都无法发现; 4. 程序出现死循环。
POD的直议是豆荚,豆荚中的一个或者多个豆属于同一个家庭,共享一个物理豆荚(可以共享调度、网络、存储,以及安全),每个豆虽然有自己的空间,但是由于之间的缝隙,可以近距离无缝沟通(Linux Namespace相关的属性)。
容器化应用
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment spec: selector: matchLabels: app: nginx replicas: 2 template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.7.9 ports: - containerPort: 80
像这样的一个 YAML 文件,对应到 Kubernetes 中,就是一个 API Object(API 对象)。当你为这个对象的各个字段填好值并提交给 Kubernetes 之后,Kubernetes 就会负责创建出这些对象所定义的容器或者其他类型的 API 资源。
定义的 Pod 副本个数 (spec.replicas) 是:2
这个容器的镜像(spec.containers.image)是 nginx:1.7.9,这个容器监听端口(containerPort)是 80。
Deployment 扮演的正是 Pod 的控制器的角色
运行
$ kubectl create -f nginx-deployment.yaml
检查
$ kubectl get pods -l app=nginx NAME READY STATUS RESTARTS AGE nginx-deployment-67594d6bf6-9gdvr 1/1 Running 0 10m nginx-deployment-67594d6bf6-v6j7w 1/1 Running 0 10m
kubectl get 指令的作用,就是从 Kubernetes 里面获取(GET)指定的 API 对象。可以看到,在这里我还加上了一个 -l 参数,即获取所有匹配 app: nginx 标签的 Pod。需要注意的是,在命令行中,所有 key-value 格式的参数,都使用“=”而非“:”表示。
实战与学习项目
问题一般是从介绍的项目背景来问 1.介绍项目背景 2.框架、数据库 3.技术,如分布式日志追踪 4.项目遇到问题
控制在1分钟里面,讲出项目基本情况,比如项目名称,背景,给哪个客户做,完成了基本的事情,做了多久,项目规模多大,用到哪些技术,数据库用什么,然后酌情简单说一下模块。重点突出背景,技术,数据库和其他和技术有关的信息。
要主动说出你做了哪些事情,这部分的描述一定需要和你的技术背景一致。
描述你在项目里的角色
可以描述用到的技术细节,特别是你用到的技术细节,这部分尤其要注意,你说出口的,一定要知道,因为面试官后面就根据这个问的。 你如果做了5个模块,宁可只说你能熟练说上口的2个。
这部分你风险自己承担,如果可以,不露声色说出一些热门的要素,比如Linux,大数据,大访问压力等。但一旦你说了,面试官就会直接问细节。
汇桔网
知识产权交易平台
商标、专利、版权等知识产权创造与保护,以及知识产权的交易等
数字版权
项目介绍
数字版权项目,担任架构师与高级工程师主要参于完成了架构设计 1.从分析需求看,了解用户角色,像会员体系、第三方认证体系、运营体系等, 2.使用场景,如门户、中台、合同档案、工单、作件 3.选择成熟架构设计方案,结合公司情况,选用前后端分离 4.自顶向下的细化工作,如分层,分模块、API设计、数据库设计等 5.验证和优化架构设计方案,如满足半年时间的业务增长 用到了开源springboot+dubbo+rabbitMQ+mybatis+redis+es+oss+mongDB+爬虫
介绍数字版权
业务
汇桔数字版权应用平台,它是创作者的版权管家
数字版权是由技术驱动的产品,结合区块链技术、可信时间戳技术与大数据分析,为知商企业与个人提供“确权、用权、维权、交易”的一站式版权服务平台。
存证(确权)
区块链存证
用户提交作品信息,服务端计算出文件的hash值并上传到区块链平台上,然后生成一张数字版权区块链证书。目前为免费试用。
时间戳存证
时间戳认证是运用了联合时间戳信任服务中心提供的认证方案,是国家法律认可的认证服务
监测
监测内容
音乐
图片
监测服务
发起爬虫--数据对比--生成报告
爬虫通过四大搜索引擎(谷歌、百度、必应、搜狗)、两大电商(淘宝天猫、京东)、特定网站等爬取数据。搜索引擎爬虫为重点。
监测内容
图片监测:图片特征提取算法(opencv+faiss)。目前比 较精确的数据都是来自搜索引擎。
音乐监测:关键词搜索,歌曲名、歌手名、专辑名
维权
利用监测服务监测版权信息,发现侵权,可立即存证以及发起维权。
公证
网页证据公证(法信、公证处)
网页证据固定(区块链)
商标转让公证
功能
确权服务(存证)
通过我们的区块链技术、联合信任时间戳技术来存证我们的作品,并颁发有效证书,在版权交易及维权时,能够提供有效的证据。
确权服务(公证)
描述:高最公信力 保留维权证据。 1. 掌握侵权证据,占领维权优势 2. 链接全国公证机构,可申请权威公证书 3. 操作高效简单,一切以版权方利益为首
公证服务是当你发现你的作品被其他网站侵权了,可以通过公证服务来截图
维权服务(监测服务)
通过上传待监测图片信息,调用faiss接口服务查询,并给出监测报告
采用什么样的架构
介绍: 为了能够支撑公司的业务复杂,大用户量的访问,个别时段高并发的以及大数据分析的场景。结合公司现有的技术框架、业务体系、交互规划,主要特点大数据计算技术,存储技术,平台前后端分离等 我们采用了这样的一个技术架构体系,分三个层面介绍 1. 表现层采用独立部署NODE、VUE 2. 业务处理层包括有数据采集服务、流数据处理、数据特征计算(opencv计算图片特征值)、数据存储服务(图片特征值存储),实时分析spark,监测服务等 3. 数据层包括基础数据存储MYSQL,热点数据缓存redis,大数据检索服务ES,项目是以faissbook的faiss框架作为内存数据库存特征码。
业务架构
技术架构
拓扑图
服务有哪些
bq-api:数字版权api,主要发起监控、报告查询和存证功能 bq-spider:爬虫框架,主要监听消息,触发爬虫,保存数据到Es和Ts bq-download:图片下载,主要提供图片的分析过滤与下载 faiss-search:以图搜图框架,主要是提供保存特殊点到Ts和faiss库 pic-spider:电商图片爬虫框架
量
用户量
每日访问用户:23万 活跃用户:15万 并发访问:2.6万 平均响应时间:7毫秒 首页单个接口调用:130万QPS/天 首页单接口最高并发量3万QPS 数据存储量:10亿?
bq_api生产环境布5台机器 压测每台机器支撑3.5万QPS
指标: 订单量:简化作件流程,同时用户监测效率的提高,也给用户带来信心和信任,从而提高作品存证的订单量。 监测效率:opencv计算图片特征值,提高监测准确率。 用户维权量:每天300个。监测的准确性,及时性,用户维权,给用户减少财产损失。 转化率:访问客户中成功完成购买的人群占比。18% 用户留存率:用户使用版权之后,仍然继续使用的用户。留存率 = 仍旧使用的用户/ 当初的总用户量。达到39%。 赋能: 解决业务问题(爬虫解决什么问题,采集数据,监控互联网数据,提升用户作品侵权,算法提升效率,提升90%。提升用户版权的损失) 落地: 价值:数据量 订单量多少,用户维权 针对某一个技术,深入原理,问题,提升
spring全家桶,netty thiry dubbo springcloud ,flume,spark,hdfs hadoop,hbase,hhive。很快的
应用业务解决方案
大数据
大数据获取
技术
描述:代替人们自动地在互联网中进行数据信息的采集与整理。
爬虫可以做什么: 可以实现搜索引擎 大数据时代,可以让我们获取更多的数据源。 快速填充测试和运营数据 为人工智能提供训练数据集
爬虫的分布式架构
实现分布式抓取 1. 需基于缓存RedisScheduler spider.setScheduler(new RedisScheduler(jedisPool)); 2. 需要进行分布式抓取,其队列应该是共享的,即多台服务器的多个爬虫使用同一个 Redis URL 队列,取 URL 或者添加 URL 都是同一个。 3. 将URL存入Redis URL队列push<item_UUID, URL, Request>,通过 task 的 UUID 获取到队列的 key,然后利用 redis 的 list 的 lpop 命令从队列左侧弹出一个带抓取的 URL,构造 Request 对象。
分布式
webmagic
底层实现
HttpClient + Jsoup
jsoup 是一款Java 的HTML解析器,可直接解析某个URL地址、HTML文本内容。它提供了一套非常省力的API,可通过DOM,CSS以及类似于jQuery的操作方法来取出和操作数 据。
主要特色
完全模块化的设计,强大的可扩展性。 核心简单但是涵盖爬虫的全部流程,灵活而强大,也是学习爬虫入门的好材料。提供丰富的抽取页面API。 无配置,但是可通过POJO+注解形式实现一个爬虫。支持多线程。 支持分布式。 支持爬取js动态渲染的页面。 无框架依赖,可以灵活的嵌入到项目中去。
架构
WebMagic项目代码分为核心和扩展两部分。 1. 核心部分(webmagic-core)是一个精简的、模块化的爬虫实现,而扩展部分则包括一些便利的、实用性的功能。 2. 扩展部分(webmagic-extension)提供一些便捷的功能,例如注解模式编写爬虫等。同时内置了一些常用的组件,便于爬虫开发。
WebMagic的结构分为Downloader、PageProcessor、Scheduler、Pipeline四大组 件,并由Spider将它们彼此组织起来。这四大组件对应爬虫生命周期中的下载、处理、管 理和持久化等功能。而Spider则将这几个组件组织起来,让它们可以互相交互,流程化的 执行,可以认为Spider是一个大的容器,它也是WebMagic逻辑的核心。
四大组件
1. Scheduler: 负责管理待抓取的URL,以及一些去重的工作。WebMagic默认提供了JDK的内存队列来管理URL,并用集合来进行去重。也支持使用Redis进行分布式管理。
解决问题
每次运行可能会爬取重复的页面
Scheduler(URL管理) 最基本的功能是实现对已经爬取的URL进行标示。可以实现URL的增量去重。 目前scheduler主要有三种实现方式: 1)内存队列 QueueScheduler 2)文件队列FileCacheQueueScheduler 3) Redis队列 RedisScheduler
2. Downloader: 负责从互联网上下载页面,以便后续处理。WebMagic默认使用了ApacheHttpClient作为下载工具。
3. PageProcessor: 负责解析页面,抽取有用信息,以及发现新的链接。WebMagic使用Jsoup 作为HTML解析工具,并基于其开发了解析XPath的工具Xsoup。 在这四个组件中,PageProcessor对于每个站点每个页面都不一样,是需要使用者定制的部分。
4. Pipeline: 负责抽取结果的处理,包括计算、持久化到文件、数据库等。WebMagic默认提供了“输出到控制台”和“保存到文件”两种结果处理方案。
类
Page
Page代表了从Downloader下载到的一个页面——可能是HTML,也可能是JSON或者其他文本格式的内容。Page是WebMagic抽取过程的核心对象,它提供一些方法可供抽 取、结果保存等。
Site
Site用于定义站点本身的一些配置信息,例如编码、HTTP头、超时时间、重试策略 等、代理等,都可以通过设置Site对象来进行配置
存储
kafka
子主题
tablestore
TableStore作为阿里云提供的一款全托管、分布式NoSql型数据存储服务,具有【海量数据存储】、【热点数据自动分片】、【海量数据多维检索】等功能,天然地解决了订单数据大爆炸这一挑战; 同时,SearchIndex功能在保证用户数据高可用的基础上,提供了数据多维度搜索、统计等能力。针对多种场景创建多种索引,实现多种模式的检索。用户可以仅在需要的时候创建、开通索引。由TableStore来保证数据同步的一致性,这极大的降低了用户的方案设计、服务运维、代码开发等工作量。
对比
hadoop
yarn服务管理 专门管理hadoop的
分析
spark内存计算引擎
如何数据清洗
1.数据收集
描述:作品监测收集知名网站数据
收集数据考虑到实时性不高,可能采用异步方案
1.定时任务触发爬虫爬取网站信息。 2.信息推送到MQ存储 3.信息服务下载图片并计算特征值 4.存入阿里云faiss-search
2.特征计算
1. MQ消费并下载图片 2. 用OpenCV计算图像特征值
OpenCV
OpenCV在诸多领域得到了广泛的应用,例如物体检测、图像识别、运动跟踪、增强现实(AR)、机器人等场景。
3.实时分析
项目是以faissbook的faiss框架作为内存数据库,通过opencv抽取图片特征,存储到faiss中,项目提供faiss的查询接口,封装了注册中心,通过修改faiss配置文件的指定ip,进行分布式多faiss库的部署,一个机器的faiss库将通过dubbo,生成一个泛化接口,进行查询和添加图片用。
4.日志服务
5.公证计算
6.存证计算
应用技术解决方案
百度配置中心
描述:分布式环境下多台服务实例的配置统一管理问题
设计理念
简单易用、体验良好 支持配置、配置发布、更新统一化 配置更新自动化(用户在平台更新配置,使用该配置的系统会自动发现该情况,并应用新配置)
原理
Disconf通过disconf-web管理配置信息,然后将配置的key在Zookeeper上建立节点,disconf-client启动后拉取自身需要的配置信息并监听Zookeeper的节点。在web上更新配置信息会触发zk节点状态的变动,client可以实时感知到变化,然后从web上拉取最新配置信息。
架构
讲解知识
基于注解
在需要进行配置的类通过DisconfFile注解指名配置从哪个文件获取,在get方法通过DisconfFileItem注解的name属性指定文件中的key,associateField指类的属性,即当前值设置到当前类的哪个属性中。 @DisconfFile(filename = "redis.properties") @DisconfFileItem(name = "redis.host", associateField = "host")
client端
系统启动时Bean初始化前 1.扫描配置文件类和配置项 2.下载配置数据 3.watch监控配置数据变化 4.注册到仓库中 系统启动时Bean初始化后 1.配置仓库容器模块:设置注册回调函数 2.配置仓库容器模块:注入Bean类 系统正常运行时请求配置数据 1.配置仓库容器模块:AOP拦截请求,返回仓库配置数据 系统正常运行时配置更新 1.配置仓库容器模块:接收远程推送配置数据,并注入到仓库和配置类里 2.配置仓库容器模块:调用回调函数进行控制
web端
架构
1. web主要用于管理配置项,当新建配置项后,会保存到DB的config及config-history表,并邮件通知app表对应的邮件地址。 2. 对更新的配置项,会更新config表并新增记录到config-history表,邮件通知,同时添加zk节点(/disconf/app_version_env/file(或item)/配置项),当client端更新配置后,会在此节点下写入client标识,所以就有了web端的“实例列表”。 3. client启动后会从web拉取最新的配置文件信息,并监听相应的zk节点,当有数据变化时,zk会通知client,然后client重新从web拉取最新数据。 4. web的另外一个功能是配置项检查,每30分钟将DB config表中配置值与zk相比,发现不一致会邮件通知。
app: app表,配置以app为核心。 config: 配置表。 config_history: 配置记录表,每更新每新增。 env: 环境表。 role: 角色表,用户有哪些角色。 role_resource: 角色权限对应的功能表。 user: 用户表,登录使用。
优势
1.数据持久化在mysql 2.推拉模型基于ZK,实时 3.容灾:多级 4.配置数据模型:文件和KV
接口鉴权
服务限流设计
限流作用
由于API接口无法控制调用方的行为,因此当遇到瞬时请求量激增时,会导致接口占用过多服务器资源,使得其他请求响应速度降低或是超时,更有甚者可能导致服务器宕机。 限流(Rate limiting)指对应用服务的请求进行限制,例如某一接口的请求限制为100个每秒,对超过限制的请求则进行快速失败或丢弃。
场景 热点业务带来的突发请求; 调用方bug导致的突发请求; 恶意攻击请求。
为什么要分布式限流
单点
当应用为单点应用时,只要应用进行了限流,那么应用所依赖的各种服务也都得到了保护。
分布式
分布式限流,可以方便地控制整个服务集群的请求限制,且由于整个集群的请求数量得到了限制,因此服务依赖的各种资源也得到了限流的保护。
子主题
限流算法
计数器算法
会限制一秒钟的能够通过的请求数,比如限流qps为100 AtomicLong#incrementAndGet()方法来给计数器加1并返回最新值
缺点: 如果我在单位时间1s内的前10ms,已经通过了100个请求,那后面的990ms,只能眼巴巴的把请求拒绝,我们把这种现象称为“突刺现象”
漏桶算法
描述
算法内部有一个容器,类似生活用到的漏斗,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变。
通过漏桶算法进行限流,每10毫秒处理一次请求。因为处理的速度是固定的,请求进来的速度是未知的,可能突然进来很多请求,没来得及处理的请求就先放在桶里,既然是个桶,肯定是有容量上限,如果桶满了,那么新进来的请求就丢弃。
算法实现
在算法实现方面,可以准备一个队列,用来保存请求,另外通过一个线程池定期从队列中获取请求并执行,可以一次性获取多个并发执行。
令牌桶算法
描述
令牌桶算法是对漏桶算法的一种改进,桶算法能够限制请求调用的速率,而令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用。
在令牌桶算法中,存在一个桶,用来存放固定数量的令牌。算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。
算法实现
可以准备一个队列,用来保存令牌,另外通过一个线程池定期生成令牌放到队列中,每来一个请求,就从队列中获取一个令牌,并继续执行。
使用阿里Sentinel哨兵
原理
sentinel主要是基于7种不同的Slot形成了一个链表,每个Slot都各司其职,自己做完分内的事之后,会把请求传递给下一个Slot,直到在某一个Slot中命中规则后抛出BlockException而终止。 前三个Slot负责做统计,后面的Slot负责根据统计的结果结合配置的规则进行具体的控制,是Block该请求还是放行。
1、dashboard控制台,可以可视化的对每个连接过来的sentinel客户端 (通过发送heartbeat消息)进行控制,dashboard和客户端之间通过http协议进行通讯。 2、规则的持久化,通过实现DataSource接口,可以通过不同的方式对配置的规则进行持久化,默认规则是在内存中的 3、对主流的框架进行适配,包括servlet,dubbo,rRpc等
Dashboard控制台
sentinel-dashboard是一个单独的应用,通过spring-boot进行启动,主要提供一个轻量级的控制台,它提供机器发现、单机资源实时监控、集群资源汇总,以及规则管理的功能。
redis缓存设计、性能、应用
集群方案
服务
注意:别太多介绍技术细节
JVM
配置
调优
监控
子主题
广告项目
项目介绍
广告项目,担任架构师与高级工程师主要参于完成了架构设计 1.从分析需求看,了解用户角色,像会员体系、第三方认证体系、运营体系等, 2.使用场景,如门户、中台、用户中心管理 3.选择成熟架构设计方案,结合公司情况,选用前后端分离 4.自顶向下的细化工作,如分层,分模块、API设计、数据库设计等 5.验证和优化架构设计方案,如满足半年时间的业务增长 用到了开源springboot+dubbo+rabbitMQ+mybatis+redis+oss
业务架构
技术架构
拓扑图
应用业务解决方案
1.数据埋点
https://地址?e=加密埋点数据&x=MD5加密校验字符串&u=https://shop.wtoip.com/220059 e: 加密埋点数据, 见{@link AdStatsEncryptionVO} x: MD5加密校验字符串 u: 原跳转地址 如: https://shop.wtoip.com/220059
自埋点: e:埋点广告数据(位ID,类目ID,广告ID)用RSA公钥加密 x:对e、自定义KEY、u:跳转地址URL 用MD5签名
安全性
1.将埋点数据、原跳转地址拼接成字符串 加上自定义KEY,对形成的数据进行MD5加密,生成签名 2.加密成一段唯一的固定长度的代码,保证了数据安全性
2.投放广告
1. 中台广告系统广告配置 2. 同步到缓存(定时任务启动/失效广告) 3. 根据投放规则
3.点击曝光
4.收集数据
5.实时计算
6.数据落地
应用技术解决方案
百度配置中心
描述:分布式环境下多台服务实例的配置统一管理问题
设计理念
简单易用、体验良好 支持配置、配置发布、更新统一化 配置更新自动化(用户在平台更新配置,使用该配置的系统会自动发现该情况,并应用新配置)
原理
Disconf通过disconf-web管理配置信息,然后将配置的key在Zookeeper上建立节点,disconf-client启动后拉取自身需要的配置信息并监听Zookeeper的节点。在web上更新配置信息会触发zk节点状态的变动,client可以实时感知到变化,然后从web上拉取最新配置信息。
架构
讲解知识
基于注解
在需要进行配置的类通过DisconfFile注解指名配置从哪个文件获取,在get方法通过DisconfFileItem注解的name属性指定文件中的key,associateField指类的属性,即当前值设置到当前类的哪个属性中。 @DisconfFile(filename = "redis.properties") @DisconfFileItem(name = "redis.host", associateField = "host")
client端
系统启动时Bean初始化前 1.扫描配置文件类和配置项 2.下载配置数据 3.watch监控配置数据变化 4.注册到仓库中 系统启动时Bean初始化后 1.配置仓库容器模块:设置注册回调函数 2.配置仓库容器模块:注入Bean类 系统正常运行时请求配置数据 1.配置仓库容器模块:AOP拦截请求,返回仓库配置数据 系统正常运行时配置更新 1.配置仓库容器模块:接收远程推送配置数据,并注入到仓库和配置类里 2.配置仓库容器模块:调用回调函数进行控制
web端
架构
1. web主要用于管理配置项,当新建配置项后,会保存到DB的config及config-history表,并邮件通知app表对应的邮件地址。 2. 对更新的配置项,会更新config表并新增记录到config-history表,邮件通知,同时添加zk节点(/disconf/app_version_env/file(或item)/配置项),当client端更新配置后,会在此节点下写入client标识,所以就有了web端的“实例列表”。 3. client启动后会从web拉取最新的配置文件信息,并监听相应的zk节点,当有数据变化时,zk会通知client,然后client重新从web拉取最新数据。 4. web的另外一个功能是配置项检查,每30分钟将DB config表中配置值与zk相比,发现不一致会邮件通知。
app: app表,配置以app为核心。 config: 配置表。 config_history: 配置记录表,每更新每新增。 env: 环境表。 role: 角色表,用户有哪些角色。 role_resource: 角色权限对应的功能表。 user: 用户表,登录使用。
优势
1.数据持久化在mysql 2.推拉模型基于ZK,实时 3.容灾:多级 4.配置数据模型:文件和KV
接口鉴权
服务限流设计
限流作用
由于API接口无法控制调用方的行为,因此当遇到瞬时请求量激增时,会导致接口占用过多服务器资源,使得其他请求响应速度降低或是超时,更有甚者可能导致服务器宕机。 限流(Rate limiting)指对应用服务的请求进行限制,例如某一接口的请求限制为100个每秒,对超过限制的请求则进行快速失败或丢弃。
场景 热点业务带来的突发请求; 调用方bug导致的突发请求; 恶意攻击请求。
为什么要分布式限流
单点
当应用为单点应用时,只要应用进行了限流,那么应用所依赖的各种服务也都得到了保护。
子主题
分布式
分布式限流,可以方便地控制整个服务集群的请求限制,且由于整个集群的请求数量得到了限制,因此服务依赖的各种资源也得到了限流的保护。
子主题
限流算法
计数器算法
会限制一秒钟的能够通过的请求数,比如限流qps为100 AtomicLong#incrementAndGet()方法来给计数器加1并返回最新值
缺点: 如果我在单位时间1s内的前10ms,已经通过了100个请求,那后面的990ms,只能眼巴巴的把请求拒绝,我们把这种现象称为“突刺现象”
漏桶算法
描述
算法内部有一个容器,类似生活用到的漏斗,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变。
通过漏桶算法进行限流,每10毫秒处理一次请求。因为处理的速度是固定的,请求进来的速度是未知的,可能突然进来很多请求,没来得及处理的请求就先放在桶里,既然是个桶,肯定是有容量上限,如果桶满了,那么新进来的请求就丢弃。
算法实现
在算法实现方面,可以准备一个队列,用来保存请求,另外通过一个线程池定期从队列中获取请求并执行,可以一次性获取多个并发执行。
令牌桶算法
描述
令牌桶算法是对漏桶算法的一种改进,桶算法能够限制请求调用的速率,而令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用。
在令牌桶算法中,存在一个桶,用来存放固定数量的令牌。算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。
算法实现
可以准备一个队列,用来保存令牌,另外通过一个线程池定期生成令牌放到队列中,每来一个请求,就从队列中获取一个令牌,并继续执行。
使用阿里Sentinel哨兵
原理
sentinel主要是基于7种不同的Slot形成了一个链表,每个Slot都各司其职,自己做完分内的事之后,会把请求传递给下一个Slot,直到在某一个Slot中命中规则后抛出BlockException而终止。 前三个Slot负责做统计,后面的Slot负责根据统计的结果结合配置的规则进行具体的控制,是Block该请求还是放行。
1、dashboard控制台,可以可视化的对每个连接过来的sentinel客户端 (通过发送heartbeat消息)进行控制,dashboard和客户端之间通过http协议进行通讯。 2、规则的持久化,通过实现DataSource接口,可以通过不同的方式对配置的规则进行持久化,默认规则是在内存中的 3、对主流的框架进行适配,包括servlet,dubbo,rRpc等
Dashboard控制台
sentinel-dashboard是一个单独的应用,通过spring-boot进行启动,主要提供一个轻量级的控制台,它提供机器发现、单机资源实时监控、集群资源汇总,以及规则管理的功能。
redis缓存设计、性能、应用
并发
服务
注意:别太多介绍技术细节
JVM
配置
调优
监控
秒杀
过滤掉大部分无效的流量
1.活动开始前前端页面的Button置灰,防止活动未开始无效的点击产生流量。
2.前端添加验证码或者答题,防止瞬间产生超高的流量,可以很好的起到错峰的效果,现在验证码花样繁多,题库有的还要做个小学题,而且题库更新频繁
3.活动校验,既然是活动,那么活动的参与用户参加条件,用户白名单之类的要首先做一层校验拦截,还有其他的比如用户终端、IP地址、参与活动次数、黑名单用户的校验,比如活动主要针对APP端的用户校验,那么根据参数其他端的用户将被拦截,针对IP、mac地址、设备ID和用户ID可以对用户参与活动的次数做校验,黑名单根据平时的活动经验拦截掉一部分羊毛党等异常用户。
4.非法请求拦截
5.限流,假设秒杀10000件商品,我们有10台服务器,单机QPS在1000,那么理论上1秒就可以抢完,针对微服务就可以做限流配置,避免后续无效的流量打到数据库造成不必要的压力。
总结: 做完无效流量的过滤,可能过滤掉了90%,剩下的有效流量会大大的降低系统的压力
针对系统的优化
1. 页面静态化,参与秒杀活动的商品一般都是已知的,可以针对活动页面做静态化处理,缓存到CDN。假设我们一个页面300K大小,1000万用户流量是多少?这些请求要请求后端服务器、数据库、压力可想而知,缓存到CDN用户请求不经过服务器,大大减少了服务器的压力
2.活动预热 针对活动的活动库存可以独立出来,不和普通的商品库存共享服务。活动库存活动开始前提前加载到redis,查询全部走缓存,最后扣减库再视情况而定。
3.独立部署 资源充足的情况下可以考虑针对秒杀活动单独部署一套环境,这套环境中可以剥离一些可能无用的逻辑。比如不用考虑使用优惠券、红包、下单后赠送积分的一些场景。 实际上单独针对钞杀活动的话你肯定有很多无用的业务代码是可以剥离的,这样可以提高不少性能。
最终流量呈漏斗状
下单扣库存
1.首先查询redis缓存库存是否充足。 2.先扣库存再落订单数据,可以防止订单生成了没有库存的超卖问题。 3.扣库存的时候先扣数据库库存,再扣减redis库存,保证在同一个事务里,无论两者哪一个发生了异常都会回滚。 在服务层排队,针对同一个商品ID的也就是数据库是一条库存记录的做一个内存队列,串行化去扣减库存,可以一定程度上缓解数据库的并发压力。
回调接口:如果你的系统架构允许,可以在库存扣减成功后调用一个回调接口,通知其他系统或服务该订单的处理结果。
质量监控
1.熔断限流降级,老生常谈,根据压测情况进行限流,可以使用sentinel或者hystrix,另外前端后端都有该降级开关。 2.监控,该上的都上,QPS监控、容器监控、CPU、缓存、IO、监控等等。 3.演练,大型秒杀事前演练少不了。 4.核对、预案、事后库存订单金额、数量核对,是否发生超卖了,金额是否正常。 预案可以在紧急情况下进行降级。 数据统计,活动做完了,数据该怎么统计? 1.前端埋点,2.数据大盘 通过后台服务的打点配合监控系统可以通过大盘直观的看到一些活动的监控和数据。 3.离线数据分析 事后活动的数据可以同步到离线数仓做进一步的分析统计。
总结
面对巨量的流量我们的方式就是首先通过各种条件先筛选掉无效流量,进行流量错峰,然后再对现有的系统性能做出优化,比如页面静态化,库存商品预热,也可以通过独立部署的方式和其他的环境做隔离,最后还要解决商并发下缓存一致性、库存不能超卖的问题,防止大量的并发打爆你的数据库。一个完整的活动从前端到后端是一个完整的链路,中间有事前的演练工作,事后的数据分析等都是必不可少的环节。
交易系统方案
痛点
一体化架构
初期
开发简单直接,代码和项目集中式管理。 只需要维护一个工程,节省维护系统运行的人力成本。 排查问题的时候,只需要排查这个应用进程就可以了,目标性强
中期遇到瓶颈
数据库
数据库连接数可能成为系统瓶颈。最大1万多 运营活动,服务器要扩容,数据库连接数变大,随时都会影响服务的稳定
解决
按业务拆库
采用不同的业务,不同的库,如购物车,订单等
新旧数据拆分
描述
存档历史订单数据,提升查询性能
很多数据具备时间属性,并随着系统的运行,累计增长越来越多,数据量达到一定程度就会越来越慢,这就要拆分数据。(像订单这类数据,都是有时间属性,存在热尾效应) 如超过2000万,将三个月后的订单迁到旧库中(如ES,MYSQL)
归档流程
1.首先创建一个和订单表结构一模一样的历史订单表 2.然后把订单表中的历史订单数据分批查出来,存入历史订单表中去。用什么技术都可以 3. 先不要删订单数据,上测试和上线支持历史订单代码,验证新代码查询历史数据正确性,做到有问题改BUG或回滚 4.等新版本代码上线并验证无误后,就可以删除订单表中历史订单数据了 5.最后,上线一个迁移数据的程序,定期把过期的订单从订单表搬到历史订单表中。 注: 1.订单商品表这类订单相关子表,也是需要按照同样的方式归档到各自的历史表中。 2.分批删除SQL
-- 新建一个临时订单表 create table orders_temp like orders; -- 把当前订单复制到临时订单表中 insert into orders_temp select * from orders where timestamp >= SUBDATE(CURDATE(),INTERVAL 3 month); -- 修改替换表名 rename table orders to orders_to_be_droppd, orders_temp to orders; -- 删除旧表 drop table orders_to_be_dropp
分库分表
什么时候分库分表
服务
业务复杂、维护一套代码,带来研发效率的降低和研发成本的提升
解决
微服务,购物车,订单
存储
应用
准确度
库存问题
代码
可以保证库存安全,满足高并发处理,但是相对复杂一点。 /** * 商品的数量 等其他信息 先保存 到 Redis * 检查库存 与 减少库存 不是原子性, 以 increment > 0 为准 * * @param req */ @Override public void placeOrder3(PlaceOrderReq req) { String key = "product:" + req.getProductId(); // 先检查 库存是否充足 Integer num = (Integer) redisService.get(key); if (num < req.getNum()) { logger.info("库存不足 1"); }else{ //不可在这里下单减库存,否则导致数据不安全, 情况类似 方法1; } //减少库存 Long value = redisService.increment(key, -req.getNum().longValue()); //库存充足 if (value >= 0) { logger.info("成功抢购 ! "); //TODO 真正减 扣 库存 等操作 下单等操作 ,这些操作可用通过 MQ 或 其他方式 place2(req); } else { //库存不足,需要增加刚刚减去的库存 redisService.increment(key, req.getNum().longValue()); logger.info("库存不足 2 "); } }
分析
利用Redis increment 的原子操作,保证库存安全。 事先需要把库存的数量等其他信息保存到Redis,并保证更新库存的时候,更新Redis。 进来的时候 先 get 库存数量是否充足,再执行 increment。以 increment > 0 为准。 检查库存 与 减少库存 不是原子性的。 检查库存的时候技术库存充足也不可下单;否则造成库存不安全,原来类似 方法1. increment 是个原子操作,已这个为准。 redisService.increment(key, -req.getNum().longValue()) >= 0 说明库存充足,可以下单。 redisService.increment(key, -req.getNum().longValue()) < 0 的时候 不能下单,次数库存不足。并且需要 回加刚刚减去的库存数量,否则会导致刚才减扣的数量 一直买不出去。数据库与缓存的库存不一致。 次方法可以满足 高并抢购等一些方案,真正减扣库存和下单可以异步执行。 订单时效问题,订单取消等 为保证商家利益,同时把商品卖给有需要的人,订单下单成功后,往往会有个有效时间。超过这个时间,订单取消,库存回滚。 订单取消后,可利用MQ 回退库存等。
数字底座
项目概述
mogo是一家什么公司
mogo是一家车路云一体化公司,由自动驾驶车辆、路侧基础设施、AI云全面数据相互联系、协同工作和协同发展,其中云负责全面的数据、智能预测、调度和管理能力,实现聪明的车+智慧的路+AI云控。 AI云特点是高并发、低时延来处理路上的所有车辆、道路数据、事件数据。
mogo数字底座是什么
1. 收集:实时、高效的对接车、路、三方海量数据,进行数据处理、分析、预测、整合、存储。 2. 处理: 3. 赋能车:自动驾驶路径规划、避障、鹰眼孪生。 4. 赋能平台:交通业务拥堵、流量、事件、信号灯控制,云控平台、大屏监控 5. 赋能路侧:事件、车流量、信号灯状态 6. 赋能第三方车企:事件、车流量、信号灯等数据
当前的业务量
1. 自动驾驶车辆数: 200辆左右 2. 38km路侧上报数据量(253个杆): 38万帧/分钟 日吞吐4亿数据帧/天 200TB数据/天
架构设计
需求分析 --有哪些功能,要达到什么样的指标
核心功能
1. 数据收集与处理
收集
车(定位、adas、轨迹)、 路(识别定位、视频流、事件)、 三方(天气、信号灯)
处理与分析
提取有价值的信息
融合(多杆、车牌)、 预测(轨迹)、 事件(三角牌、施工)、 车流量
拥堵、违章
存储
设施:杆、相机、位置、超向等 定位:车、路 事件:三角牌、施工、遗撒 违章:超速、逆行、违停 轨迹:预测轨迹 图片:事件、违章 三方:天气
2. 信息共享与交互
1. 车辆、路侧、管理云平台之间信息共享与交互
API接口和数据服务
接口规范
车到云 路到云
协议格式 字段定义
3. 管理与运营
运行状态监测
设施管理 车辆管理 平行驾驶 实时孪生 拥堵 信号灯 单车追踪 事件下行到车
信号灯控制
4. 安全与应急响应
设施:监测设施在线 事件:平台预警 拥堵:平台预警 孪生:杆上报、融合
性能指标
1. 数据处理能力
实时处理
上行数据100ms接收 下行100ms
分析
拥堵
违章
事件识别
预测
存储
定位数据:1.5T/天 图片:几千张
2. 可扩展性
横向和纵向扩展
满足未来业务增长和数据量增加的需求
提供灵活的架构设计和模块化开发
3. 可靠性
高并发、大流量稳定运行
容错保证安全性和可用性
4. 安全性
服务器安全性
选云
应用稳定性
选spring
数据正确性
接口鉴权
监控
硬件、服务、数据
分几层
数据采集
路侧:MQTT收集数据 + 转换服务 + kafka(多分区) 车端:Netty长连接收集数据 + kafka(多分区)
长连协议
out header:由魔数、payload偏移量、包大小组成;用于识别长连接数据和数据报解码使用。 header:用户的基本数据,用户环境数据。根据头数据进行负载数据的转发 payload:真实上报的数据。该数据上下行数据,直接转发到其他服务
MQTT协议
原理
子主题
kafka协议
// 接收工控机主实体 message SocketReceiveDataProto { uint64 seq = 1; /** 消息来自:0.自车与ADAS数据 */ uint32 msgType = 2; /** 工控机对应的车机sn */ string sn = 3; /** 工控机sn */ string IPCSn = 4; /** 自车、adas的实体组合数据 */ OnePerSecondSendReqProto data = 5; /** AI云上行数据处理 / 小程序上行数据处理 */ SocketRequestVo data2 = 6; uint64 utcTime = 7; uint64 upUtcTime = 8; string cityCode = 9; string macAddr = 10; }
数据均衡处理
1. 加入监控,统计总的数据量 2. 排序每个路口 3. 根据kafka分区数,分配路口 4. 达到每个分区数据相差不大
数据传输
车端:5G 路侧:专网
数据处理
定义了数据架构
数据流处理框架flink
数字孪生
清洗、融合、预测
拥堵
流量、排队
违章
超速、逆行、违停
8大预警
AI算法引擎
轨迹预测 车牌识别 事件识别
数据存储
es、mysql、mongoDB
应用服务
设计基于微服务架构
每个微服务负责一个独立的业务功能,通过网关进行通信
业务服务
移动端门面服务
事件
拥堵
违章
信号灯
设施
WEB后端门面
大屏
平台
接口
兼容
//老接口 void oldService(A,B){ //兼容新接口,传个null代替C newService(A,B,null); } //新接口,暂时不能删掉老接口,需要做兼容。 void newService(A,B,C){ ... }
可扩展性
复用共公接口
防重处理
服务端考虑幂等(redis) 数据库碓一性(mysql)
重点接口,考虑线程池隔离
第三方接口考虑异常和超时处理
是否需要熔断 重试次数
熔断和降级
最简单是加开关控制,当下游系统出问题时,开关降级,不再调用下游系统。还可以选用开源组件Hystrix。
接口单一性
比如一个登陆接口,它做的事情就只是校验账户名密码,然后返回登陆成功以及userId即可
有些场景,使用异步更合理
线程池 消息队列
远程串行改并行调用
CompletableFuture
恰当使用缓存
保证数据库和缓存一致性:缓存延时双删、删除缓存重试机制、读取biglog异步删除缓存 缓存击穿:设置数据永不过期 缓存雪奔:Redis集群高可用、均匀设置过期时间 缓存穿透:接口层校验、查询为空设置个默认空值标记、布隆过滤器。
热点数据隔离性
业务隔离性,比如12306的分时段售票,将热点数据分散处理,降低系统负载压力。 系统隔离:比如把系统分成了用户、商品、社区三个板块。这三个块分别使用不同的域名、服务器和数据库,做到从接入层到应用层再到数据层三层完全隔离。 用户隔离:重点用户请求到配置更好的机器。 数据隔离:使用单独的缓存集群或者数据库服务热点数据。
接口幂等性
select+insert+主键/唯一索引冲突 直接insert + 主键/唯一索引冲突 状态机幂等 抽取防重表 token令牌 悲观锁 乐观锁 分布式锁
SQL优化
explain 分析SQL查询计划(重点关注type、extra、filtered字段) show profile分析,了解SQL执行的线程的状态以及消耗的时间 索引优化 (覆盖索引、最左前缀原则、隐式转换、order by以及group by的优化、join优化) 大分页问题优化(延迟关联、记录上一页最大ID) 数据量太大(分库分表、同步到es,用es查询)
考虑限流
可以使用Guava的RateLimiter单机版限流,也可以使用Redis分布式限流,还可以使用阿里开源组件sentinel限流
接口安全性
token
保证接口的安全性有token机制
接口签名
把接口请求相关信息(请求报文,包括请求时间戳、版本号、appid等),客户端私钥加签,然后服务端用公钥验签,验证通过才认为是合法的、没有被篡改过的请求。
分布式事务
2PC(二阶段提交)方案、3PC TCC(Try、Confirm、Cancel) 本地消息表 最大努力通知 seata
版本号
比如客户端APP某个功能优化了,新老版本会共存,这时候我们的version版本号就派上用场了,对version做升级,做好版本控制。
保证接口正确性
要做单元测试
注册中心、网关、熔断、负载均衡等
技术选型
大数据技术
flink
实时流式处理,毫秒级
时间窗口(事件时间、处理时间)
100ms
状态state
broadcase
共享高精道路信息
与storm、spark
1. flink提供更低的延迟和内置状态管理 2. 容错方面flink更好 3. spart和storm在状态管理方面较弱,需要外部存储系统支持 4. apart适合批处理,storm适合实时流处理
参数配置
1. 38km划分25个7度H3索引 2. 10台工作节点消费10个kafka分区 3. 每台机器消费3.8万帧/分钟 4. source 并行度10,处理并行度25,sink并行度10
原理
JobClient JobManager TaskManager
子主题
反压
接收数据的速率大于其处理速率的一种场景
原因
GC问题 并行度少 处理速度慢
flink反压
每个子任务都有自己的本地缓存池,当收到上游发来的数据时,放到缓存池中,两个TM之间,由netty通讯。当子任务本地缓存耗尽时,就会产生背压,会停止从上游读取数据。
解决反压
每个子任务的本地缓存分2部分:独占缓存、浮动缓存 1. 独占缓存大小作为信用点,用netty通知数据发送方,发送方根据不同的子任务的信用点,发送尽可能多的数据给接收方,这时信用点也会减小。 2. 当发送方收到的信用点为0时,则不再发送,起到背压的作用。 3. 发送方这时将数据放入到本地暂存队列中,再通知接收方,接收方根据本地缓存的大小,动态调整浮动缓存,加速接收上游发的数据。 注:任务级的背压,只会影响这个任务,并不会对TM上的其他任务造成影响
优化点
并行度、创建对象要减少、多用状态缓存、压缩数据
es
考虑因素
可扩展,分片
共计1.5T 30分片,每个分片50G 存储一天,用于回放、单车追踪等
通过集群扩展
地理定位查询
支持多边形 性能高(比mongoDB)
快速检索
版本
7.x
比较
数据模型
1. 模型:ES支持JSON文档,MongoDB支持BSON文档,支持更多数据类型,如二进制和日期。 2. 索引:ES支持深度索引,对JSON文档每个字段进行索引,支持深度查询。MongoDB对文档的每个字段创建索引。 3. 性能:ES优化了查询性能,在全文搜索和聚合查询方面。MongoDB在读写操作进行了优化。
索引机制
1. 索引类型:ES使用全文索引、精确索引,MongoDB使用B树索引,适合范围查询。 2. 索引结构:ES是倒排索引,适合快速检索文本数据,MongoDB是B树适合结构化数据快速检索 3. 索引使用场景:ES用于搜索、日志分析,MongoDB适合高吞吐量读写操作场景
查询性能
1. 查询速度:ES在全文搜索、聚合、复杂查询中表现出色。MongoDB在点查询和范围查询上速度快。 2. 使用场景:ES用与搜索、日志分析。MongoDB用于通用数据库
分布式架构
1. 分布式支持:两者都支持分布式环境设计,可以跨多节点运行 2. 数据分片:两者都支持数据水平分割,即分片,以支持大规模数据集。 3. 数据一致性:ES是最终一致性,MongoDB是在某些配置下可以提供强一致性 4. 扩展性:两者都易于水平扩展,可添加更多节点来增加处理能力和存储容量 5. 使用场景:ES用于搜索、日志分析。MongoDB用于高吞吐量读写操作。
mongoDB
副本集 在副本集中,每个节点都可以担任主节点或从节点的角色,通过异步复制数据到多个服务器上,保证了数据的高可用性和冗余性。当主节点出现故障时,副本集可以自动进行故障切换,选择一个从节点成为新的主节点,从而保证了服务的连续性。此外,副本集还提供了数据冗余,增强了数据的容错能力。
两种类型: 主节点(Primary)类型:数据操作的主要连接点,可读写。 次要(辅助、从)节点(Secondaries)类型:数据冗余备份节点,可以读或选举。
三种角色: 1. 主要成员(Primary):主要接收所有写操作。就是主节点。 2. 副本成员(Replicate):从主节点通过复制操作以维护相同的数据集,即备份数据,不可写操作,但可以读操作(但需要配置)。是默认的一种从节点类型。 3. 仲裁者(Arbiter):不保留任何数据的副本,只具有投票选举作用。当然也可以将仲裁服务器维护为副本集的一部分,即副本成员同时也可以是仲裁者。也是一种从节点类型。
分片 通过将数据分散存储到多个服务器上,分片可以显著提高系统的整体性能和可扩展性。每个分片都是一个独立的数据库,可以独立地进行数据复制和故障恢复。在实际生产环境中,通常将副本集和分片两种技术结合使用,以实现既高性能又高可用性的数据存储解决方案。 分片策略 范围分片。文档根据分片键值分区到分片上。分片键值彼此接近的文档可能位于同一个分片上。这种方法非常适用于需要优化基于范围的查询的应用程序,例如将特定区域所有客户的数据放置在特定分片上。 散列分片。文档根据分片键值的MD5散列进行分布。这种方法保证了写入在分片上的均匀分布,通常对于摄取时间序列和事件数据流是最优选择。 区域分片。提供了开发人员定义在分片群集中数据放置的特定规则的能力。
MongoDB分片集群中共有三种角色 Shard角色(或称为分片服务器): 这是MongoDB分片集群中的数据节点,用于存储实际的数据块。在实际生产环境中,一个Shard角色可以由几台机器组成一个副本集(Replica Set)来承担,以防止主机单点故障,保证数据的高可用性和完整性。Shard角色可以是一个副本集,也可以是单独的一台服务器。 Config Server角色(或称为配置服务器): 这类角色主要用来保存MongoDB分片集群的元数据信息,包括各个分片包含了哪些数据的信息,以及数据块的分布信息等。Config Server角色通常由一个独立的mongod进程来运行,并且为了保证其高可用性,通常会将其运行为一个副本集。它不需要太多的存储空间,因为保存的只是数据的分布表。 Router角色(或称为路由服务器、mongos): 这是MongoDB分片集群中的前端路由,客户端由此接入,让整个集群看上去像单一数据库。Router角色主要用来接收客户端的读写请求,并将请求路由到相应的分片上进行处理。为了使得Router角色的高可用,通常会用多个节点来组成Router高可用集群。Router角色通常由mongos实例来运行。 以上三种角色共同协作,实现了MongoDB的分片集群功能,使得MongoDB能够支持大规模的数据存储和高并发的读写操作。
分片集群中,数据读写时的流程大致 1. 客户端发送请求:客户端通过MongoDB的驱动程序连接到Router角色(mongos实例)。客户端发送读写请求到Router,请求中包含了要操作的数据库、集合以及具体的CRUD(增删改查)操作。 2. Router路由请求:Router接收到客户端的请求后,会根据请求中的元数据信息(如数据库名、集合名和查询条件等),查询Config Server来获取数据的分片信息。Config Server返回相关的分片信息给Router,告诉它应该将数据路由到哪个Shard上进行处理。 3. Router转发请求:Router根据从Config Server获取的分片信息,将客户端的请求转发到相应的Shard上。如果请求涉及多个Shard上的数据(如跨分片的查询),Router可能会将请求拆分成多个子请求,并分别发送到相关的Shard上进行处理。 4. Shard处理请求:Shard接收到Router转发的请求后,会在本地执行相应的CRUD操作。如果是写操作(如插入、更新、删除),Shard会在本地进行数据变更,并将变更结果返回给Router;如果是读操作(如查询),Shard会查询本地存储的数据,并将查询结果返回给Router。 5. Router汇总结果:如果请求涉及多个Shard上的数据,Router会等待所有Shard返回结果后,对结果进行汇总和排序等操作(如果需要的话),然后将最终的结果返回给客户端。 6. 客户端接收结果:客户端通过MongoDB的驱动程序接收到Router返回的结果,完成一次数据读写操作。 需要注意的是,MongoDB分片集群中的Router、Config Server和Shard之间的通信是通过MongoDB的内部协议进行的,而客户端与Router之间的通信则是通过MongoDB的驱动程序和标准的MongoDB协议进行的。此外,为了保证数据的一致性和可用性,MongoDB分片集群还提供了复制集(Replica Set)和自动故障切换等机制。 总结来说,主从复制模式由于其存在的问题已经被MongoDB官方淘汰;副本集模式适合对数据可用性有较高要求的生产环境;而分片模式则适合处理大规模数据,提高系统的整体性能和可扩展性。在实际应用中,需要根据具体的需求和场景来选择合适的集群架构模式。
ES
多主架构
数据一致性
1. 一致性模型:ES是最终一致性,意味着写入操作最终会反映在所有副本中,MongoDB是在副本集配置中可以提供强一致性保证 2. 写入操作:ES是异步复制到副本集。MongoDB允许配置写入操作是同步还是异步复制到副本 3. 系统架构:ES是多主架构,MongoDB是主从、副本集
文档大小限制
1. 最大文档大小:ES可以2.2G单个文档,MongoDB可以16MB 2. 数据压缩:ES支持自动压缩,MongoDB可选压缩 3. 使用场景:ES适合大型文档存储,MongoDB适合中等大小文档。
存储引擎
1. 存储引擎:ES是Lucene,是一个全文搜索库,MongoDB是WiredTiger,是一个多文档存储引擎
地理空间
1. 两者都支持地理空间索引 2. 查询:ES支持距离查询、多边型查询,MongoDB支持2D、3D空间数据支持。
多租户支持
1. ES设计为单租户,但也可以通过策略实现多租户,如索引隔离。MongoDB支持多租户,允许在单个实例内运行多个数据库 2. 场景:ES搜索、日志分析,MongoDB支持SaaS应用、多租户应用
使用场景
1. ES用于搜索、日志分析,MongoDB用于文档存储、分析 2. 应用示例:ES应用搜索引擎、日志数据分析,MongoDB用于内容管理系统、用户档案 3. 数据结构:ES支持JSON文档,适合存储结构化和半结构化数据,MongoDB的BSON文档支持更多的数据类型 4. 性能要求:ES是高效搜索和分析,MongoDB是高效读写 5. 数据量:ES适合大规模数据集,MongoDB适合中等规模数据集 6. 地理空间需求:ES是地理空间搜索和分析,MongoDB地理空间数据存储
数据量
mongodb
32T
es
每个分片50G
消息队列
mqtt
发布者(Publish)、 代理服务器(MQTT-Broker)、 订阅者(Subscribe)。 其中,消息的发布者和订阅者都是客户端,消息代理是服务器,消息发布者可以同时是订阅者
考虑因素(物联网)
低时延、低功耗、低网络开销
具有低延迟、低功耗、低网络开销的特点,适合在低带宽或不稳定网络环境下进行消息传递。
消息格式简洁,高效传输
结构化数据:消息格式设计简洁,在低带宽和资源受限的环境传输 在处理结构化数据时,MQTT可能更加适合。 自己定义的基于二进制的消息格式,包括包头、变长编码和载荷等字段。MQTT的消息格式设计简洁,使得它能够在低带宽和资源受限的环境下高效地传输消息 MQTT消息格式包含 Fixed Header, 固定头 Variable Header 可变头 Payload。消息体
服务质量
QoS = 0:最多发一次 QoS = 1:最少发一次 QoS = 2:保证收一次
生产两台机器
通过ip的hash进行分发数据
kafka
考虑因素
Kafka适合处理大量数据的实时处理任务,Kafka能够保证数据的可靠传输以及快速的消息处理速度,支持多个生产者和消费者节点。
可扩展性
分区机制使得它可以水平扩展
性能 --1个分区
生产者
最大处理量:39.2501MB/S TPS:41156.6817条
消费者
最大处理量:163.6659MB/S TPS:171616.1767条
netty
子主题
tcp
三次握手
双方确认自己与对方的发送与接收是正常的。
第一次握手:建立连接时,客户端发送syn包(syn=j)到服务器,并进入SYN_SENT状态,等待服务器确认;SYN:同步序列编号(Synchronize Sequence Numbers)。 第二次握手:服务器收到syn包,必须确认客户的SYN(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态; 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED(TCP连接成功)状态,完成三次握手。
配置
this.bossGroup = new EpollEventLoopGroup(2); this.workerGroup = new EpollEventLoopGroup(16); .childHandler(this.connsvrChannelInitializer) MessageToMessageDecoder
// 2个线程支持EventLoop进行事件轮询,每个 EventLoop 会占⽤⼀个 Thread, // 同时这个 Thread 会处理 EventLoop上发生的事件,如监听连接等。
心跳
30秒一次,3次为一周期
ai
机器学习
轨迹预测
深度学习
三角牌、施工牌、遗撒
性能优化
JVM参数
堆大小:由2G调整到4G
监控
对系统进行实时监控和性能分析
优化算法
子主题
异步编程
缓存
消息队列
项目挑战
多源数据整合
定义数据模型和接口规范
确保数据的准确性和一致性
数字孪生多杆融合、轨迹预测
大数据的实时处理
flink
成果
实时监测交通流量
拥堵预警
路况事件:三角牌、施工牌
车辆追踪
信号灯通信
linux
基础概念
http://www.openpcba.com/web/contents/get?id=4638&tid=15
总结:操作系统就像一个软件外包公司,内核就是公司的老板,需要了解内核是怎么协调资源工作的。点击了一个qq程序,首先是输入设备驱动,他们是公司客户的对接员,客户输了一个指令,首先会中断,调用一个中断处理函数,弄明白客户的指令是什么,然后开始立项,立项就需要项目计划书,即项目的二进制程序的逻辑,设定好了执行的步骤,操作系统拿到二进制文件,就可以运行了,运行的qq称为进程。二进制程序是保存在硬盘上的,需要用到文件存储系统。项目立项需要用到各种审批和资源调度,有一些操作放在系统内核,不能随便调用,统一在办公大厅,即系统调用,系统调用会列出哪些接口可以调用,有需要的时候调用。项目实际运行过程中,为了管理,进程的进行也需要分配cpu执行,为了管理进程,进程管理子系统,如果进程管理很多,需要cpu调度执行,需要看cpu的调度能力。项目执行过程中需要做隔离,否则会互相影响,需要用到会议室,即内存,内存有限,需要内存管理子系统,执行完毕之后,统一交给交付员显示给客户,即输出设备输出设备驱动。qq启动之后,有一部分代码会告诉启动一个对话框,并将鼠标焦点移到对话框,cpu收到指令之后,告知显卡程序,将对话框调出来,呈现给用户,用户用键盘输入,输入设备会触发中断,调用输入设备驱动程序,用户输入了一个a,焦点在这个进程上,所以操作系统知道发给哪个进程,然后交给qq程序处理,qq程序记录下客户的输入,告诉显卡程序,花一个a,画完了,客户就能看到了。输入成功之后,按enter键,通过键盘驱动程序告诉系统,系统找到qq,将用户的输入发送到网络上。qq进程需要调用系统调用,内核使用网卡驱动发送。
文件管理子系统
管理程序
系统调用
操作系统中,打印文件,由系统调用打印机
内存管理子系统
不同的进程有不同的内存空间
进程管理子系统
网络管理子系统
设备子系统
内核任务
1.从技术层面讲,内核是硬件与软件之间的一个中间层。作用是将应用层序的请求传递给硬件,并充当底层驱动程序,对系统中的各种设备和组件进行寻址。 2.从应用程序的层面讲,应用程序与硬件没有联系,只与内核有联系,内核是应用程序知道的层次中的最底层。在实际工作中内核抽象了相关细节。 3.内核是一个资源管理程序。负责将可用的共享资源(CPU时间、磁盘空间、网络连接等)分配得到各个系统进程。 4.内核就像一个库,提供了一组面向系统的命令。系统调用对于应用程序来说,就像调用普通函数一样。
内核实现策略
1.微内核。最基本的功能由中央内核(微内核)实现。所有其他的功能都委托给一些独立进程,这些进程通过明确定义的通信接口与中心内核通信。 2.宏内核。内核的所有代码,包括子系统(如内存管理、文件管理、设备驱动程序)都打包到一个文件中。内核中的每一个函数都可以访问到内核中所有其他部分。目前支持模块的动态装卸(裁剪)。Linux内核就是基于这个策略实现的。
内核
内核同步机制
原子性、有序性、可见性
优化
linux文件句柄数优化
在linux服务器大并发调优时,往往需要预先调优linux参数,其中修改linux最大文件句柄数是最常修改的参数之一。 在linux中执行ulimit -a 即可查询linux相关的参数 ulimit -n 2048 这命令就可以修改linux最大文件句柄数,修改以后使用ulimit -a 查看修改的状态
修改linux系统参数。vi /etc/security/limits.conf 添加 * soft nofile 65536 * hard nofile 65536 修改以后保存,注销当前用户,重新登录,执行ulimit -a ,ok ,参数生效了
内核参数优化
生产环境服务器配置,此配置可以支撑全省用户核心业务受理(服务器硬件为16核32GB内存): cat /etc/sysctl.conf
# Disable netfilter on bridges. #net.bridge.bridge-nf-call-ip6tables = 0 #net.bridge.bridge-nf-call-iptables = 0 #net.bridge.bridge-nf-call-arptables = 0 # Controls the default maxmimum size of a mesage queue kernel.msgmnb = 65536 # Controls the maximum size of a message, in bytes kernel.msgmax = 65536 # Controls the maximum shared segment size, in bytes kernel.shmmax = 68719476736 # Controls the maximum number of shared memory segments, in pages kernel.shmall = 4294967296 net.ipv4.conf.lo.arp_ignore = 1 net.ipv4.conf.lo.arp_announce = 2 net.ipv4.conf.all.arp_ignore = 1 net.ipv4.conf.all.arp_announce = 2 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_tw_recycle = 1 net.ipv4.tcp_fin_timeout = 10 net.ipv4.tcp_max_syn_backlog = 20000 net.core.netdev_max_backlog = 32768 net.core.somaxconn = 32768 net.core.wmem_default = 8388608 net.core.rmem_default = 8388608 net.core.rmem_max = 16777216 net.core.wmem_max = 16777216 net.ipv4.tcp_timestamps = 0 net.ipv4.tcp_synack_retries = 2 net.ipv4.tcp_syn_retries = 2 net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_tw_recycle = 1 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_mem = 94500000 915000000 927000000 net.ipv4.tcp_max_orphans = 3276800 net.ipv4.tcp_fin_timeout = 10 net.ipv4.tcp_keepalive_time = 120 net.ipv4.ip_local_port_range = 1024 65535 net.ipv4.tcp_max_tw_buckets = 80000 net.ipv4.tcp_keepalive_time = 120 net.ipv4.tcp_keepalive_intvl = 15 net.ipv4.tcp_keepalive_probes = 5 net.ipv4.conf.lo.arp_ignore = 1 net.ipv4.conf.lo.arp_announce = 2 net.ipv4.conf.all.arp_ignore = 1 net.ipv4.conf.all.arp_announce = 2 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_tw_recycle = 1 net.ipv4.tcp_fin_timeout = 10 net.ipv4.tcp_max_syn_backlog = 20000 net.core.netdev_max_backlog = 32768 net.core.somaxconn = 32768 net.core.wmem_default = 8388608 net.core.rmem_default = 8388608 net.core.rmem_max = 16777216 net.core.wmem_max = 16777216 net.ipv4.tcp_timestamps = 0 net.ipv4.tcp_synack_retries = 2 net.ipv4.tcp_syn_retries = 2 net.ipv4.tcp_mem = 94500000 915000000 927000000 net.ipv4.tcp_max_orphans = 3276800 net.ipv4.ip_local_port_range = 1024 65535 net.ipv4.tcp_max_tw_buckets = 500000 net.ipv4.tcp_keepalive_time = 60 net.ipv4.tcp_keepalive_intvl = 15 net.ipv4.tcp_keepalive_probes = 5 net.nf_conntrack_max = 2097152 #--------------------------------------------------------------------- # 系统级别,所有进程一共可以打开的文件数量 fs.file-max = 6815744 # 异步IO请求数目 fs.aio-max-nr = 1048576
优化后
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
shell脚本学习
shell基本命令
查看文件和目录的命令
ls : 列出目录下的清单; cat: 连接显示文件内容 less/more: 分页显示文件内容,建议使用less,相比于more更方便; head: 显示文件头部,可指定行数,默认显示10行; tail: 显示文件尾部,可指定行数,默认显示10行; file: 显示文件类型; wc: 查看文件或统计信息; find: 查找文件或目录(选项比较多,前面有博客专门介绍过)
操作文件和目录
touch:创建新文件(可以直接使用vim/vi/gredit等编辑器直接创建) mkdir:创建目录,可以利用 -r选项递归创建 cp: 拷贝命令,拷贝目录时可以使用 -r 选项 ln: 创建链接命令,分为软连接和硬链接(有专门的文章介绍) mv: 移动文件或者目录的命令,同时也是改名命令 rm: 删除命令,-r 删除目录, 谨慎使用;
管理文件或者目录的权限
ls -l : 可以列出文件和目录的权限信息; chmod: 修改文件和目录的权限(需要了解字母及数字权限表示) chown/chgrp: 改变属组和属主 setuid/setgid:设置用户或组权限位;
文本处理命令
sort : 文本排序 uniq : 文本去重 tr : 替换命令 grep : 查找字符串 diff: 文件对比,找出文件差异
其它常用的命令
hostname : 查看主机名 w, who : 列出系统登录的用户 uptime : 查看系统运行时间 uname : 查看系统信息 date : 显示和设置系统日期和时间 id : 显示用户属性
shell命令进阶
paster : 合并文本 dd : 备份和拷贝文件(和vim 和剪切命令一样) tar : 打包和解包文件 mount, umount : 挂载和卸载存储介质 df : 报告文件系统磁盘空间利用率 du : 评估文件空间利用率 ps : 查看系统的进程 pidof : 列出进程的pid top : 相当于 Linux 的任务管理器 & : 将作业后台运行 jobs : 查看作业 bg : 让挂起的进程在后台继续执行 fg : 将后台进程放入前台 fdisk: 查看系统的磁盘信息
shell基本语法
#!/bin/bash echo "hello world"
分析:还是 hello world 起步,第一行的 #!叫做 Shebang(我也不知道为什么这么叫), 然后后面的 /bin/bash 则是指定解释器,既然shell是门解释性语言,自然需要解释器
变量
在shell中设置变量和平时在命令行设置变量是一样的,可以直接定义一个变量,例如: a=10 不需要声明类型,不需要分号; 如果需要明确指定变量的类型的话,可以只用declare的选项指定类型; 变量的作用域: 在shell 中,变量默认都具有全局属性,如果需要局部的变量则需要在变量的声明处加 local 关键字。如: local a=10 变量的操作: 3.1 获取变量的值 在命令行获取变量的值是通过 echo ${a} 打印变量的值到终端的,那么echo的作用是打印,'${}' 的作用不言而喻(花括号可省略,起分割的作用)。 3.2 变量的运算 let a+=1 即使用 let 命令,当然除了 let 命令还有 '(())' 双括号,都可以进行变量的运算; 3.3 参数变量 说到shell的参数,有两个点,一个是命令行参数,一个是函数的参数,这里先说明命令行参数,函数的参数在后面学习函数的时候补充; 首先,记住这几个特殊符号: $# $@ $* $1 $2 ... 在运行程序的时候,经常需要带一些初始的参数,那么这些命令行参数到底是怎么传递进来的呢? $# : 除$0以外的参数的个数(文件名,命令除外,它们用$0查看); $@/*: 表示所有参数的集合 $0,1,2...: 表示参数
环境变量和普通变量
首先,明确两点,第一点即环境变量和普通变量在虚拟地址空间的存放位置,环境变量在栈顶的位置(高地址处),而普通变量是在数据段存放的;第二点,子进程会继承父进程的环境变量; 普通变量的命令行声明: a=10 将普通变量变为环境变量:export a 如何验证? 4.1 在父bash 中声明变量 a=10, 命令行敲 'bash' 命令相当于起了一个子bash, 在子 bash 中查找是否有 a变量; (set 命令查看所有变量, env 命令查看所有环境变量,exit或者ctrl + d 退出子bash) 4.2 在父bash 中将变量 a 声明为环境变量 'export a', 在子bash 中查看变量 a;
条件
if true then echo "hello world!" else echo "see you world!" fi
shell的条件判断需要结合一些命令,符号,选项来配合使用; ’ [ ] ’ 和 test 有很多选项,这里我就不一一演示,需要的话直接 man test; ( 注意: 使用 ‘[ ]’ 时, 需要两个空格,‘[’的后面, ‘]’的前面各一个);
2.1 '[]' 和 test '[]' 和 test 都被用作测试,如:
if [ -f install.log ] then echo "hello" fi test -f install.log; echo "hello"
(注: $? 查看上一条命令的返回值 ) 追加一个知识点: 因为字符串的比较使用 ‘[]’ 时需要对 ><进行转义,所以shell提供 ’ [[ ]] ’ 符号,则不再需要转义,对于算术,shell提供 ’ (( )) ’ 则可以直接使用比较符进行比较(这在后面的例子中有体现);
&& 和 ||
&& : &&符号的前面的表达式为真才会执行 &&后面的表达式; || : || 符号前面的表达式为假才会执行 || 后面的表达式; '!': 逻辑非 通常 && 和 || 配合使用,如:
shell 的 case 语句
case $1 in 1) echo "1" ;; 2) echo "2" ;; esac
上面就是case语句的格式,这里刚好提醒一点,就是在shell中,不允许出现空语句,如果非要有空语句,则必须写上一个 冒号 ’ : ‘;
Bash 循环
shell的循环:for/while/until 下面只演示前两种,until不太用到;
for i in $(seq 10) do echo -n $i done #类C 的for 循环: for (( i=0; i<10; ++i )) do echo -n $i done
注: seq 是生成连续序列的命令,而shell中执行命令的话,会用 ’ $() ‘; echo -n 选项则是取消换行,每个echo语句都会带一个换行,直接一个echo则代表换行;
while循环
i=1 while true do if [ $i -gt 2 ];then echo $i break fi let i++ done
函数
$# 传递到脚本或函数的参数个数 $* 以一个单字符串显示所有向脚本传递的参数 $$ 脚本运行的当前进程ID号 $! 后台运行的最后一个进程的ID号 $@ 与$*相同,但是使用时加引号,并在引号中返回每个参数。 $- 显示Shell使用的当前选项,与set命令功能相同。 $? 显示最后命令的退出状态。0表示没有错误,其他任何值表明有错误。
function fun() { if [ $# -ne 2 ];then return 1 fi local a=$1 local b=$2 let c = a + b echo "hello $c" return 0 } fun 1 2 if [ $? -ne 0 ];then echo "arguments fault!" exit else echo "used right" fi
分析: 从这个脚本中我们可以看出shell脚本函数的使用方式,从定义到参数的获取,再到返回值;最后还有函数的调用方式其实和命令的执行方式是一样的; (注: function关键字可以省略但不建议,return 可以返回的值是 0-255) 额外知识点:在shell脚本中 双引号 和 单引号是有区别的,虽然在大部分场景中它们是一致的,但是如果含有特殊字符的话,双引号是没有对特殊字符进行转义的,所以上面的脚本中 “hello $c”可以顺利执行,但是单引号的话默认会对特殊字符转义的,例如:
注:shell函数对于参数的处理还有 shift 和 getopts 的处理方法
重定向
说起重定向,估计不少同学都想到了管道; 但是,管道并不是重定向,重定向是将一个命令的输出结果作为下一个命令的输入,而重定向是将输出和文件相链接的; 重定向分为输入重定向和输出重定向,其中又有追加和覆盖的区别,总的来说就是下面四个符号: ’ > ‘, ’ >> ‘, ’ << ’ , ’ < ‘;
可以将标准错误也重定向到文件
ls l 2>test.sh
例如: tr a-z A-Z <<END abcdef END
注: 这里的END不是固定的,只是起一个标签的作用,代表下次遇到END就结束输入;
重定向的这种用法还被用到项目中的configure文件,使configure文件可以自动生成Makefile文件
从标准输入读取
这个其实只是一个read的使用,在shell中使用read从标准输入读取数据;
awk/sed工具
sed 工具 sed 是一个文本处理工具,它为我们提供了很多的方便,刚开始知道sed这个工具的时候,其实挺害怕,觉得好复杂,估计用不了,而且在尝试了几次之后发现过几天就忘了,也就不了了之,这很正常,因为这些工具的使用就是唯手熟尔;现在只要了解它就够了; 学习sed和awk工具还有一个前提就是正则表达式的学习,这也是很多同学望而却步的原因,这里我就不对正则表达式进行百科了,不会就查,用的时间长了就记住了;
sed的工作方式
sed是一行一行处理文本的,还有需要知道sed有一个HOLD空间和模式空间,HOLD用来作为文档处理的暂存空间,不能有任何的操作,所有的操作只能在模式空间进行; sed常用的选项: -e: 它告诉sed将下一个参数解释为sed指令,即需要连续多个处理时使用; -f : 指定由sed指令组成的脚本的名称; -i : 直接在读取的内容进行修改,如果没有—i,不会源文件造成任何修改; -n: 静默模式,即只输出匹配的行,如果没有-n则匹配行会和源文件全部输出; 现在先不做演示,等把下面的编辑命令也复习完毕,一块做演示; sed的编辑命令有24个之多,在这里只学习常用的几个: 追加(a),更改(c),删除(d),插入(i),替换(s),打印(l),打印行号(=),转换(y);
选项则是需要用到暂存空间时要用到的选项: + g:[address[,address]]g 将hold space中的内容拷⻉贝到pattern space中,原来pattern space⾥里的内容清除 + G:[address[,address]]G 将hold space中的内容append到pattern space\n后 + h:[address[,address]]h 将pattern space中的内容拷⻉贝到hold space中,原来的hold space⾥里的内容被清除 + H:[address[,address]]H 将pattern space中的内容append hold space\n后 + d:[address[,address]]d 删除pattern中的所有⾏行,并读⼊入下⼀一新⾏行到pattern中 + D:[address[,address]]D 删除multiline pattern中的第⼀一⾏行,不读⼊入下⼀一⾏行 + N,添加下一行至pattern space内; + x:交换保持空间和模式空间的内容
awk工具
awk可是厉害了,awk严格说也是一门语言,语法很多,也很强大,在这里我只学了个皮毛,也就只负责皮毛的这块了; 既然有了sed为什么还要有awk呢? 肯定是因为awk比sed更加强大,就最明显的一点,sed只能一行一行处理数据,不能处理一列一列的数据,而awk就可以做到,而且awk还可以利用类C的语法结构,简直强大,可是我用的还是不熟; 关于awk的使用,在前面复习shell语法的时候其实都用到了,就是找出你在Linux上最常用的十个命令: history | awk '{print $1}' | sort | uniq -c | sort -nr |head awk 的默认的列分割符是空格,我们也可以指定分割符,利用 -F选项; 例如:
awk 还有一个特点就是它的 BEGIN 块和 END 块; BEGIN块的作用是在awk执行匹配之前先执行的语句,END自然就是awk处理完之后执行的语句
技巧
1、 脚本中多写注释 这是不仅可应用于 shell 脚本程序中,也可用在其他所有类型的编程中的一种推荐做法。在脚本中作注释能帮你或别人翻阅你的脚本时了解脚本的不同部分所做的工作。 对于刚入门的人来说,注释用 # 号来定义。 # 这是一条注释 2、 当运行失败时使脚本退出 有时即使某些命令运行失败,bash 可能继续去执行脚本,这样就影响到脚本的其余部分(会最终导致逻辑错误)。用下面的行的方式在遇到命令失败时来退出脚本执行: # 如果命令运行失败让脚本退出执行 set -o errexit # 或 set -e 3、 当 Bash 用未声明变量时使脚本退出 Bash 也可能会使用能导致起逻辑错误的未声明的变量。因此用下面行的方式去通知 bash 当它尝试去用一个未声明变量时就退出脚本执行: # 若有用未设置的变量即让脚本退出执行 set -o nounset # 或 set -u 4、 使用双引号来引用变量 当引用时(使用一个变量的值)用双引号有助于防止由于空格导致单词分割开和由于识别和扩展了通配符而导致的不必要匹配。 看看下面的例子: #!/bin/bash # 若命令失败让脚本退出 set -o errexit # 若未设置的变量被使用让脚本退出 set -o nounset echo “Names without double quotes” echo names=”Tecmint FOSSMint Linusay” for name in $names; do echo “$name” done echo echo “Names with double quotes” echo for name in “$names”; do echo “$name” done exit 0 保存文件并退出,接着如下运行一下: $ ./names.sh 在脚本中用双引号 在脚本中用双引号 5、 在脚本中使用函数 除了非常小的脚本(只有几行代码),总是记得用函数来使代码模块化且使得脚本更可读和可重用。 写函数的语法如下所示: function check_root(){ command1; command2; } # 或 check_root(){ command1; command2; } 写成单行代码时,每个命令后要用终止符号: check_root(){ command1; command2; } 6、 字符串比较时用 = 而不是 == 注意 == 是 = 的同义词,因此仅用个单 = 来做字符串比较,例如: value1=”tecmint.com” value2=”fossmint.com” if [ “$value1” = “$value2” ] 7、 用 $(command) 而不是老旧的 `command` 来做代换 命令代换 是用这个命令的输出结果取代命令本身。用 $(command) 而不是引号 `command` 来做命令代换。 这种做法也是 shellcheck tool (可针对 shell 脚本显示警告和建议)所建议的。例如: user=`echo “$UID”` user=$(echo “$UID”) 8、 用 readonly 来声明静态变量 静态变量不会改变;它的值一旦在脚本中定义后不能被修改: readonly passwd_file=”/etc/passwd” readonly group_file=”/etc/group” 9、 环境变量用大写字母命名,而自定义变量用小写 所有的 bash 环境变量用大写字母去命名,因此用小写字母来命名你的自定义变量以避免变量名冲突: # 定义自定义变量用小写,而环境变量用大写 nikto_file=”$HOME/Downloads/nikto-master/program/nikto.pl” perl “$nikto_file” -h “$1” 10、 总是对长脚本进行调试 如果你在写有数千行代码的 bash 脚本,排错可能变成噩梦。为了在脚本执行前易于修正一些错误,要进行一些调试。
案例
先了解下编写Shell过程中注意事项: 开头加解释器:#!/bin/bash 语法缩进,使用四个空格;多加注释说明。 命名建议规则:变量名大写、局部变量小写,函数名小写,名字体现出实际作用。 默认变量是全局的,在函数中变量local指定为局部变量,避免污染其他作用域。 有两个命令能帮助我调试脚本:set -e 遇到执行非0时退出脚本,set-x 打印执行过程。 写脚本一定先测试再到生产上。 1、获取随机字符串或数字 获取随机8位字符串: 方法1: # echo $RANDOM |md5sum |cut -c 1-8 471b94f2 方法2: # openssl rand -base64 4 vg3BEg== 方法3: # cat /proc/sys/kernel/random/uuid |cut -c 1-8 ed9e032c 获取随机8位数字: 方法1: # echo $RANDOM |cksum |cut -c 1-8 23648321 方法2: # openssl rand -base64 4 |cksum |cut -c 1-8 38571131 方法3: # date +%N |cut -c 1-8 69024815 cksum:打印CRC效验和统计字节 2、定义一个颜色输出字符串函数 方法1: function echo_color() { if [ $1 == "green" ]; then echo -e "\033[32;40m$2\033[0m" elif [ $1 == "red" ]; then echo -e "\033[31;40m$2\033[0m" fi } 方法2: function echo_color() { case $1 in green) echo -e "[32;40m$2[0m" ;; red) echo -e "[31;40m$2[0m" ;; *) echo "Example: echo_color red string" esac } 使用方法:echo_color green "test" function关键字定义一个函数,可加或不加。 3、批量创建用户 #!/bin/bash DATE=$(date +%F_%T) USER_FILE=user.txt echo_color(){ if [ $1 == "green" ]; then echo -e "[32;40m$2[0m" elif [ $1 == "red" ]; then echo -e "[31;40m$2[0m" fi } # 如果用户文件存在并且大小大于0就备份 if [ -s $USER_FILE ]; then mv $USER_FILE ${USER_FILE}-${DATE}.bak echo_color green "$USER_FILE exist, rename ${USER_FILE}-${DATE}.bak" fi echo -e "User Password" >> $USER_FILE echo "----------------" >> $USER_FILE for USER in user{1..10}; do if ! id $USER &>/dev/null; then PASS=$(echo $RANDOM |md5sum |cut -c 1-8) useradd $USER echo $PASS |passwd --stdin $USER &>/dev/null echo -e "$USER $PASS" >> $USER_FILE echo "$USER User create successful." else echo_color red "$USER User already exists!" fi done 4、检查软件包是否安装 #!/bin/bash if rpm -q sysstat &>/dev/null; then echo "sysstat is already installed." else echo "sysstat is not installed!" fi 5、检查服务状态 #!/bin/bash PORT_C=$(ss -anu |grep -c 123) PS_C=$(ps -ef |grep ntpd |grep -vc grep) if [ $PORT_C -eq 0 -o $PS_C -eq 0 ]; then echo "内容" | mail -s "主题" dst@example.com fi 6、检查主机存活状态 方法1:将错误IP放到数组里面判断是否ping失败三次 #!/bin/bash IP_LIST="192.168.18.1 192.168.1.1 192.168.18.2" for IP in $IP_LIST; do NUM=1 while [ $NUM -le 3 ]; do if ping -c 1 $IP > /dev/null; then echo "$IP Ping is successful." break else # echo "$IP Ping is failure $NUM" FAIL_COUNT[$NUM]=$IP let NUM++ fi done if [ ${#FAIL_COUNT[*]} -eq 3 ];then echo "${FAIL_COUNT[1]} Ping is failure!" unset FAIL_COUNT[*] fi done 方法2:将错误次数放到FAIL_COUNT变量里面判断是否ping失败三次 #!/bin/bash IP_LIST="192.168.18.1 192.168.1.1 192.168.18.2" for IP in $IP_LIST; do FAIL_COUNT=0 for ((i=1;i<=3;i++)); do if ping -c 1 $IP >/dev/null; then echo "$IP Ping is successful." break else # echo "$IP Ping is failure $i" let FAIL_COUNT++ fi done if [ $FAIL_COUNT -eq 3 ]; then echo "$IP Ping is failure!" fi done 方法3:利用for循环将ping通就跳出循环继续,如果不跳出就会走到打印ping失败 #!/bin/bash ping_success_status() { if ping -c 1 $IP >/dev/null; then echo "$IP Ping is successful." continue fi } IP_LIST="192.168.18.1 192.168.1.1 192.168.18.2" for IP in $IP_LIST; do ping_success_status ping_success_status ping_success_status echo "$IP Ping is failure!" done 7、监控CPU、内存和硬盘利用率 1)CPU 借助vmstat工具来分析CPU统计信息。 #!/bin/bash DATE=$(date +%F" "%H:%M) IP=$(ifconfig eth0 |awk -F [ :]+ /inet addr/{print $4} ) # 只支持CentOS6 MAIL="example@mail.com" if ! which vmstat &>/dev/null; then echo "vmstat command no found, Please install procps package." exit 1 fi US=$(vmstat |awk NR==3{print $13} ) SY=$(vmstat |awk NR==3{print $14} ) IDLE=$(vmstat |awk NR==3{print $15} ) WAIT=$(vmstat |awk NR==3{print $16} ) USE=$(($US+$SY)) if [ $USE -ge 50 ]; then echo " Date: $DATE Host: $IP Problem: CPU utilization $USE " | mail -s "CPU Monitor" $MAIL fi 2)内存 #!/bin/bash DATE=$(date +%F" "%H:%M) IP=$(ifconfig eth0 |awk -F [ :]+ /inet addr/{print $4} ) MAIL="example@mail.com" TOTAL=$(free -m |awk /Mem/{print $2} ) USE=$(free -m |awk /Mem/{print $3-$6-$7} ) FREE=$(($TOTAL-$USE)) # 内存小于1G发送报警邮件 if [ $FREE -lt 1024 ]; then echo " Date: $DATE Host: $IP Problem: Total=$TOTAL,Use=$USE,Free=$FREE " | mail -s "Memory Monitor" $MAIL fi 3)硬盘 #!/bin/bash DATE=$(date +%F" "%H:%M) IP=$(ifconfig eth0 |awk -F [ :]+ /inet addr/{print $4} ) MAIL="example@mail.com" TOTAL=$(fdisk -l |awk -F [: ]+ BEGIN{OFS="="}/^Disk /dev/{printf "%s=%sG,",$2,$3} ) PART_USE=$(df -h |awk BEGIN{OFS="="}/^/dev/{print $1,int($5),$6} ) for i in $PART_USE; do PART=$(echo $i |cut -d"=" -f1) USE=$(echo $i |cut -d"=" -f2) MOUNT=$(echo $i |cut -d"=" -f3) if [ $USE -gt 80 ]; then echo " Date: $DATE Host: $IP Total: $TOTAL Problem: $PART=$USE($MOUNT) " | mail -s "Disk Monitor" $MAIL fi done 8、批量主机磁盘利用率监控 前提监控端和被监控端SSH免交互登录或者密钥登录。 写一个配置文件保存被监控主机SSH连接信息,文件内容格式:IP User Port #!/bin/bash HOST_INFO=host.info for IP in $(awk /^[^#]/{print $1} $HOST_INFO); do USER=$(awk -v ip=$IP ip==$1{print $2} $HOST_INFO) PORT=$(awk -v ip=$IP ip==$1{print $3} $HOST_INFO) TMP_FILE=/tmp/disk.tmp ssh -p $PORT $USER@$IP df -h > $TMP_FILE USE_RATE_LIST=$(awk BEGIN{OFS="="}/^/dev/{print $1,int($5)} $TMP_FILE) for USE_RATE in $USE_RATE_LIST; do PART_NAME=${USE_RATE%=*} USE_RATE=${USE_RATE#*=} if [ $USE_RATE -ge 80 ]; then echo "Warning: $PART_NAME Partition usage $USE_RATE%!" fi done done 9、检查网站可用性 1)检查URL可用性 方法1: check_url() { HTTP_CODE=$(curl -o /dev/null --connect-timeout 3 -s -w "%{http_code}" $1) if [ $HTTP_CODE -ne 200 ]; then echo "Warning: $1 Access failure!" fi } 方法2: check_url() { if ! wget -T 10 --tries=1 --spider $1 >/dev/null 2>&1; then #-T超时时间,--tries尝试1次,--spider爬虫模式 echo "Warning: $1 Access failure!" fi } 使用方法:check_url www.baidu.com 2)判断三次URL可用性 思路与上面检查主机存活状态一样。 方法1:利用循环技巧,如果成功就跳出当前循环,否则执行到最后一行 #!/bin/bash check_url() { HTTP_CODE=$(curl -o /dev/null --connect-timeout 3 -s -w "%{http_code}" $1) if [ $HTTP_CODE -eq 200 ]; then continue fi } URL_LIST="www.baidu.com www.agasgf.com" for URL in $URL_LIST; do check_url $URL check_url $URL check_url $URL echo "Warning: $URL Access failure!" done 方法2:错误次数保存到变量 #!/bin/bash URL_LIST="www.baidu.com www.agasgf.com" for URL in $URL_LIST; do FAIL_COUNT=0 for ((i=1;i<=3;i++)); do HTTP_CODE=$(curl -o /dev/null --connect-timeout 3 -s -w "%{http_code}" $URL) if [ $HTTP_CODE -ne 200 ]; then let FAIL_COUNT++ else break fi done if [ $FAIL_COUNT -eq 3 ]; then echo "Warning: $URL Access failure!" fi done 方法3:错误次数保存到数组 #!/bin/bash URL_LIST="www.baidu.com www.agasgf.com" for URL in $URL_LIST; do NUM=1 while [ $NUM -le 3 ]; do HTTP_CODE=$(curl -o /dev/null --connect-timeout 3 -s -w "%{http_code}" $URL) if [ $HTTP_CODE -ne 200 ]; then FAIL_COUNT[$NUM]=$IP #创建数组,以$NUM下标,$IP元素 let NUM++ else break fi done if [ ${#FAIL_COUNT[*]} -eq 3 ]; then echo "Warning: $URL Access failure!" unset FAIL_COUNT[*] #清空数组 fi done 10、检查MySQL主从同步状态 #!/bin/bash USER=bak PASSWD=123456 IO_SQL_STATUS=$(mysql -u$USER -p$PASSWD -e show slave statusG |awk -F: /Slave_.*_Running/{gsub(": ",":");print $0} ) #gsub去除冒号后面的空格 for i in $IO_SQL_STATUS; do THREAD_STATUS_NAME=${i%:*} THREAD_STATUS=${i#*:} if [ "$THREAD_STATUS" != "Yes" ]; then echo "Error: MySQL Master-Slave $THREAD_STATUS_NAME status is $THREAD_STATUS!" fi done
本章目录 11、iptables自动屏蔽访问网站频繁的IP 12、判断用户输入的是否为IP地址 13、判断用户输入的是否为数字 14、给定目录找出包含关键字的文件 15、监控目录,将新创建的文件名追加到日志中 16、给用户提供多个网卡选择 17、查看网卡实时流量 18、MySQL数据库备份 19、Nginx服务管理脚本 20、用户根据菜单选择要连接的Linux主机 --------------------------- 11、iptables自动屏蔽访问网站频繁的IP 场景:恶意访问,安全防范 1)屏蔽每分钟访问超过200的IP 方法1:根据访问日志(Nginx为例) #!/bin/bash DATE=$(date +%d/%b/%Y:%H:%M) ABNORMAL_IP=$(tail -n5000 access.log |grep $DATE |awk '{a[$1]++}END{for(i in a)if(a[i]>100)print i}') #先tail防止文件过大,读取慢,数字可调整每分钟最大的访问量。awk不能直接过滤日志,因为包含特殊字符。 for IP in $ABNORMAL_IP; do if [ $(iptables -vnL |grep -c "$IP") -eq 0 ]; then iptables -I INPUT -s $IP -j DROP fi done 方法2:通过TCP建立的连接 #!/bin/bash ABNORMAL_IP=$(netstat -an |awk '$4~/:80$/ && $6~/ESTABLISHED/{gsub(/:[0-9]+/,"",$5);{a[$5]++}}END{for(i in a)if(a[i]>100)print i}') #gsub是将第五列(客户端IP)的冒号和端口去掉 for IP in $ABNORMAL_IP; do if [ $(iptables -vnL |grep -c "$IP") -eq 0 ]; then iptables -I INPUT -s $IP -j DROP fi done 2)屏蔽每分钟SSH尝试登录超过10次的IP 方法1:通过lastb获取登录状态: #!/bin/bash DATE=$(date +"%a %b %e %H:%M") #星期月天时分 %e单数字时显示7,而%d显示07 ABNORMAL_IP=$(lastb |grep "$DATE" |awk '{a[$3]++}END{for(i in a)if(a[i]>10)print i}') for IP in $ABNORMAL_IP; do if [ $(iptables -vnL |grep -c "$IP") -eq 0 ]; then iptables -I INPUT -s $IP -j DROP fi done 方法2:通过日志获取登录状态 #!/bin/bash DATE=$(date +"%b %d %H") ABNORMAL_IP="$(tail -n10000 /var/log/auth.log |grep "$DATE" |awk '/Failed/{a[$(NF-3)]++}END{for(i in a)if(a[i]>5)print i}')" for IP in $ABNORMAL_IP; do if [ $(iptables -vnL |grep -c "$IP") -eq 0 ]; then iptables -A INPUT -s $IP -j DROP echo "$(date +"%F %T") - iptables -A INPUT -s $IP -j DROP" >>~/ssh-login-limit.log fi done 12、判断用户输入的是否为IP地址 方法1: #!/bin/bash function check_ip(){ IP=$1 VALID_CHECK=$(echo $IP|awk -F. '$1<=255&&$2<=255&&$3<=255&&$4<=255{print "yes"}') if echo $IP|grep -E "^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$">/dev/null; then if [ $VALID_CHECK == "yes" ]; then echo "$IP available." else echo "$IP not available!" fi else echo "Format error!" fi } check_ip 192.168.1.1 check_ip 256.1.1.1 方法2: #!/bin/bash function check_ip(){ IP=$1 if [[ $IP =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then FIELD1=$(echo $IP|cut -d. -f1) FIELD2=$(echo $IP|cut -d. -f2) FIELD3=$(echo $IP|cut -d. -f3) FIELD4=$(echo $IP|cut -d. -f4) if [ $FIELD1 -le 255 -a $FIELD2 -le 255 -a $FIELD3 -le 255 -a $FIELD4 -le 255 ]; then echo "$IP available." else echo "$IP not available!" fi else echo "Format error!" fi } check_ip 192.168.1.1 check_ip 256.1.1.1 增加版: 加个死循环,如果IP可用就退出,不可用提示继续输入,并使用awk判断。 #!/bin/bash function check_ip(){ local IP=$1 VALID_CHECK=$(echo $IP|awk -F. '$1<=255&&$2<=255&&$3<=255&&$4<=255{print "yes"}') if echo $IP|grep -E "^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$" >/dev/null; then if [ $VALID_CHECK == "yes" ]; then return 0 else echo "$IP not available!" return 1 fi else echo "Format error! Please input again." return 1 fi } while true; do read -p "Please enter IP: " IP check_ip $IP [ $? -eq 0 ] && break || continue done 13、判断用户输入的是否为数字 方法1: #!/bin/bash if [[ $1 =~ ^[0-9]+$ ]]; then echo "Is Number." else echo "No Number." fi 方法2: #!/bin/bash if [ $1 -gt 0 ] 2>/dev/null; then echo "Is Number." else echo "No Number." fi 方法3: #!/bin/bash echo $1 |awk '{print $0~/^[0-9]+$/?"Is Number.":"No Number."}' #三目运算符 12.14 找出包含关键字的文件 DIR=$1 KEY=$2 for FILE in $(find $DIR -type f); do if grep $KEY $FILE &>/dev/null; then echo "--> $FILE" fi done 14、给定目录找出包含关键字的文件 #!/bin/bash DIR=$1 KEY=$2 for FILE in $(find $DIR -type f); do if grep $KEY $FILE &>/dev/null; then echo "--> $FILE" fi done 15、监控目录,将新创建的文件名追加到日志中 场景:记录目录下文件操作。 需先安装inotify-tools软件包。 #!/bin/bash MON_DIR=/opt inotifywait -mq --format %f -e create $MON_DIR |\ while read files; do echo $files >> test.log done 16、给用户提供多个网卡选择 场景:服务器多个网卡时,获取指定网卡,例如网卡流量 #!/bin/bash function local_nic() { local NUM ARRAY_LENGTH NUM=0 for NIC_NAME in $(ls /sys/class/net|grep -vE "lo|docker0"); do NIC_IP=$(ifconfig $NIC_NAME |awk -F'[: ]+' '/inet addr/{print $4}') if [ -n "$NIC_IP" ]; then NIC_IP_ARRAY[$NUM]="$NIC_NAME:$NIC_IP" #将网卡名和对应IP放到数组 let NUM++ fi done ARRAY_LENGTH=${#NIC_IP_ARRAY[*]} if [ $ARRAY_LENGTH -eq 1 ]; then #如果数组里面只有一条记录说明就一个网卡 NIC=${NIC_IP_ARRAY[0]%:*} return 0 elif [ $ARRAY_LENGTH -eq 0 ]; then #如果没有记录说明没有网卡 echo "No available network card!" exit 1 else #如果有多条记录则提醒输入选择 for NIC in ${NIC_IP_ARRAY[*]}; do echo $NIC done while true; do read -p "Please enter local use to network card name: " INPUT_NIC_NAME COUNT=0 for NIC in ${NIC_IP_ARRAY[*]}; do NIC_NAME=${NIC%:*} if [ $NIC_NAME == "$INPUT_NIC_NAME" ]; then NIC=${NIC_IP_ARRAY[$COUNT]%:*} return 0 else COUNT+=1 fi done echo "Not match! Please input again." done fi } local_nic 17、查看网卡实时流量 适用于CentOS6操作系统。 #!/bin/bash # Description: Only CentOS6 traffic_unit_conv() { local traffic=$1 if [ $traffic -gt 1024000 ]; then printf "%.1f%s" "$(($traffic/1024/1024))" "MB/s" elif [ $traffic -lt 1024000 ]; then printf "%.1f%s" "$(($traffic/1024))" "KB/s" fi } NIC=$1 echo -e " In ------ Out" while true; do OLD_IN=$(awk -F'[: ]+' '$0~"'$NIC'"{print $3}' /proc/net/dev) OLD_OUT=$(awk -F'[: ]+' '$0~"'$NIC'"{print $11}' /proc/net/dev) sleep 1 NEW_IN=$(awk -F'[: ]+' '$0~"'$NIC'"{print $3}' /proc/net/dev) NEW_OUT=$(awk -F'[: ]+' '$0~"'$NIC'"{print $11}' /proc/net/dev) IN=$(($NEW_IN-$OLD_IN)) OUT=$(($NEW_OUT-$OLD_OUT)) echo "$(traffic_unit_conv $IN) $(traffic_unit_conv $OUT)" sleep 1 done 使用:./traffic.sh eth0 18、MySQL数据库备份 #!/bin/bash DATE=$(date +%F_%H-%M-%S) HOST=192.168.1.120 DB=test USER=bak PASS=123456 MAIL="zhangsan@example.com lisi@example.com" BACKUP_DIR=/data/db_backup SQL_FILE=${DB}_full_$DATE.sql BAK_FILE=${DB}_full_$DATE.zip cd $BACKUP_DIR if mysqldump -h$HOST -u$USER -p$PASS --single-transaction --routines --triggers -B $DB > $SQL_FILE; then zip $BAK_FILE $SQL_FILE && rm -f $SQL_FILE if [ ! -s $BAK_FILE ]; then echo "$DATE 内容" | mail -s "主题" $MAIL fi else echo "$DATE 内容" | mail -s "主题" $MAIL fi find $BACKUP_DIR -name '*.zip' -ctime +14 -exec rm {} \; 19、Nginx服务管理脚本 场景:使用源码包安装Nginx不含带服务管理脚本,也就是不能使用"service nginx start"或"/etc/init.d/nginx start",所以写了以下的服务管理脚本。 #!/bin/bash # Description: Only support RedHat system . /etc/init.d/functions WORD_DIR=/usr/local/nginx DAEMON=$WORD_DIR/sbin/nginx CONF=$WORD_DIR/conf/nginx.conf NAME=nginx PID=$(awk -F'[; ]+' '/^[^#]/{if($0~/pid;/)print $2}' $CONF) if [ -z "$PID" ]; then PID=$WORD_DIR/logs/nginx.pid else PID=$WORD_DIR/$PID fi stop() { $DAEMON -s stop sleep 1 [ ! -f $PID ] && action "* Stopping $NAME" /bin/true || action "* Stopping $NAME" /bin/false } start() { $DAEMON sleep 1 [ -f $PID ] && action "* Starting $NAME" /bin/true || action "* Starting $NAME" /bin/false } reload() { $DAEMON -s reload } test_config() { $DAEMON -t } case "$1" in start) if [ ! -f $PID ]; then start else echo "$NAME is running..." exit 0 fi ;; stop) if [ -f $PID ]; then stop else echo "$NAME not running!" exit 0 fi ;; restart) if [ ! -f $PID ]; then echo "$NAME not running!" start else stop start fi ;; reload) reload ;; testconfig) test_config ;; status) [ -f $PID ] && echo "$NAME is running..." || echo "$NAME not running!" ;; *) echo "Usage: $0 {start|stop|restart|reload|testconfig|status}" exit 3 ;; esac 20、用户根据菜单选择要连接的Linux主机 Linux主机SSH连接信息: # cat host.txt Web 192.168.1.10 root 22 DB 192.168.1.11 root 22 内容格式:主机名 IP User Port #!/bin/bash PS3="Please input number: " HOST_FILE=host.txt while true; do select NAME in $(awk '{print $1}' $HOST_FILE) quit; do [ ${NAME:=empty} == "quit" ] && exit 0 IP=$(awk -v NAME=${NAME} '$1==NAME{print $2}' $HOST_FILE) USER=$(awk -v NAME=${NAME} '$1==NAME{print $3}' $HOST_FILE) PORT=$(awk -v NAME=${NAME} '$1==NAME{print $4}' $HOST_FILE) if [ $IP ]; then echo "Name: $NAME, IP: $IP" ssh -o StrictHostKeyChecking=no -p $PORT -i id_rsa $USER@$IP # 密钥免交互登录 break else echo "Input error, Please enter again!" break fi done done
本文目录 21、从FTP服务器下载文件 22、连续输入5个100以内的数字,统计和、最小和最大 23、将结果分别赋值给变量 24、批量修改文件名 25、统计当前目录中以.html结尾的文件总大 26、扫描主机端口状态 27、Expect实现SSH免交互执行命令 28、批量修改服务器用户密码 29、打印乘法口诀 30、getopts工具完善脚本命令行参数 21、从FTP服务器下载文件 #!/bin/bash if [ $# -ne 1 ]; then echo "Usage: $0 filename" fi dir=$(dirname $1) file=$(basename $1) ftp -n -v << EOF # -n 自动登录 open 192.168.1.10 # ftp服务器 user admin password binary # 设置ftp传输模式为二进制,避免MD5值不同或.tar.gz压缩包格式错误 cd $dir get "$file" EOF 22、连续输入5个100以内的数字,统计和、最小和最大 #!/bin/bash COUNT=1 SUM=0 MIN=0 MAX=100 while [ $COUNT -le 5 ]; do read -p "请输入1-10个整数:" INT if [[ ! $INT =~ ^[0-9]+$ ]]; then echo "输入必须是整数!" exit 1 elif [[ $INT -gt 100 ]]; then echo "输入必须是100以内!" exit 1 fi SUM=$(($SUM+$INT)) [ $MIN -lt $INT ] && MIN=$INT [ $MAX -gt $INT ] && MAX=$INT let COUNT++ done echo "SUM: $SUM" echo "MIN: $MIN" echo "MAX: $MAX" 23、将结果分别赋值给变量 应用场景:希望将执行结果或者位置参数赋值给变量,以便后续使用。 方法1: for i in $(echo "4 5 6"); do eval a$i=$i done echo $a4 $a5 $a6 方法2:将位置参数192.168.1.1{1,2}拆分为到每个变量 num=0 for i in $(eval echo $*);do #eval将{1,2}分解为1 2 let num+=1 eval node${num}="$i" done echo $node1 $node2 $node3 # bash a.sh 192.168.1.1{1,2} 192.168.1.11 192.168.1.12 方法3: arr=(4 5 6) INDEX1=$(echo ${arr[0]}) INDEX2=$(echo ${arr[1]}) INDEX3=$(echo ${arr[2]}) 24、批量修改文件名 示例: # touch article_{1..3}.html # ls article_1.html article_2.html article_3.html 目的:把article改为bbs 方法1: for file in $(ls *html); do mv $file bbs_${file#*_} # mv $file $(echo $file |sed -r 's/.*(_.*)/bbs\1/') # mv $file $(echo $file |echo bbs_$(cut -d_ -f2) done 方法2: for file in $(find . -maxdepth 1 -name "*html"); do mv $file bbs_${file#*_} done 方法3: # rename article bbs *.html 25、统计当前目录中以.html结尾的文件总大 方法1: # find . -name "*.html" -exec du -k {} \; |awk '{sum+=$1}END{print sum}' 方法2: for size in $(ls -l *.html |awk '{print $5}'); do sum=$(($sum+$size)) done echo $sum 26、扫描主机端口状态 #!/bin/bash HOST=$1 PORT="22 25 80 8080" for PORT in $PORT; do if echo &>/dev/null > /dev/tcp/$HOST/$PORT; then echo "$PORT open" else echo "$PORT close" fi done 27、Expect实现SSH免交互执行命令 Expect是一个自动交互式应用程序的工具,如telnet,ftp,passwd等。 需先安装expect软件包。 方法1:EOF标准输出作为expect标准输入 #!/bin/bash USER=root PASS=123.com IP=192.168.1.120 expect << EOF set timeout 30 spawn ssh $USER@$IP expect { "(yes/no)" {send "yes\r"; exp_continue} "password:" {send "$PASS\r"} } expect "$USER@*" {send "$1\r"} expect "$USER@*" {send "exit\r"} expect eof EOF 方法2: #!/bin/bash USER=root PASS=123.com IP=192.168.1.120 expect -c " spawn ssh $USER@$IP expect { \"(yes/no)\" {send \"yes\r\"; exp_continue} \"password:\" {send \"$PASS\r\"; exp_continue} \"$USER@*\" {send \"df -h\r exit\r\"; exp_continue} }" 方法3:将expect脚本独立出来 登录脚本: # cat login.exp #!/usr/bin/expect set ip [lindex $argv 0] set user [lindex $argv 1] set passwd [lindex $argv 2] set cmd [lindex $argv 3] if { $argc != 4 } { puts "Usage: expect login.exp ip user passwd" exit 1 } set timeout 30 spawn ssh $user@$ip expect { "(yes/no)" {send "yes\r"; exp_continue} "password:" {send "$passwd\r"} } expect "$user@*" {send "$cmd\r"} expect "$user@*" {send "exit\r"} expect eof 执行命令脚本:写个循环可以批量操作多台服务器 #!/bin/bash HOST_INFO=user_info.txt for ip in $(awk '{print $1}' $HOST_INFO) do user=$(awk -v I="$ip" 'I==$1{print $2}' $HOST_INFO) pass=$(awk -v I="$ip" 'I==$1{print $3}' $HOST_INFO) expect login.exp $ip $user $pass $1 done Linux主机SSH连接信息: # cat user_info.txt 192.168.1.120 root 123456 28、批量修改服务器用户密码 Linux主机SSH连接信息:旧密码 # cat old_pass.txt 192.168.18.217 root 123456 22 192.168.18.218 root 123456 22 内容格式:IP User Password Port SSH远程修改密码脚本:新密码随机生成 #!/bin/bash OLD_INFO=old_pass.txt NEW_INFO=new_pass.txt for IP in $(awk '/^[^#]/{print $1}' $OLD_INFO); do USER=$(awk -v I=$IP 'I==$1{print $2}' $OLD_INFO) PASS=$(awk -v I=$IP 'I==$1{print $3}' $OLD_INFO) PORT=$(awk -v I=$IP 'I==$1{print $4}' $OLD_INFO) NEW_PASS=$(mkpasswd -l 8) # 随机密码 echo "$IP $USER $NEW_PASS $PORT" >> $NEW_INFO expect -c " spawn ssh -p$PORT $USER@$IP set timeout 2 expect { \"(yes/no)\" {send \"yes\r\";exp_continue} \"password:\" {send \"$PASS\r\";exp_continue} \"$USER@*\" {send \"echo \'$NEW_PASS\' |passwd --stdin $USER\r exit\r\";exp_continue} }" done 生成新密码文件: # cat new_pass.txt 192.168.18.217 root n8wX3mU% 22 192.168.18.218 root c87;ZnnL 22 29、打印乘法口诀 方法1: # awk 'BEGIN{for(n=0;n++<9;){for(i=0;i++<n;)printf i"x"n"="i*n" ";print ""}}' 方法2: for ((i=1;i<=9;i++)); do for ((j=1;j<=i;j++)); do result=$(($i*$j)) echo -n "$j*$i=$result " done echo done 30、getopts工具完善脚本命令行参数 getopts是一个解析脚本选项参数的工具。 命令格式:getopts optstring name [arg] 初次使用你要注意这几点: 脚本位置参数会与optstring中的单个字母逐个匹配,如果匹配到就赋值给name,否则赋值name为问号; optstring中单个字母是一个选项,如果字母后面加冒号,表示该选项后面带参数,参数值并会赋值给OPTARG变量; optstring中第一个是冒号,表示屏蔽系统错误(test.sh: illegal option -- h); 允许把选项放一起,例如-ab https://www.linuxprobe.com/books 下面写一个打印文件指定行的简单例子,引导你思路: #!/bin/bash while getopts :f:n: option; do case $option in f) FILE=$OPTARG [ ! -f $FILE ] && echo "$FILE File not exist!" && exit ;; n) sed -n "${OPTARG}p" $FILE ;; ?) echo "Usage: $0 -f -n " echo "-f, --file specified file" echo "-n, --line-number print specified line" exit 1 ;; esac done 根据工作经验总结的30个Shell脚本案例至此完结,都还是比较实用的,面试笔试题也经常会出。希望朋友们多动手练一练,让你的Shell功底上升一个段位!
命令
查看进程线程数
cat /proc/23844/status