SSD, Redis多线程与云服务器架构,PC处理器
SSD, Redis多线程与云服务器架构,PC处理器
参考文献链接
https://mp.weixin.qq.com/s/T-ZTn4_oGwhXSpPNg0_wOw
https://mp.weixin.qq.com/s/qqTgnG3ndeUiZXdnmzQUfQ
https://mp.weixin.qq.com/s/1ex9MVhpKE5GPX4-bY-fdg
https://mp.weixin.qq.com/s/qwzdeVK4ILAwpFrd0I5M9Q
企业级SSD创新技术分析
内容涵盖了企业级 SSD 存储总体情况,企业级 SSD 创新技术分析,企业级 SSD 测试技术分析和主控厂商分析。
1. 主控
(1) ARM 架构
ARM 架构采用 ARM 指令集系统,根据适用范围的不同,开发差异化处理器体系结构。ARM 架构采用精简指令集(RISC)设计,通过高时钟频率、短周期执行指令集,能够有效降低芯片复杂度。Load/Store 指令架构使处理器可以根据 Load 和 Store 指令存取内存数据,RISC 架构处理器中均包含多种存放指令和数据的寄存器,ARM 架构亦是如此,ARM 处理器虽然没有采用单周期指令,但是为了便于指令解码,绝大多数指令采用定长设计,在进行运算时,CPU 只处理寄存器内数据。除了 ARM 指令集,ARM 架构还可以支持 32 位或者 16 位 Thumb指令集,它能够提供更强的代码密度,与 ARM 指令集交互工作,进一步增强处理器性能。与 x86 架构的复杂指令集(CISC)相比,ARM 架构有效提升了常用操作的运行效率。
Armv9 架构重点关注人工智能、安全性和专用处理,极大提升整体性能,支撑机器学习、数字信号处理、图像和语音识别等任务需求。Armv9 支持 Aarch32和 Aarch64 指令、NEON、加密扩展、信任区等,在 Armv8 的基础上,新增了可缩放矢量扩展 2.0(SVE2)、Arm 机密计算架构(CCA)、跟踪和调试、事务内存扩展(TME)等功能[14]。SVE2 在部分 Arm 超级计算机内核中增加定点运算,向量长度为 128bit,最高可达 2048bit,面向专用 DSP 和 XR(增强和虚拟现实)工作负载,应用于 5G、基因学、计算机视觉等多个领域[15];Arm 机密计算架构(CCA)包括领域管理扩展(RME)和 Arm 机密计算固件架构两个部分,建起全新的安全硬件环境,同时简化硬件设计,增强机密计算的可扩展性、再利用性和可移植性;跟踪和调试的分支记录缓冲区扩展(BRBE)提供信息分析能力,可用于调试和优化,嵌入式跟踪扩展(ETE)和跟踪缓冲区扩展(TRBE)改进了 Armv9的跟踪功能;事务性内存扩展(TME)支持 Arm 架构的硬件事务性内存(HTM),解决高并发写入、多线程编程问题,并通过减少锁争用导致的序列化,使得粗粒度、线程级的并行性可以随着 CPU 数量的增加而扩展。
(2) Cortex 处理器
Cortex 处理器由 Cortex-A 系列、Cortex-R 系列和 Cortex-M 系列组成,面向不同的任务要求,提供差异化功能。Cortex-A 系列处理器面向承担复杂计算任务的设备,多用于企业级和数据中心 IT 设备,要想使 Cortex-A 处理器保持均衡的性能、功耗和尺寸,需要安装前端处理器,即在 Cortex-R 处理器上增加性能;Cortex-R 系列处理器针对高性能进行了优化,满足实时处理和低时延要求,是企业级 SSD、消费级 SSD 和数据中心中的常用处理器;Cortex-M 系列用于离散处理和微控制器,可以通过数据快速移动实现低时延和实时性,极低功耗的消费级控制器通常会选择此类型号,但 Cortex-M55、Cortex-M33 和 Cortex-M0+等处理器可以为 SSD 提供辅助。Cortex 处理器系列如表3所示。
Cortex-A55 是 Arm 最强大、最高效的中端系列处理器,以 DynamIQ 技术为基础,适用于企业级 SSD 和数据中心设备等复杂计算任务。Arm Cortex-A55处理器采用 DynamIQ 技术、Armv8-A 扩展架构和专用机器学习指令,是第一代CPU 应用的组成部分。Cortex-A55 使用经过重新设计的微架构系统,显著提高处理器性能,在规格和能效方面形成较强竞争力。与的 Cortex-A53 相比,它的性能提高了 18%,功率效率提高了 15%。作为可扩展的 CPU,Cortex-A55 可用于解决从边缘到云计算的各种应用场景。Cortex-A55 既可以在独立应用中实现,也可以作为 Cortex-A7x 系列的“缩小版”CPU。
Cortex-R 系列处理器能够为企业级 SSD 主控提供有效支撑,满足实时性高、复杂性强的任务要求。Cortex-R5 处理器为实时应用程序提供了扩展的故障遏制。Cortex-R5 处理器在 Cortex-R4 的基础上,增强了错误管理、扩展功能安全和 SoC集成等方面的性能,可用于深度嵌入式实时系统和关键安全系统。Cortex-R8 是该系列产品中性能最强实时处理器,使用 Armv7-R 架构的 32 位指令集内核、11级流水线和超标量无序执行,可以从单核配置扩展到四核配置,充分释放工作负载并行性,单核 Cortex-R8 也可以根据工作负载进行断电[17]。Cortex-R82 实现 64位 Armv8-R AArch64 架构的处理器,通过 Arm Neon 技术实现机器学习负载加速,同时采用可选内存管理单元(MMU)来支持复杂操作系统(OS),适用于数据中心、企业级 SSD 和计算存储驱动器(CSD)。
2、固件
(1) 固件算法
固件是增强企业级 SSD 性能的重要组成,高效高质的固件算法可以提升固件性能,实现闪存和主控的完美兼容,增强 SSD 整体性能。固件算法由垃圾回收、损耗均衡、坏块管理、数据保持、断电恢复等多种算法组成。由于同一个物理块不能无限次擦除[18 ],因此需要通过垃圾回收将现有物理块的有效数据迁移到空闲块,通过算法回收已写入或被标记删除的无效数据,进而擦除整个块数据,使其变为可用状态;损耗均衡包括动态损耗均衡和静态损耗均衡两种,动态算法是指写入新数据时,优先选择损耗少的物理块,静态算法是把冷数据迁移到损耗多的物理块上,从而释放可写空间;在坏块管理方面,坏块作为读写擦异常的物理块,需要进行管理和标记,坏块管理算法通常采用略过策略或替换策略;在数据保持方面,数据存放时间太久会出现电荷泄露,物理页多次读取会对周围页数据产生干扰,需要通过数据保持算法监测物理块数据,将超过 ECC 设定阈值或物理页读取次数阈值的块数据迁移到其他物理块上;断电恢复针对物理页在未写完时发生的断电,断电恢复算法采用将映射表写入闪存的方法,在发生断电时可以找回之前的映射表,实现断电恢复。
(2) 纠错算法
纠错算法技术能力是提升企业级 SSD 整体水平的关键,增强算法研发设计能力至关重要。LDPC(Low-Density Parity-check Codes,低密度奇偶校验码)编码是一种企业级 SSD 常用的软信息编码技术,能够延长使用寿命,保存更长时间数据;用户数据与校验数据比例会不同以往;与信噪比和 UBER 有关,并不是简单的固定 bit 数纠错。在 LDPC 纠错码方面,为了优化纠错性能和编解码算法效果,需要关注 LDPC 校验矩阵[19]。在设计时需要将错误平层(Error Floor)降低到不影响系统性能的水平,减弱编解码算法复杂性,同时控制芯片功耗、系统性能和制造成本。在编解码算法方面,由于闪存颗粒读写次数增加会导致较高的错误率,可以通过升级 LDPC 编解码模块对应的接口协议延长使用周期,并建立解码算法流程自适应调整机制,在保证功耗最少、时延最低的情况下完成解码任务。
ECC 技术逐渐成为提升企业级 SSD 核心竞争力的有效方式和性能保障。随着 NAND 技术的不断迭代和单元内比特数量的不断增多,使得数据错误率随之增长,提升 SSD 纠错能力和纠错算法技术能力,成为 SSD 性能提升的重要方向。
(3) Telemetry
Telemetry 是一种可以远程的从物理设备或虚拟设备实时采集数据的技术。Telemetry 实现对设备、网络、协议、Overlay、业务进行多层网络健康度全面监控并评估生成可视化结果,从而实现网络故障快速定位,帮助运维人员解决网络运维问题。这种技术相比传统的网络监控方式,不仅快速,而且直观,为智能化以及精细化网络运维提供了重要的技术支撑和整体的解决方案。
SSD 产品中引入 Telemetry 属性,通过定制的 Extend SMART 日志涵盖了磨损、失败操作计数和内部异常状态相关的三大类属性。在磨损相关属性方面,磨损相关属性包含 NAND 级别的读写量,SSD 内部块的磨损程度等信息,可用于检测由磨损导致的 SSD 故障;在失败操作计数属性方面,失败操作计数属性包含 SSD 内部读写以及擦除失败等相关计数,能反映出 SSD 内部 NAND 数据存储的可靠性;在内部异常相关属性方面,内部异常相关属性包含内部温度过高,缓冲溢出等由负载量和外部环境导致的内部状态的异常,很好地反映了 SSD 内部工作状态的稳定性。
通过多个维度上对 SSD 内部状态进行监控,全面了解 SSD 内部组件的健康状况。监控更全面,对于 SSD 的各个硬件组件都有记录;记录更详细,对于温度等有更详细的记录,故障日志更详细;更贴近应用层,对于各种工作负载记录更详细。通过引入 Telemetry,能够进一步提升故障盘的预测准确度,也能大大提高数据中心云存储系统的智能运维效率。
3. 闪存
(1) QLC 技术
QLC SSD 大容量、低成本的特性,能够增强存储密度和服务器集成度。QLC SSD 与 HDD 相比,具有更明显的随机读性能优势,为服务器和数据中心带来更低的读延迟。QLC SSD 大容量技术难度低于 HDD,发热少于 HDD,随机速度更快,抗震性能更强,单位体积容量密度更高。QLC SSD 适用于实时性要求更高的数据读取密集型应用,如 AI 计算、机器学习、金融数据实时分析和各种在线大数据信息挖掘等。与此同时,QLC 与 SLC、MLC、TLC 等闪存芯片相比,具有明显的成本优势。在海量数据的应用场景中,QLC SSD 的成本竞争力为其在数据中心领域赢得更大的应用场景。存储需求的快速增长带动 QLC SSD 等大容量、高密度的新型闪存芯片不断扩大应用范围,极大地节省数据中心面积,降低运营成本。
QLC 可以为 3D NAND 提供更大的存储空间,适用于读取量大的工作负载,而 TLC 的写入性能更强,出错率更低。相较于 2D NAND,3D NAND 具有更高密度,可以实现更高的写入和擦除次数。由于 QLC NAND SSD 每个单元可以存储 4 位数据,有 16 个电压状态,但 TLC NAND SSD 只能存储 3 位数据,拥有 8个电压状态。
在读取性能方面,与 QLC NAND 相比,由于 TLC 每单元的写入位数少,因此需要更长的擦除周期和程序时长,而 QLC NAND SSD 的顺序读取速度与 TLC SSD 一样快,更适用于读取量大的工作负载。
而在 3D NAND 写入性能方面,TLC SSD 要优于 QLC SSD,尽管这两种技术都采用纠错算法来保证数据的完整性,但该过程在基于 QLC 的驱动器上会花费更多的处理周期,严重影响写入性能。TLC 和 QLC 将呈现相辅相成、协同发展趋势。未来,50%的 NAND闪存将是 3D QLC,剩下的部分中绝大多数将由 3D TLC 完成。此外,QLC NAND将逐渐取代部分 TLC NAND,这两项技术将一起向前发展。
(2) SLC Cache 技术
SLC Cache 规避了高密度存储的减速问题,为 TLC SSD 或者 QLC SSD 提供写入性能、存储容量和成本优势。SLC Cache 本质是用 TLC、QLC 等颗粒模拟 SLC 的工作模式,将 TLC、QLC 等多个工作状态按照 SLC 的两个状态进行标记,只判断最高位状态,简化控制难度的同时,提高速度和耐久性。SLC Cache的加速策略包括动态容量和固定容量两个类型。动态容量的 SLC Cache 是指根据剩余容量来规划 SLC Cache,对于大容量 SSD 可以实现大缓存冗余,但是随着硬盘占用率提高,整体性能随之下降,这对主控智能化提出更高要求,但是随着 SSD 容量增长,动态掉速问题得到极大缓解。
(3) Xtacking 晶栈
Xtacking 晶栈创新 3D NAND 闪存技术,使得速度更快、密度更高、架构更灵活。Xtacking 晶栈可实现在 CMOS 外围电路晶圆、NAND 存储阵列晶圆两片独立的晶圆上分别加工外围电路和存储单元,当两片晶圆各自完工后,Xtacking技术只需一个处理步骤就可通过数十亿根垂直互联通道(VIA)将两片晶圆键合,合并为牢固的整体,让 NAND 获取更高的 I/O 接口速度及更多的操作功能。在传统 3DNAND 架构中,外围电路约占芯片面积 20%到 30%,Xtacking 晶栈技术将外围电路置于存储单元之上,从而实现比传统 3D NAND 更高的存储密度,芯片面积可减少约25%。模组化的工艺充分利用存储单元和外围电路的独立加工优势,提升研发效率,缩短生产周期,为引入 NAND 外围电路以实现闪存定制化提供可能。
Xtacking 晶栈 2.0 将充分利用架构优势,进一步提升 NAND 吞吐速率、提升系统级存储的综合性能、开启定制化 NAND 全新商业模式等。长江存储采用先进的 Xstacking 技术,在一个芯片里面把 NAND 存储单元和 IO 接口分开独立设计与加工,于 2020 年 4 月成功推出 128 层 TLC 和 QLC 两款产品,接口速度达到 1600MT/s。
Redis多线程架构的演进
Redis真的是单线程吗?网上有很多关于这个问题的讨论,得出的结论也几乎是一致的。本文在讨论这个问题之前,先定义好问题中“单线程”的概念边界:
- 1.单线程指的是“核心网络模型”
- 2.单线程指的是Redis整个服务端架构的设计
对于边界1,那么答案是肯定的,在Redis v6.0 版本以前,Redis的网络模型一直都是单线程模式的,即使到了v6.0版本,所有客户端命令的执行依然是在主线程上完成的;对于边界2,答案是否定的,Redis从发布之出就有两个BIO(background I/O service)
线程来异步处理aof持久化、关闭文件的任务,在Redis v4.0版本中又添加了一个BIO线程,将比较耗时的命令异步化,到了Redis v6.0,Redis核心的网络模型也被改造成了多线程。
在概念边界2的限制下,我们可以得出结论:Redis从来都不是单线程工作的!在Redis发布近十年来,在系统架构演进过程中都遇到了哪些问题?作者antirez对这些问题是怎样思考的?采取了什么样的方案来改进?探索这些问题对于开发者的成长很有价值,也是本文的写作目的,笔者会结合相关源码与读者共同解答这些问题。
1. Redis基础架构设计
性能优异的服务离不开好的架构设计,Redis使用 I/O multiplexing
实现了单线程接收海量客户端请求;通过单线程Reactor
模型实现了高性能的事件处理;基于条件变量实现的生产者消费者模型构建了自己的BIO
系统
。本节先简单介绍一下这些从Redis诞生以来就一直使用的基础架构设计。
1.1 Redis对 I/O multiplexing 的封装
I/O multiplexing 指的是多个网络socket I/O 复用同一个线程,它解决了C10K的问题。Redis将不同的 I/O 多路复用函数封装成相同的 API 提供给上层使用,仅仅以单线程处理网络I/O,就可以为成千上万的客户端提供服务。
Redis的I/O多路复用模块提供的API:
//下面的方法不同版本的redis在src目录下的ae_epoll.c、ae_evport.c、ae_kqueue.c、ae_select.c代码文件中都有实现static int aeApiCreate(aeEventLoop *eventLoop)static int aeApiResize(aeEventLoop *eventLoop, int setsize)static void aeApiFree(aeEventLoop *eventLoop)static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask)static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int mask)static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp)
Redis可以在多个平台上运行,所以会通过宏定义,根据编译平台的不同,选择不同的I/O多路复用函数作为子模块,提供给上层接口做封装。
/*下面代码在Redis不同版本的ae.c源码文件中均包含 *Include the best multiplexing layer supported by this system. * The following should be ordered by performances, descending. */#ifdef HAVE_EVPORT#include "ae_evport.c"#else #ifdef HAVE_EPOLL #include "ae_epoll.c" #else #ifdef HAVE_KQUEUE #include "ae_kqueue.c" #else #include "ae_select.c" #endif #endif#endif
Redis 会优先选择时间复杂度为 𝑂(1) 的 I/O 多路复用函数作为底层实现,包括 Solaries 10 中的 evport、Linux 中的 epoll 和 macOS/FreeBSD 中的 kqueue。select 函数是作为 POSIX 标准中的系统调用,在不同版本的操作系统上都会实现,所以将其作为保底方案。
1.2 单线程事件循环 —— Reactor网络模型
在服务端架构设计中,Reactor是一种基于事件驱动的设计模式。目前Linux平台上主流的高性能网络库/框架中,大都采用这种设计模式,比如 netty、libevent、libev、ACE,POE(Perl)、Twisted(Python)等。Reactor 模式本质上指的是使用 I/O
多路复用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O)
的模式。通常设置一个主线程负责做 event-loop 事件循环和 I/O 读写,通过 select/poll/epoll_wait 等系统调用监听 I/O 事件,业务逻辑提交给其他工作线程去做。而所谓『非阻塞 I/O』的核心思想是指避免阻塞在 read() 或者 write() 或者其他的 I/O 系统调用上,这样可以最大限度的复用 event-loop 线程,让一个线程能服务于多个 sockets。在 Reactor 模式中,I/O 线程只能阻塞在 I/O multiplexing 函数上(select/poll/epoll_wait)。单线程的Reactor网络模型是这样的:
Redis在 6.0 版本之前的单事件循环模型,实际上就是一个非常经典的Reactor模型,看下图:
Redis的单线程Reactor大体是这样工作的:“I/O 多路复用模块”会监听多个 FD ,当这些FD产生,accept,read,write 或 close 的文件事件。会向“文件事件分发器(dispatcher)”传送事件。文件事件分发器(dispatcher)在收到事件之后,会根据事件的类型将事件分发给对应的 handler。后边的章节我们会对这一过程再展开详述。
1.3 基于条件变量实现的生产者消费者模型
我们前边提到,Redis从发版之出就自带BIO线程,主要用来处理一些比较耗时的后台任务。redis使用的是一个基于条件变量实现生产者消费者模型来设计BIO系统。Redis使用C语言开发,我们以Linux C为例子,先来介绍一下锁与共享变量的使用方法,再总结一下这一模型的设计。
1.3.1 锁
多线程情况下, 锁的使用主要涉及以下5个函数, 它们都包含在pthread.h头文件中。
pthread_mutex_init(pthread_mutex_t mutex,const pthread_mutexattr_t attr)pthread_mutex_lock(pthread_mutex_t *mutex)pthread_mutex_trylock(pthread_mutex_t *mutex)pthread_mutex_unlock(pthread_mutex_t *mutex)pthread_mutex_destroy(pthread_mutex_t *mutex)
其中锁变量类型为pthread_mutex_t,锁的使用包含三个步骤:锁的初始化、加锁、以及释放锁。
1.3.2 锁初始化
pthread_mutex_init该函数用于锁的初始化,要使用锁,首先需要声明一个pthread_mutex_t变量,然后用该函数进行初始化,如下:
pthread_mutex_t mutex;pthread_mutex_init(&mutex,NULL);
初始化的时候,第二个参数可以用于设置锁的性质。经过这一步, 我们完成了锁的初始化,在第二个参数设置NULL的时候,一个线程加锁,另外一个线程再执行加锁操作就会阻塞,直到另外的线程释放锁。
1.3.3 加锁、释放锁与资源回收
pthread_mutex_lock与pthread_mutex_trylock这两个函数可以用于加锁。这两个函数都完成了加锁的功能,在获得了变量初始化后的mutex以后,直接调用函数即可完成加锁功能。其中第一个函数在另外一个线程已经获得锁的情况下会一直阻塞,而第二个函数则会直接返回,不会阻塞。pthread_mutex_unlock函数可以用于释放锁。pthread_mutex_destroy函数用于释放资源,在使用pthread_mutex_init函数进行锁初始化的情况下,使用结束以后,需要使用该函数释放资源。
1.3.4 共享变量
共享变量应用于这样一种场景:一个线程先对某一条件进行判断,如果条件不满足则进入等待,条件满足的时候,该线程被通知条件满足,继续执行任务。共享变量涉及的函数有如下6个:
- int pthread_cond_init(pthread_cond_t cond, pthread_condattr_t cond_attr)
- int pthread_cond_wait(pthread_cond_t cond, pthread_mutex_t mutex)
- int pthread_cond_signal(pthread_cond_t *cond)
- int pthread_cond_broadcast(pthread_cond_t *cond)
- int pthread_cond_timedwait(pthread_cond_t cond, pthread_mutex_t mutex, const struct timespec *abstime)
1.3.5 共享变量的初始化与条件等待
共享变量使用函数pthread_cond_init初始化,要使用条件变量,首先要声明一个pthread_cond_t变量,然后用该函数进行初始化。第二个参数使用NULL,可以进行具体的参数设置。初始化完成后,需要通过pthread_cond_wait或pthread_cond_timedwait等待条件成立;在条件不满足的时候,函数进入等待;当条件满足的时候,该函数会停止等待,继续执行。该函数的第二个参数是pthread_mutex_t类型,这是因为在条件判断的时候,需要先进行加锁来防止出现错误,在执行改函数前需要主动对这个变量执行加锁操作,进入这个函数以后,其内部会对mutex进行解锁操作;而函数执行完以后(也就是停止阻塞以后),又会重新加锁,具体原因在介绍完本组函数以后进行说明,其中第二个函数可以指定等待的时间,而不是一直在阻塞。
1.3.6 条件满足通知
上面说到,在条件不满足的时候,一个线程会调用pthread_cond_wait函数阻塞等待;而此时如果其他线程检查到条件满足,则可以调用pthread_cond_signal、pthread_cond_broadcast,让处于等待状态的线程重新开始执行。当有多个线程在等待的时候,则可以调用pthread_cond_signal函数会唤醒其中一个线程,而pthread_cond_broadcast函数会唤醒所有的等待的线程。
1.3.7 实现生产者消费者模型的正确方法
上边我们介绍完了锁与共享变量的基本函数,接下来介绍这两套函数配合使用的一个常见场景:有两个线程,其中一个线程先对一个条件进行检查,这个检查动作需要先加锁。如果条件成立则执行操作,否则阻塞等待,直到条件成立这个线程才会被通知继续执行;另一个线程先做加锁处理,然后置条件为真,并通知其他等待的线程条件已经满足,可以继续执行。上面说在检查共享变量的时候要加锁,其原因通过以下伪代码来说明。第一种情况:
线程1
pthread_mutex_lock(&mutex); while (condition == FALSE) { pthread_cond_wait(&cond, &mutex); }pthread_mutex_unlock(&mutex);线程2condition = TRUE;pthread_cond_signal(&cond);
可以看到线程1先检查一个条件是否成立,在不成立的情况下就调用wait函数进行等待,而在这之前,先对这步过程进行了加锁操作。线程2则是把条件设置为true(假设其通过某种方式知道了这个时候该条件应当为true),然后用pthread_cond_signal函数通知线程1停止阻塞继续执行。上面的程序在多个线程并发执行的时候有如下的问题:如果线程1先判断,发现条件不满足,准备进入等待,在这个时候线程2中条件被置为真,且发送通知,然后线程1才阻塞等待,这样的话线程1错过了一次通知,导致其在条件满足的情况下依然在阻塞等待。
线程1 线程2pthread_mutex_lock(&mutex);while (condition == FALSE) condition = TRUE; pthread_cond_signal(&cond);pthread_cond_wait(&cond, &mutex);
为了解决上面说的问题,对程序进行了如下的改进。通过线程2的加锁操作,避免了这样的问题。这也解释了为什么pthread_cond_wait函数在进入以后要进行解锁操作,如果起不解锁,那么线程2在进行条件置为true的操作就没有办法执行,因为线程1在进入等待之前已经对这个变量加锁了,这样线程1会一直等待,而线程2也会等待,导致死锁。
线程1 线程2pthread_mutex_lock(&mutex); pthread_mutex_lock(&mutex);while (condition == FALSE) { condition = TRUE; pthread_cond_wait(&cond, &mutex); pthread_cond_signal(&cond);} pthread_mutex_unlock(&mutex);pthread_mutex_unlock(&mutex);
因为wait重新执行的时候需要再次加锁,所以上面的pthread_cond_signal调用以后,必须释放锁才能够完成wait。另外,也可以先解锁,然后调用pthread_cond_signal,这两种写法都是正确的。虽然共享变量的访问一般需要加锁,但在这个场景下不加锁造成的竞争不会产生错误,只是会造成线程调度效率上的问题,所以也可以这么写,但是一般推荐标准的写法。对于条件变量,其基本的使用场景是,某些线程对条件进行判断,如果不满足条件,就进入等待状态。在进行条件判断之前,先进行加锁操作,另外一些线程则是负责对条件赋值为真,然后通知等待的线程继续执行,线程被唤醒后,继续进入判断的环节以及后续的操作。总结一下本节介绍的基于条件变量实现的生产者消费者模型:A类线程依次执行加锁、检查(条件不成立则等待,知道成立再次进入检查阶段)、执行、解锁;同时B类线程依次执行加锁、条件置为真、通知、解锁。
2. Redis核心网络模型架构的演进
从Redis的发展历史我们可以看出,其核心网络模型经历了从单线程到多线程的演进。那么Redis单线程的I/O模型是怎样实现的?单线程存在什么问题?Redis的多线程的I/O又是怎样实现的?为什么要这么做? 本节我们就来探索这些问题。
2.1 单线程I/O的工作流程
我们前边有说到,Redis在 6.0 版本以前,其核心的网络模型一直是单线程Reactor模型,利用 epoll/select/kqueue 等多路复用技术,在单线程的事件循环中不断去处理客户端请求,最后回写响应数据到客户端:
我们先来学习一下redis网络模型中的一些核心概念:
client
:客户端对象,Redis 是典型的 CS 架构(Client <---> Server),客户端通过 socket 与服务端建立网络通道然后发送请求命令,服务端执行请求的命令并回复。Redis 使用结构体 client 存储客户端的所有相关信息,包括但不限于封装的套接字连接 *conn,当前选择的数据库指针 *db,读入缓冲区 querybuf,写出缓冲区 buf,写出数据链表 reply等。aeApiPoll
:这个我们前边提到过,I/O 多路复用 API,是基于 epoll_wait/select/kevent 等系统调用的封装,监听等待读写事件触发,然后处理,它是事件循环(Event Loop)中的核心函数,是事件驱动得以运行的基础。acceptTcpHandler
:连接应答处理器,底层使用系统调用 accept 接受来自客户端的新连接,并为新连接注册绑定命令读取处理器,以备后续处理新的客户端 TCP 连接;除了这个处理器,还有对应的 acceptUnixHandler 负责处理 Unix Domain Socket 以及 acceptTLSHandler 负责处理 TLS 加密连接。readQueryFromClient
:命令读取处理器,解析并执行客户端的请求命令。beforeSleep
:事件循环中进入 aeApiPoll 等待事件到来之前会执行的函数,其中包含一些日常的任务,比如把 client->buf 或者 client->reply (后面会解释为什么这里需要两个缓冲区)中的响应写回到客户端,持久化 AOF 缓冲区的数据到磁盘等,相对应的还有一个 afterSleep 函数,在 aeApiPoll 之后执行。sendReplyToClient
:命令回复处理器,当一次事件循环之后写出缓冲区中还有数据残留,则这个处理器会被注册绑定到相应的连接上,等连接触发写就绪事件时,它会将写出缓冲区剩余的数据回写到客户端。
/* client对象在redis不同版本的redis.h中均有定义 * With multiplexing we need to take per-client state. * Clients are taken in a linked list. */typedef struct client { uint64_t id; /* Client incremental unique ID. */ int fd; /* Client socket. */ redisDb *db; /* Pointer to currently SELECTed DB. */ robj *name; /* As set by CLIENT SETNAME. */ sds querybuf; /* Buffer we use to accumulate client queries. */ size_t qb_pos; /* The position we have read in querybuf. */ sds pending_querybuf; /* If this client is flagged as master, this buffer represents the yet not applied portion of the replication stream that we are receiving from the master. */ size_t querybuf_peak; /* Recent (100ms or more) peak of querybuf size. */ int argc; /* Num of arguments of current command. */ robj **argv; /* Arguments of current command. */ struct redisCommand *cmd, *lastcmd; /* Last command executed. */ ...... //此处省略很多其它的属性
/* Response buffer */ int bufpos; char buf[PROTO_REPLY_CHUNK_BYTES];} client;
然后,笔者再描述一下Redis服务端的工作流程:
- Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接到来;
- 客户端和服务端建立网络连接;
- acceptTcpHandler 被调用,主线程使用 AE 的 API 将 readQueryFromClient 命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个 client 绑定这个客户端连接;
- 客户端发送请求命令,触发读就绪事件,主线程调用 readQueryFromClient 通过 socket 读取客户端发送过来的命令存入 client->querybuf 读入缓冲区;
- 接着调用 processInputBuffer,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
- 根据请求命令的类型(SET, GET, DEL, EXEC 等),分配相应的命令执行器去执行,最后调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->reply ,client->buf 是首选的写出缓冲区,固定大小 16KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到 client->reply 链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write;
- 在事件循环(Event Loop)中,主线程执行 beforeSleep --> handleClientsWithPendingWrites,遍历 clients_pending_write 队列,调用 writeToClient 把 client 的写出缓冲区里的数据回写到客户端,如果写出缓冲区还有数据遗留,则注册 sendReplyToClient 命令回复处理器到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。
笔者整理了一张更详细的redis单线程I/O的工作流程图,感兴趣的读者可以结合上边的描述过程看一下。
2.2 单线程存在什么问题?
我们都知道单线程的程序是无法利用服务器的多核CPU的,那么早期的Redis为什么还要使用单线程呢?我们不妨先看一下Redis
官方给出的FAQ核心意思是:CPU并不是制约Redis
性能表现的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制,所以Redis核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核CPU,可以在一台服务器上启动多个实例或者采用分片集群的方式。通过上一节对Redis单线程Reactor模型的分析,我们知道Redis的I/O线程除了在等待事件,其它的事件都是非阻塞的,没有浪费任何的CPU时间,这也是Redis能够提供高性能服务的原因。不过除了我们上边说的只能使用一个CPU核心外,这个模型还有两个缺陷:
- 在value比较大的情况下会阻塞Redis服务
- QPS难以更上一层楼
redis主线程的时间主要消耗在两个地方:逻辑计算的消耗(CPU计算)和由于同步IO(I/O multiplexing)的读写,内核态和用户态相互拷贝数据。当value很大时,Redis的瓶颈会首先出现在同步IO这里,如果能有多个线程来分担这部分压力,那Redis的QPS还能有大幅度的提升,这就是Redis多线程网络模型的实现思路。
2.3 Redis多线程网络模型的实现
Redis在 6.0 版本之后正式在核心网络模型中引入了多线程,前边我们提到Redis在 6.0 版本之前,使用的是经典的单线程Reactor模型,通常来说,单线程的Reactor架构模型在引入了多线程之后会进化为 Multi-Reactors模式,它的工作模式是这样的:
区别于单 Reactor 模式,这种模式不再是单线程的事件循环,而是有多个线程(Sub Reactors)各自维护一个独立的事件循环,由 Main Reactor 负责接收新连接并分发给 Sub Reactors 去独立处理,最后 Sub Reactors 回写响应给客户端。Multiple Reactors 模式通常也可以等同于 Master-Workers 模式,比如 Nginx 和 Memcached 等就是采用这种多线程模型,虽然不同的项目实现细节略有区别,但总体来说模式是一致的。
2.3.1 多线程网络模型的工作流程
Redis 虽然也实现了多线程,但是却不是标准的 Multi-Reactors/Master-Workers 模式,我们先看一下 Redis 多线程网络模型的总体设计:
- Redis 服务器启动,开启主线程
事件循环(Event Loop),注册 acceptTcpHandler 连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接到来; - 客户端和服务端建立网络连接;
- acceptTcpHandler 被调用,主线程使用 AE 的 API 将 readQueryFromClient 命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个 client 绑定这个客户端连接;
- 客户端发送请求命令,触发读就绪事件,服务端主线程不会通过 socket 去读取客户端的请求命令,而是先将 client 放入一个 LIFO 队列 clients_pending_read;
- 在事件循环(Event Loop)中,主线程执行 beforeSleep -->handleClientsWithPendingReadsUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_read队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程自己,I/O 线程通过 socket 读取客户端的请求命令,存入 client->querybuf 并解析第一个命令,但不执行命令,主线程忙轮询,等待所有 I/O 线程完成读取任务;
- 主线程和所有 I/O 线程都完成了读取任务,主线程结束忙轮询,遍历 clients_pending_read 队列,执行所有客户端连接的请求命令,先调用 processCommandAndResetClient 执行第一条已经解析好的命令,然后调用 processInputBuffer 解析并执行客户端连接的所有命令,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
- 根据请求命令的类型(SET, GET, DEL, EXEC 等),分配相应的命令执行器去执行,最后调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->reply ,client->buf 是首选的写出缓冲区,固定大小 16KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到 client->reply 链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write;
- 在事件循环(Event Loop)中,主线程执行 beforeSleep --> handleClientsWithPendingWritesUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_write 队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程自己,I/O 线程通过调用 writeToClient 把 client 的写出缓冲区里的数据回写到客户端,主线程忙轮询,等待所有 I/O 线程完成写出任务;
- 主线程和所有 I/O 线程都完成了写出任务, 主线程结束忙轮询,遍历 clients_pending_write 队列,如果 client 的写出缓冲区还有数据遗留,则注册 sendReplyToClient 到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。
这里大部分逻辑和之前的单线程模型是一致的,变动的地方仅仅是把读取客户端请求命令和回写响应数据的逻辑异步化了,交给 I/O 线程去完成,这里需要特别注意的一点是:I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令,客户端命令的执行最终还是要回到主线程上完成。笔者也同样整理了一张更详细的redis多线程I/O的工作流程图,感兴趣的读者可以结合上边的描述过程看一下。
2.3.2 多线程I/O源码剖析
接下里,笔者以 Redis v6.0.1版本,对多线程I/O实现中比较重要的源码进行分析。先来看多线程的初始化:
/* Initialize the data structures needed for threaded I/O. */void initThreadedIO(void) { io_threads_active = 0; /* We start with threads not active. */
//如果只配置了一个线程,则所有的I/O放到主线程上执行 if (server.io_threads_num == 1) return;
//最多配置的线程数量不超过128 if (server.io_threads_num > IO_THREADS_MAX_NUM) { serverLog(LL_WARNING,"Fatal: too many I/O threads configured. " "The maximum number is %d.", IO_THREADS_MAX_NUM); exit(1); }
//启动线程,线程数量为配置中的线程数 for (int i = 0; i < server.io_threads_num; i++) { //创建I/O线程的本地任务队列 io_threads_list[i] = listCreate(); if (i == 0) continue; //0号线程是主线程
//初始化 I/O 线程并启动。 pthread_t tid; // 每个 I/O 线程会分配一个本地锁,用来休眠和唤醒线程。 pthread_mutex_init(&io_threads_mutex[i],NULL); // 每个 I/O 线程分配一个原子计数器,用来记录当前遗留的任务数量。 io_threads_pending[i] = 0; // 主线程在启动 I/O 线程的时候会默认先锁住它,直到有 I/O 任务才唤醒它。 pthread_mutex_lock(&io_threads_mutex[i]); // 启动线程,进入 I/O 线程的主逻辑函数 IOThreadMain if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) { serverLog(LL_WARNING,"Fatal: Can't initialize IO thread."); exit(1); } io_threads[i] = tid; }}
initThreadedIO
会在Redis服务器启动的末尾被调用,进行I/O多线程的初始化工作。Redis的多线程模式是关闭的,需要用户在 redis.conf 配置文件中开启并设置:
io-threads 2io-threads-do-reads yes
注意通过配置io-theads设置的I/O线程数量也包括主线程在内,io-threads-do-reads标识I/O线程是否参与读I/O的处理,提供这个配置的原因是因为,读I/O的并行处理对于Redis的性能提升并不明显。我们再来看读取请求的源代码。当客户端发送请求命令之后,会触发 Redis 主线程的事件循环,命令处理器 readQueryFromClient 被回调,在以前的单线程模型下,这个方法会直接读取解析客户端命令并执行,但是多线程模式下,则会把 client 加入到 clients_pending_read 任务队列中去,后面主线程再分配到 I/O 线程去读取客户端请求命令:
void readQueryFromClient(connection *conn) { client *c = connGetPrivateData(conn); int nread, readlen; size_t qblen;
//如果开启了多线程,将client加入到异步队列之后返回 if (postponeClientRead(c)) return;
...... //省略代码,逻辑同单线程版本几乎一样}
/* Return 1 if we want to handle the client read later using threaded I/O. * This is called by the readable handler of the event loop. * As a side effect of calling this function the client is put in the * pending read clients and flagged as such. */int postponeClientRead(client *c) { //当多线程 I/O 模式开启、主线程没有在处理阻塞任务时,将 client 加入异步队列。 if (io_threads_active && server.io_threads_do_reads && !ProcessingEventsWhileBlocked && !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ))) { // 给 client 打上 CLIENT_PENDING_READ 标识,表示该 client 需要被多线程处理, // 后续在 I/O 线程中会在读取和解析完客户端命令之后判断该标识并放弃执行命令,让主线程去执行。 c->flags |= CLIENT_PENDING_READ; listAddNodeHead(server.clients_pending_read,c); return 1; } else { return 0; }}
主线程会在事件循环的 beforeSleep() 方法中,调用 handleClientsWithPendingReadsUsingThreads:
/* When threaded I/O is also enabled for the reading + parsing side, the * readable handler will just put normal clients into a queue of clients to * process (instead of serving them synchronously). This function runs * the queue using the I/O threads, and process them in order to accumulate * the reads in the buffers, and also parse the first command available * rendering it in the client structures. */int handleClientsWithPendingReadsUsingThreads(void) { if (!io_threads_active || !server.io_threads_do_reads) return 0; int processed = listLength(server.clients_pending_read); if (processed == 0) return 0;
if (tio_debug) printf("%d TOTAL READ pending clients\n", processed);
// 遍历待读取的 client 队列 clients_pending_read, // 通过 RR 轮询均匀地分配给 I/O 线程和主线程自己(编号 0)。 listIter li; listNode *ln; listRewind(server.clients_pending_read,&li); int item_id = 0; while((ln = listNext(&li))) { client *c = listNodeValue(ln); int target_id = item_id % server.io_threads_num; listAddNodeTail(io_threads_list[target_id],c); item_id++; }
// 设置当前 I/O 操作为读取操作,给每个 I/O 线程的计数器设置分配的任务数量, // 让 I/O 线程可以开始工作:只读取和解析命令,不执行。 io_threads_op = IO_THREADS_OP_READ; for (int j = 1; j < server.io_threads_num; j++) { int count = listLength(io_threads_list[j]); io_threads_pending[j] = count; }
// 主线程自己也会去执行读取客户端请求命令的任务,以达到最大限度利用 CPU。 listRewind(io_threads_list[0],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); readQueryFromClient(c->conn); } listEmpty(io_threads_list[0]);
// 忙轮询,累加所有 I/O 线程的原子任务计数器,直到所有计数器的遗留任务数量都是 0, // 表示所有任务都已经执行完成,结束轮询。 while(1) { unsigned long pending = 0; for (int j = 1; j < server.io_threads_num; j++) pending += io_threads_pending[j]; if (pending == 0) break; } if (tio_debug) printf("I/O READ All threads finshed\n");
// 遍历待读取的 client 队列,清除 CLIENT_PENDING_READ 和 CLIENT_PENDING_COMMAND 标记, // 然后解析并执行所有 client 的命令。 while(listLength(server.clients_pending_read)) { ln = listFirst(server.clients_pending_read); client *c = listNodeValue(ln); c->flags &= ~CLIENT_PENDING_READ; listDelNode(server.clients_pending_read,ln);
if (c->flags & CLIENT_PENDING_COMMAND) { c->flags &= ~CLIENT_PENDING_COMMAND; // client 的第一条命令已经被解析好了,直接尝试执行。 if (processCommandAndResetClient(c) == C_ERR) { /* If the client is no longer valid, we avoid * processing the client later. So we just go * to the next. */ continue; } } // 继续解析并执行 client 命令。 processInputBuffer(c); } return processed;}
这里主线程所做的核心工作就是:
- 遍历待读取的 client 队列 clients_pending_read,通过 RR 策略把所有任务分配给 I/O 线程和主线程去读取和解析客户端命令。
- 忙轮询等待所有 I/O 线程完成任务。
- 等主线程和其它I/O吓成一起完成了读请求处理以后,最后再遍历 clients_pending_read,执行所有 client 的命令。
与读取相求相对应的自然是写回响应了,完成命令的读取、解析以及执行之后,客户端命令的响应数据已经存入 client->buf 或者 client->reply 中了,接下来就需要把响应数据回写到客户端了,还是在 beforeSleep 中, 主线程调用 handleClientsWithPendingWritesUsingThreads:
int handleClientsWithPendingWritesUsingThreads(void) { int processed = listLength(server.clients_pending_write); if (processed == 0) return 0; /* Return ASAP if there are no clients. */
// 如果用户设置的 I/O 线程数等于 1 或者当前 clients_pending_write 队列中待写出的 client // 数量不足 I/O 线程数的两倍,则不用多线程的逻辑,让所有 I/O 线程进入休眠, // 直接在主线程把所有 client 的相应数据回写到客户端。 if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) { return handleClientsWithPendingWrites(); }
// 唤醒正在休眠的 I/O 线程(如果有的话)。 if (!io_threads_active) startThreadedIO();
if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed);
// 遍历待写出的 client 队列 clients_pending_write, // 通过 RR 轮询均匀地分配给 I/O 线程和主线程自己(0号线程)。 listIter li; listNode *ln; listRewind(server.clients_pending_write,&li); int item_id = 0; while((ln = listNext(&li))) { client *c = listNodeValue(ln); c->flags &= ~CLIENT_PENDING_WRITE; int target_id = item_id % server.io_threads_num; listAddNodeTail(io_threads_list[target_id],c); item_id++; }
// 设置当前 I/O 操作为写出操作,给每个 I/O 线程的计数器设置分配的任务数量, // 让 I/O 线程可以开始工作,把写出缓冲区(client->buf 或 c->reply)中的响应数据回写到客户端。 io_threads_op = IO_THREADS_OP_WRITE; for (int j = 1; j < server.io_threads_num; j++) { int count = listLength(io_threads_list[j]); io_threads_pending[j] = count; }
// 主线程自己也会去执行读取客户端请求命令的任务,以达到最大限度利用 CPU。 listRewind(io_threads_list[0],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); writeToClient(c,0); } listEmpty(io_threads_list[0]);
// 忙轮询,累加所有 I/O 线程的原子任务计数器,直到所有计数器的遗留任务数量都是 0。 // 表示所有任务都已经执行完成,结束轮询。 while(1) { unsigned long pending = 0; for (int j = 1; j < server.io_threads_num; j++) pending += io_threads_pending[j]; if (pending == 0) break; } if (tio_debug) printf("I/O WRITE All threads finshed\n");
// 最后再遍历一次 clients_pending_write 队列,检查是否还有 client 的中写出缓冲区中有残留数据, // 如果有,那就为 client 注册一个命令回复器 sendReplyToClient,等待客户端写就绪再继续把数据回写。 listRewind(server.clients_pending_write,&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln);
// 检查 client 的写出缓冲区是否还有遗留数据。 if (clientHasPendingReplies(c) && connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR) { freeClientAsync(c); } } listEmpty(server.clients_pending_write); return processed;}
与读取请求相似,主线程处理写回响应的核心工作是:
- 检查当前任务负载,如果当前的任务数量不足以用多线程模式处理的话,则休眠 I/O 线程并且直接同步将响应数据回写到客户端。
- 唤醒正在休眠的 I/O 线程(如果有的话)。
- 遍历待写出的 client 队列 clients_pending_write,通过 RR 策略把所有任务分配给 I/O 线程和主线程去将响应数据写回到客户端。
- 忙轮询等待所有 I/O 线程完成任务。
- 最后再遍历 clients_pending_write,为那些还残留有响应数据的 client 注册命令回复处理器 sendReplyToClient,等待客户端可写之后在事件循环中继续回写残余的响应数据。
上边我们分析了主线程的工作,下边我们再来看一下I/O线程的主逻辑都做了些什么:
void *IOThreadMain(void *myid) { /* The ID is the thread number (from 0 to server.iothreads_num-1), and is * used by the thread to just manipulate a single sub-array of clients. */ long id = (unsigned long)myid; char thdname[16];
snprintf(thdname, sizeof(thdname), "io_thd_%ld", id); redis_set_thread_title(thdname);
while(1) { // 忙轮询,100w 次循环,等待主线程分配 I/O 任务。 for (int j = 0; j < 1000000; j++) { if (io_threads_pending[id] != 0) break; }
// 如果 100w 次忙轮询之后如果还是没有任务分配给它,则通过尝试加锁进入休眠, // 等待主线程分配任务之后调用 startThreadedIO 解锁,唤醒 I/O 线程去执行。 if (io_threads_pending[id] == 0) { pthread_mutex_lock(&io_threads_mutex[id]); pthread_mutex_unlock(&io_threads_mutex[id]); continue; }
serverAssert(io_threads_pending[id] != 0);
if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id]));
// 注意:主线程分配任务给 I/O 线程之时, // 会把任务加入每个线程的本地任务队列 io_threads_list[id], // 但是当 I/O 线程开始执行任务之后,主线程就不会再去访问这些任务队列,避免数据竞争。 listIter li; listNode *ln; listRewind(io_threads_list[id],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); // 如果当前是写出操作,则把 client 的写出缓冲区中的数据回写到客户端。 if (io_threads_op == IO_THREADS_OP_WRITE) { writeToClient(c,0); // 如果当前是读取操作,则socket 读取客户端的请求命令并解析第一条命令。 } else if (io_threads_op == IO_THREADS_OP_READ) { readQueryFromClient(c->conn); } else { serverPanic("io_threads_op value is unknown"); } } listEmpty(io_threads_list[id]); // 所有任务执行完之后把自己的计数器置 0,主线程通过累加所有 I/O 线程的计数器 // 判断是否所有 I/O 线程都已经完成工作。 io_threads_pending[id] = 0;
if (tio_debug) printf("[%ld] Done\n", id); }}
I/O 线程启动之后,会先进入忙轮询,判断原子计数器中的任务数量,如果是非 0 则表示主线程已经给它分配了任务,开始执行任务,否则就一直忙轮询一百万次等待,忙轮询结束之后再查看计数器,如果还是 0,则尝试加本地锁,因为主线程在启动 I/O 线程之时就已经提前锁住了所有 I/O 线程的本地锁,因此 I/O 线程会进行休眠,等待主线程唤醒。主线程会在每次事件循环中尝试调用 startThreadedIO 唤醒 I/O 线程去执行任务,如果接收到客户端请求命令,则 I/O 线程会被唤醒开始工作,根据主线程设置的 io_threads_op 标识去执行命令读取和解析或者回写响应数据的任务,I/O 线程在收到主线程通知之后,会遍历自己的本地任务队列 io_threads_list[id],取出一个个 client 执行任务:
- 如果当前是写出操作,则调用 writeToClient,通过 socket 把 client->buf 或者 client->reply 里的响应数据回写到客户端。
- 如果当前是读取操作,则调用 readQueryFromClient,通过 socket 读取客户端命令,存入 client->querybuf,然后调用 processInputBuffer 去解析命令,这里最终只会解析到第一条命令,然后就结束,不会去执行命令。
- 在全部任务执行完之后把自己的原子计数器置 0,以告知主线程自己已经完成了工作。
至此,我们就分析完了多线程I/O工作的核心源码。
2.3.3 I/O多线程lock-free的设计亮点
细心的读者可能会发现,Redis的多线程I/O设计是无锁化的,lock-free一直是多线程设计中的一个特色,Redis是通过原子操作+交错访问来实现的,主线程和 I/O 线程之间共享的变量有三个:io_threads_pending 计数器、io_threads_op I/O 标识符和 io_threads_list 线程本地任务队列。io_threads_pending 是原子变量,不需要加锁保护,io_threads_op 和 io_threads_list 这两个变量则是通过控制主线程和 I/O 线程交错访问来规避共享数据竞争问题:I/O 线程启动之后会通过忙轮询和锁休眠等待主线程的信号,在这之前它不会去访问自己的本地任务队列 io_threads_list[id],而主线程会在分配完所有任务到各个 I/O 线程的本地队列之后才去唤醒 I/O 线程开始工作,并且主线程之后在 I/O 线程运行期间只会访问自己的本地任务队列 io_threads_list[0] 而不会再去访问 I/O 线程的本地队列,这也就保证了主线程永远会在 I/O 线程之前访问 io_threads_list 并且之后不再访问,保证了交错访问。io_threads_op 同理,主线程会在唤醒 I/O 线程之前先设置好 io_threads_op 的值,并且在 I/O 线程运行期间不会再去访问这个变量。
2.3.4 多线程I/O的性能提升与设计缺陷
事实上,Redis
通过多线程来提升性能达到的效果可能比你想象中的还要好,下边是从ITNEXT平台提取的一张压测结果报告图,更详细的内容可参考:对实验性Redis多线程I / O进行基准测试
可以看到,在配置24个IO-threads时,执行普通的GET、SET指令,Redis
能够达到20W+的QPS,相比较于单线程,性能提升了将近一倍。我们前边提到,Redis的多线程网络模型为了保持与旧版本的兼容性,并没有使用标准的Multi-Reactors/Master-Workers 模型。客户端命令的执行还是在主线程上完成的。并且主线程和I/O线程的通信也是通过忙轮询和锁粗暴解决的,这会导致Redis在服务期间有CPU的空转问题,后边我们研究BIO系统的实现之后,会发现BIO线程与主线程之间的通信,相比较与Redis的核心多线程网络模型中通信的实现,要优雅很多。相信之后Redis的作者会对目前的方案进行改进。
3. Redis BIO系统的演进
本文一开始就给出了结论:对于整个服务端架构的设计而言,Redis从来都不是单线程的。我们讨论的单线程,只是针对Redis的核心网络模型而言,这部分的内容我们在上一节已经深入剖析过了。对于Redis的BIO系统,也就是这些所谓的后台线程,也经历了一个演进的过程,在这个过程中都发生了哪些事?Redis的作者antirez对BIO系统做了哪些优化呢?本节我们就来一起探索下这两个问题。
3.1 早期的BIO系统设计与实现
早期的Redis通过bio系统完成两件事,一是进行Aof持久化,也就是将写入到系统的page cache的数据fsync到磁盘中;二是关闭文件。为了完成这件任务,其采用了任务队列的方式,每个任务都是一个线程来完成,任务会被放到任务队列中,然后由执行任务线程取走,如果队列空,则阻塞等待,如果队列里有任务,就通知工作线程,这通过条件变量来实现。后面以任务初始化,任务放入队列,任务出队列三个方面进行介绍,并且以aof持久化为例说明其在系统中的使用方式,本节的内容与代码分析基于redis的 3.2.3 版本。
3.1.1 任务初始化
对于一个任务,比如aof持久化任务,首先要初始化一个队列,在redis里面使用了redis自己的链表结构建立这个队列。这个队列需要满足以下特点:
- 生产者放任务到队列中。
- 如果队列不为空,消费者从队列中取任务;否则消费者进入等待状态。
这里的消费者就是服务线程,而为了完成队列为空则等待的功能,redis使用了条件变量机制。其初始化代码如下。
static pthread_t bio_threads[BIO_NUM_OPS];static pthread_mutex_t bio_mutex[BIO_NUM_OPS];static pthread_cond_t bio_condvar[BIO_NUM_OPS];static list *bio_jobs[BIO_NUM_OPS];
上面的常量BIO_NUM_OPS = 2,表示支持两种任务。对于每种任务,对应一个list用于放置任务,一个pthread_cond_t和pthread_mutex_t变量用于并发控制,以及一个pthread_t 用于后台服务线程。初始化使用了bioInit函数,部分代码如下:
for (j = 0; j < BIO_NUM_OPS; j++) { pthread_mutex_init(&bio_mutex[j],NULL); pthread_cond_init(&bio_condvar[j],NULL); bio_jobs[j] = listCreate(); bio_pending[j] = 0;}//初始化锁与条件变量............for (j = 0; j < BIO_NUM_OPS; j++) { void *arg = (void*)(unsigned long) j; //这里的函数参数是arg = j,也就是每个线程传入一个编号j,0代表关闭文件,1代表aof初始化 if (pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg) != 0) { serverLog(LL_WARNING,"Fatal: Can't initialize Background Jobs."); exit(1); } bio_threads[j] = thread; }//初始化线程
在完成初始化任务以后,我们有了BIO_NUM_OPS(其值为2)个链表表示任务队列,有两个线程调用bioProcessBackgroundJobs函数,参数是一个编号j,并且每个队列都初始化了锁与条件变量做并发控制。
3.1.2 任务入队列
任务入队列就是把一个任务放到链表的头部,并且把相应任务的pending值+1,表示这个队列里面未完成的任务多了一个。其中任务的结构如下:
struct bio_job { time_t time; void *arg1, *arg2, *arg3;};
可以看到,任务不是很复杂,只记录一个时间和参数就可以了,后面讲任务执行的时候,会讲到这样一个简单的结构记录的任务怎么执行。任务入队列的代码如下:
void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3) { struct bio_job *job = zmalloc(sizeof(*job)); job->arg1 = arg1; ... pthread_mutex_lock(&bio_mutex[type]); listAddNodeTail(bio_jobs[type],job); bio_pending[type]++; pthread_cond_signal(&bio_condvar[type]); pthread_mutex_unlock(&bio_mutex[type]);}
这段入队列的代码先为任务结构分配空间,然后使用listAddNodeTail函数把任务放到链表的头部。这里使用的是redis自己实现的链表。可以看到,进行链表操作的时候,要先加锁,这是因为这里的链表是共享资源。在任务成功加入队列以后,调用pthread_cond_signal函数,通知阻塞等待的线程继续执行。上面这个过程是共享变量使用的基本模式:加锁、置条件为真(这里是任务入队列)、通知、解锁。
3.1.3 任务出队列
我们已经做好了任务初始化的工作,并且可以在队列里面放置新的任务,那么当队列里面有任务的时候,我们在第一步初始化的时候开启的后台线程就会调用bioProcessBackgroundJobs函数进行任务处理,其处理主要代码如下:
void *bioProcessBackgroundJobs(void *arg) { unsigned long type = (unsigned long) arg; struct bio_job *job; while(1) { listNode *ln; pthread_mutex_lock(&bio_mutex[type]); if (listLength(bio_jobs[type]) == 0) { //条件不成立,等待 pthread_cond_wait(&bio_condvar[type],&bio_mutex[type]); //被通知以后,停止阻塞,重新判断条件 continue; } //条件成立,直接执行 ln = listFirst(bio_jobs[type]); job = ln->value; //取走值以后,解锁 pthread_mutex_unlock(&bio_mutex[type]); //完成队列处理以后,根据类型调用close函数或者fsync函数。 if (type == BIO_CLOSE_FILE) { close((long)job->arg1); } else if (type == BIO_AOF_FSYNC) { fsync((long)job->arg1); } else { serverPanic("Wrong job type in bioProcessBackgroundJobs()."); } pthread_mutex_lock(&bio_mutex[type]); listDelNode(bio_jobs[type],ln); bio_pending[type]--; }}
上面的代码主要流程是,先判断当前的队列是不是空的,如果是空的,则等待。否则,从队列中取出一个job结构,并且根据线程的类型决定调用什么函数。这里的类型通过创建线程是传如的参数获得,可以是0 或者 1。获得类型以后,从job里面取出arg1作为参数,调用close函数或者fsync函数。arg1是一个文件描述符,所以,在任务加入队列的时候,只是需要放一个文件描述符如队列,这也就是为什么bio_job结构体会设置得如此简单。
3.1.4 Aof持久化的例子
Aof 持久化是redis的两大持久化方式之一,其会以字符串的形式把对redis的每一个操作都先记录在内存的一个buffer中,然后写入文件,并且在适当的时间使用fsync将数据刷入磁盘,而调用fsync的其中一种方式就是使用上面介绍的bio系统,其使用的方式遵循了上面说的三个步骤。首先,在server.c中的main函数里面,有一个initServer函数,其内部调用了bioInit函数,完成了bio系统的初始化,这样,相关的队列结构被建立,后台线程也被创建了。在redis主循环被启动以后,会进入持久化的时机,调用flushAppendOnlyFile函数,完成aof持久化工作。这个函数会处理aof相关的配置以及优化等各类问题,在本文只关注对bio系统的使用,其相关代码如下:
if (server.aof_fsync == AOF_FSYNC_EVERYSEC) sync_in_progress = bioPendingJobsOfType(BIO_AOF_FSYNC) != 0;............if (!sync_in_progress) aof_background_fsync(server.aof_fd);
void aof_background_fsync(int fd) { bioCreateBackgroundJob(BIO_AOF_FSYNC,(void*)(long)fd,NULL,NULL);}
可以看到,其通过bioPendingJobsOfType来检查当前队列处理的情况,并且调用bioCreateBackgroundJob来将aof任务加入队列。由于在前面已经完成了线程的创建,在队列中有任务的时候,线程就会启动,并且通过上面讲的fsync函数完成持久化操作。Redis的Bio是一个非常好的在实际系统中使条件变量的例子。
3.2 令人头痛的问题:大key应该如何删除?
我们知道Redis的DEL命令用来删除一个或多个KEY存储的值,它是一个阻塞的命令,有的时候你要删除一个超大的键值对,比如一个有上百万的对象,这条命令可能就会被阻塞好几秒了;又因为Redis执行指令都是在主线程上执行的,所以整个服务必然会有大量慢查询,吞吐量急剧下降!Redis的作者antirez也遇到了这个问题。那么针对这个问题怎么优化呢?研究过Redis源码的读者可能会想到了:使用渐进式rehash的方案,利用定时器和数据游标,每次删除一部分数据怎么样呢?其实这种思路,antirez并不是没有考虑过,但是最终antirez采取的方案是在原有的BIO系统中再增加一个成员。那么我们想到的渐进式删除方案有什么问题呢?问题的答案要从antriez在2015年发表的一篇博客中查找了:Lazy Redis is better Redis笔者截取了antirez举的一个例子:
为什么说渐进式懒删除很难做呢?作者说当我们删除一个集合的时候,可能我们删除集合中元素的速度还没有客户端向集合中添加元素的速度快,那我们的删除工作看起来是永远也无法完成了。
3.3 BIO系统再添新成员——lazyfree
上一小节我们分析了使用DEL命令删除大key时会造成redis服务端阻塞,除此之外,在使用 FLUSHDB 和 FLUSHALL 删除包含大量键的数据库时,或者redis在清理过期数据和淘汰内存超限的数据时,如果碰巧撞到了大体积的键也会造成服务器阻塞。
为了解决以上问题, redis 4.0 引入了lazyfree的机制,它可以将删除键或数据库的操作放在后台线程里执行, 从而尽可能地避免服务器阻塞。
lazyfree的原理不难想象,就是在删除对象时只是进行逻辑删除,然后把对象丢给后台,让后台线程去执行真正的destruct,避免由于对象体积过大而造成阻塞。
redis的lazyfree实现即是如此,下面我们主要看一下unlink命令的实现(以下代码是基于Redis 4.0的):
void unlinkCommand(client *c) { delGenericCommand(c, 1);}
入口很简单,就是调用delGenericCommand,第二个参数为1表示需要异步删除。
/* This command implements DEL and LAZYDEL. */void delGenericCommand(client *c, int lazy) { int numdel = 0, j;
for (j = 1; j < c->argc; j++) { expireIfNeeded(c->db,c->argv[j]); int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) : dbSyncDelete(c->db,c->argv[j]); if (deleted) { signalModifiedKey(c->db,c->argv[j]); notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC, "del",c->argv[j],c->db->id); server.dirty++; numdel++; } } addReplyLongLong(c,numdel);}
delGenericCommand函数根据lazy参数来决定是同步删除还是异步删除,同步删除的逻辑没有什么变化就不细讲了,我们重点看下新增的异步删除的实现。
#define LAZYFREE_THRESHOLD 64// 首先定义了启用后台删除的阈值,对象中的元素大于该阈值时才真正丢给后台线程去删除,如果对象中包含的元素太少就没有必要丢给后台线程,因为线程同步也要一定的消耗。int dbAsyncDelete(redisDb *db, robj *key) { if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr); //清除待删除key的过期时间
dictEntry *de = dictUnlink(db->dict,key->ptr); //dictUnlink返回数据库字典中包含key的条目指针,并从数据库字典中摘除该条目(并不会释放资源) if (de) { robj *val = dictGetVal(de); size_t free_effort = lazyfreeGetFreeEffort(val); //lazyfreeGetFreeEffort来获取val对象所包含的元素个数
if (free_effort > LAZYFREE_THRESHOLD) { atomicIncr(lazyfree_objects,1); //原子操作给lazyfree_objects加1,以备info命令查看有多少对象待后台线程删除 bioCreateBackgroundJob(BIO_LAZY_FREE ,val,NULL,NULL); //此时真正把对象val丢到后台线程的任务队列中 dictSetVal(db->dict,de,NULL); //把条目里的val指针设置为NULL,防止删除数据库字典条目时重复删除val对象 } }
if (de) { dictFreeUnlinkedEntry(db->dict,de); //删除数据库字典条目,释放资源 return 1; } else { return 0; }}
以上便是异步删除的逻辑,首先会清除过期时间,然后调用dictUnlink把要删除的对象从数据库字典摘除,再判断下对象的大小(太小就没必要后台删除),如果足够大就丢给后台线程,最后清理下数据库字典的条目信息。由以上的逻辑可以看出,当unlink一个体积较大的键时,实际的删除是交给后台线程完成的,所以并不会阻塞redis。
4. 小结
本文内容确实太过于冗长了,如果读者将文章仔细看到了这里,我相信您一定是对这篇文章非常感兴趣了。笔者使用GDB调试Redis 6.0版本代码时,输出了Redis启动后的线程信息,我们就从下面这张图结合本文的内容做一个总结吧:
可以看到笔者的调试信息中,Redis启动了6个线程,它们分别是:
redis-server
:redis的主线程,通过 I/O multiplexing 接收客户端请求,执行CPU指令,从redis发版之出就已经存在并一直作为Redis核心网络模型的主线程。bio_close_file
、bio_aof_fsync
:Redis BIO系统的老成员,bio_close_file用来异步的关闭服务端文件,bio_aof_fsync用来异步的同步日志,它们也是从Redis发版之出就一直存在了。bio_lazy_free
:Redis在4.0版本BIO系统增加的新成员,它主要将删除键或数据库的操作放在后台线程里执行, 从而尽可能地避免服务器阻塞。io_thd_1
、io_thd_2、io_thd_3
:Redis在6.0版本提供了I/O工作线程的配置,它们用来分担Redis主线程的I/O压力,使主线程更高效的进行CPU指令执行的工作。
以上就是笔者对Redis各个线程的功能和发展历史的总结,脉络很清晰!希望读者不仅限于了解Redis多线程的演进过程,还能清楚的知道这个过程中Redis都遇到了哪些问题,并对这些问题进行自己的思考,要知道作者antirez最终采取了什么样方案,他又是基于哪些因素考虑的。此外,本文分析了Redis架构设计的很多模型和源码,比如基于锁和共享变量实现的多线程生产者消费者模型、redis多线程I/O做到了lock-free,这些也都是我们在系统设计和编码中值得学习和借鉴的地方。最后,再次感谢坚持到最后的读者!
微服务架构路线
我为什么选择微服务架构?
众所周知,单体应用程序,由于其种种不足,几乎不支持敏捷方法。如果你想为一个大型或复杂的业务创建一个软件项目,最好从微服务架构开始。
微服务架构是一种灵活的架构,可以显著性地提高应用程序灵活性、可扩展性等。
微服务架构路线
据我了解很多开发者,想知道他们应该如何开始微服务架构旅程,虽然有成千上万的资源可以使用,但是资源到处分散。我决定通过为微服务架构学习定义路线图,使这段旅程更加清晰。
基本思路
基于微服务的架构通常有几个独立的单元,它们协同工作以接收和处理各种请求。这个复杂的某些部分可以是插件,这意味着在需要的情况下,你可以在不干扰应用程序的整体工作情况下, 新增一个新插件或删除一个插件。
例如,如果你决定实现一个微服务架构,你应该熟悉应用程序生命周期中的各种关注点,如持久化、日志记录、监控、负载均衡、缓存等,此外你还应该知道哪些哪些工具比较好或哪些堆栈更适合你的应用程序。
本文,我将从以下几个方面来介绍各种关注点
- 它是什么?
- 我为什么要使用它?
- 哪些工具比较好?
请注意,我在哪些工具比较好部分提到了两三个哪些工具比较好,当然,我们还有很多其他哪些工具比较好,选择这些哪些工具比较好的标准是业务需求,受欢迎程度、性能、开源以及更新频率。
再次注意,我们还有基于云的服务,这不在本文讨论的范围内。
本文,我用上图作为架构图示例。这个图涉及到大部分微服务架构组件,虽然不是也很全面,但是微服务架构的标准模型。
本文将会介绍微服务架构的关注点有:
- Docker
- 容器编排
- Docker容器管理
- API网关
- 负载均衡
- 服务发现
- 事件总线
- 日志记录
- 监控和警报
- 分布式追踪
- 数据持久化
- 缓存
- 云供应商
Docker
它是什么:
Docker 是一个开源平台,用于容器化你的应用程序,其中包含你的应用程序在各种环境中运行所需的类库和依赖项。在 Docker 的帮助下,开发团队能够将应用程序打包到容器中。
我为什么要使用它:
实际上,Docker 是容器化应用程序的哪些工具比较好之一,你也可以在不使用 Docker 的情况下创建容器,Docker 的真正好处是使这个过程更容易、更安全、更简单。
哪些工具比较好:
Docker
容器编排
它是什么:
在容器化应用程序后,你将需要一些哪些工具比较好来管理容器化应用程序,以执行一些手动和自动操作,例如水平扩展。
我为什么要使用它:
这些哪些工具比较好为你的应用程序管理提供一些服务,例如自动负载均衡,保证服务的高可用性。
这种服务是通过定义多个管理器节点来完成的,如果一个节点管理器出现任何故障,其他管理器可以保持应用程序服务可用。
哪些工具比较好:
Kubernetes or K8s,Docker Swarm
Docker 容器管理
它是什么:
管理 Docker 环境、配置、安全等。
我为什么要使用它:
为用户提供了一个基于 GUI 的Docker 容器管理,可以使他们不必处理不舒服的 CLI。这些工具也为开发人员提供了丰富的 UI 来构建和发布他们的镜像,还可以通过提供简化的用户界面来更轻松地执行一些操作任务,例如服务水平扩展。
哪些工具比较好:
Portainer , DockStation, Kitematic,Rancher
API网关
它是什么:
API 网关可以被视为一种充当你的应用程序服务和不同客户端之间的中间件。API 网关可以管理许多事情,例如:
Routing :网关接收所有 API 请求并将它们转发到目标服务。
Logging :你将能够在一处记录所有请求。
Authorization: 检查你作为用户是否有资格访问该服务,如果没有,可以拒绝该请求
Performance profiling:你可以估计每个请求的执行时间并检查你的应用程序瓶颈。
Caching:通过在网关级别处理缓存,你将消除服务上的大量流量。
事实上,它是作为一个反向代理工作的,客户端只需要知道你的网关,应用服务就可以隐藏起来,不直接向其他系统暴露。
我为什么要使用它:
如果没有 API 网关,你可能需要在每个服务中做一些横切关注点,例如,如果你想记录服务的请求和响应。此外,如果你的应用程序由多个服务组成,你的客户端需要知道每个服务地址,并且在更改服务地址的情况下,应该更新多个地方。
哪些工具比较好:
Kong,Ocelot
负载均衡
它是什么:
我们选择微服务架构最重要的原因是可扩展性,这意味着我们将能够通过运行更多服务实例来处理更多请求,但问题是,哪个实例应该接收请求,或客户端如何知道哪个服务实例应该处理请求?
这些问题的答案是负载均衡。负载均衡是高可用网络基础架构的关键组件,通常用于将工作负载分布到多个服务器来提高网站、应用、数据库或其他服务的性能和可靠性。
我为什么要使用它:
为了扩展你的独立服务,你需要运行多个服务实例。使用负载均衡器,客户端不需要知道服务的正确实例。
哪些工具比较好:
Traefik , NGINX,Seesaw
服务发现
它是什么:
随着你的应用服务的数量越来越多,服务需要知道彼此的服务实例地址,但是在有很多服务的大型应用中,这是无法处理的。因此我们需要服务发现,它负责提供应用程序中所有组件的地址,它们可以轻松地向服务发现系统发送请求并获取可用的服务实例地址。
我为什么要使用它:
当你的应用程序中可以有多个服务时,服务发现对于你的应用程序来说是必不可少的。你的应用服务不需要知道每个服务实例地址,这意味着服务发现为你铺平了道路。
哪些工具比较好:
Consul,Zookeeper,Eureka,etcd和Keepalived
事件总线
它是什么:
在微服务架构模式中,你将使用两种不同类型的通信,同步通信以及异步通信。同步通信是指服务之间通过 HTTP 或 GRPC 相互调用。异步通信意味着服务通过消息总线或事件总线相互交互,这意味着服务之间没有直接连接。
你的架构可以同时使用两种通信方式,例如在在线商店示例中,你可以在订单注册时发送消息,并且订阅了特定频道的服务将收到该消息。但有时你可能需要一些实时的查询,例如,你需要知道一个物品的数量,你可能会在服务之间使用 GRPC 或 HTTP 调用来获取响应。
我为什么要使用它:
如果你想要一个包含多个服务的可扩展应用程序,你将遵循的原则之一是创建松散耦合的服务,这些服务通过事件总线相互交互。此外,如果你需要创建一个能够插入新服务以接收一系列特定消息的应用程序,则需要使用事件总线。
哪些工具比较好:
RabbitMQ,Kafka
日志记录
它是什么:
使用微服务架构模式时,最好将服务日志集中起来。这些日志将用于调试问题或根据其类型聚合日志以供分析使用。
我为什么要使用它:
系统调试时,如果没有提前集中在一个地方收集服务日志,你可能会遇到困难。你还可以将与特定请求相关的日志与唯一的相关 ID 关联。这意味着与请求相关的不同服务中的所有日志都可以通过此关联 ID 访问。
哪些工具比较好:
Elastic Logstash
监控和警报
它是什么:
在微服务架构中,如果你想要一个可靠的应用程序或服务,你必须监控应用程序的功能、性能、通信和任何其他方面,以实现一个负责任的应用程序。
我为什么要使用它:
你需要监控整体功能和服务健康状况,还需要监控性能瓶颈,并准备解决故障的计划。通过在关键点定义服务的早期警报来减少服务的停机时间,从而优化用户体验。当负载较重时等,可以监控服务的整体资源消耗。
哪些工具比较好:
Prometheus , Kibana,Graphana
分布式追踪
它是什么:
调试始终是开发人员最关心的问题之一,因为你都有跟踪或调试单体引用程序的经验。那是非常直接和容易,但是在微服务架构上,因为一个请求可能会通过不同的服务,这使得调试和跟踪变得困难,因为服务不在一个地方,所以分布式追踪工具将会有所帮助。
我为什么要使用它:
如果没有分布式跟踪哪些工具比较好,通过不同的服务跟踪你的请求会令人沮丧或不可能。你可以借助用于演示请求流的丰富 UI 轻松跟踪请求和事件。
哪些工具比较好:
OpenTelemetry , Jeager,Zipkin
数据持久化
它是什么:
在大多数系统中,我们需要持久化数据,将应用程序的数据写入具有不同结构的物理文件中,以便数据用于进一步的处理或报告。
我为什么要使用它:
在单体应用程序中,我们曾经有一种或两种不同的持久性类型,大多数单体应用程序使用关系数据库,如 SQL Server、Oracle、MySQL。但是在微服务架构中,我们应该遵循“DataBase Per Service”模式,这意味着保持每个微服务的持久数据对该服务是私有的,并且只能通过其 API 访问。
对于不同的用途和场景,你将拥有不同的数据库。例如,数据展示服务可能会使用像 ElasticSearch 或 MongoDB 这样的 NoSQL 数据库,因为它们使用文档基础结构,这意味着这些数据库中持久化数据的结构与关系数据库不同,更适用于具有读多写少的服务。
另一方面,在某些微服务中,你可能需要 Oracle 或 SQL SERVER 等关系数据库,或者你可能还需要一些支持图结构或键值结构的数据库。
所以,在微服务架构中,根据服务的使命,你会需要不同类型的数据库。
哪些工具比较好:
关系数据库或 RDBMS : PostgreSQL, MySQL, SQL SERVRE, Oracle
NoSQL 数据库 : MongoDB, Cassandra,Elasticsearch
缓存
它是什么:
缓存减少了微服务架构的服务到服务通信的延迟。缓存是高速数据存储层。当从缓存中请求数据时,它的速度比访问硬盘中的数据要快。
我为什么要使用它:
在微服务架构中,有许多策略可以通过这些方式实现缓存。考虑以下:
1:嵌入式缓存(分布式和非分布式)
2:客户端-服务器缓存(分布式)
3:反向代理缓存(Sidecar)
为了减少延迟,可以在不同的层中实现缓存。此外,你还可以实现分布式缓存,它可以被多个微服务访问。它们还有不同的用途,比如限流,限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务。。
哪些工具比较好
Redis (Remote Dictionary Server), Apache Ignite,Hazelcast IMDG
云供应商
它是什么:
云服务提供商是一个第三方公司,提供基于云的平台,基础设施,应用程序或存储服务。就像房主为电力或天然气等公用事业付费一样,公司通常只需根据业务需求为他们使用的云服务数量付费。
云提供商最重要的类别:
- 软件即服务 (SaaS)。
- 平台即服务 (PaaS)。
- 基础设施即服务 (IaaS)。
我为什么要使用它
使用云计算服务的一个好处是,公司可以避免搭建和维护自己的 IT 基础设施的前期成本和复杂性,而只需在使用时为所用的东西付费。今天,公司可以租用从应用程序到存储的任何东西,而不是拥有自己的计算基础设施或数据中心。
哪些工具比较好
Amazon Web Services (AWS), Microsoft Azure, Google Cloud,Alibaba Cloud
结论
在本文中,我试图展示一个与微服务架构模式相关的路线图。如果你想从头开始实现微服务架构或将单体架构迁移到微服务架构,你将需要了解这些概念。
除了这些概念之外,我们还有其他概念,如服务网格、缓存、持久性,它们可能是本路线图的一部分,但为了简单起见,我故意没有提及它们。
奔腾、赛扬处理器“寿终正寝”
提起奔腾和赛扬,早年接触PC的玩家们一定不陌生。起源于1992年的奔腾处理器,曾经代表了个人电脑处理器的一个时代;而起源于1998年的赛扬处理器,虽然只是英特尔用来应对AMD低价策略而推出的入门产品,但却以其低价格和出色的超频能力,给国内数码发烧友们留下了深刻的印象。
可惜的是,随着时光的流逝,两条产品线如今都不复当年之勇。在英特尔推出了全新的酷睿产品线后,奔腾和赛扬的地位更是被新品牌所取代。自此,奔腾品牌被定位于低端市场,而赛扬品牌则被用于入门级笔记本电脑、海外的ChromeBook甚至软路由等产品,两个品牌就此消失在消费者的视野中。
9月16日,英特尔官方宣布,在2023年的笔记本处理器产品线中,将不会再有奔腾、赛扬品牌,取而代之的是全新的“Intel Processor”(英特尔处理器)作为多个处理器产品家族的品牌名称。英特尔公司副总裁Josh Newman表示:希望全新“英特尔处理器”品牌能让用户能够更有针对性地选择满足需求的处理器。
(图源:PCGamer)
在走过接近三十年的时光旅程后,曾经见证英特尔辉煌和低谷的奔腾和赛扬品牌,即将正式与我们道别。借此机会,让我们一起来回顾一下这两个品牌的兴衰史,看看是什么让这两个品牌走到了今天这一步,英特尔的这番决策又会对我们消费者产生什么影响呢?
英特尔的“掎角之势”
在英特尔推出奔腾这个品牌之前,X86处理器市场的产品命名可以说是非常地朴素。当时英特尔推出的处理器就叫286/386/486系列,AMD推出的处理器就叫AM286/AM386/AM486系列,而Cyrix(另一家X86处理器厂商,产品由德州仪器代工)推出的处理器就叫CY486系列。各家厂商均以86为后缀,导致产品之间相互混淆,不好区别。
为了打破这个命名混乱的局面,英特尔在1993年正式推出了Pentium(奔腾)系列处理器,以此和竞争对手推出的5x86系列做区分。按照微软的说法,Pentium这个名字来源于希腊单词中的Penta和拉丁单词的后缀ium,表明这是第“五”代X86架构处理器。
(图源:revamp-it.co)
Pentium(奔腾)系列的诞生,将X86架构处理器带上了一个新的高度。初代Pentium处理器以其64bit总线带宽、大幅改善的浮点运算和更高的核心频率独领风骚;1996年诞生的Pentium MMX通过加入MMX指令集使得多媒体性能得到了明显提升;1997年推出的Pentium II处理器首次采用了250nm制程,这也是英特尔确立业界优势的经典之作。
有Pentium lI和Pentium Il Xeon两大产品,英特尔在高端桌面级处理器市场和服务器级市场有若攻城拔寨之势。但是在入门级市场上,为了应对AMD的低价策略,英特尔还需要有一款有竞争力的产品,而这便是1998年推出的Celeron(赛扬)系列处理器。
尽管初版Celeron处理器因为砍掉二级缓存的原因而饱受非议,但是英特尔后续推出的Celeron 300A、Celeron 3系列和Celeron D系列均以合理的刀法以及不错的超频性能吸引了一大批硬核DIY玩家。“面对Celeron D,只有傻子才会去买奔四”,甚至一度成为了2005年CPU选购的至理名言。
(图源:英特尔官网)
对英特尔而言,在很长一段时间内,奔腾和赛扬就是他们在消费级处理器市场上厮杀的双极。两大品牌形成“掎角之势”,帮助英特尔从入门到高端全方位占据着主导地位。
定位迷茫的“老东西”
然而,王权不是永恒的。
在千禧年之际,英特尔和AMD围绕着处理器主频展开了一场世纪大战。在AMD强大的压力下,英特尔开始鼓吹“唯主频论”,并推出了采用NetBurst架构的Pentium 4系列,而这种利用加长流水管线的方式提升的工作主频,不仅换来了过低的执行效率和严重的发热问题,也让采用这种架构的奔腾4和赛扬4陷入了严重的争议中。
为了摆脱负面风评,家大业大的英特尔最终壮士断腕般放弃整个NetBurst架构,同时也间接放弃了采用NetBurst架构的奔腾和赛扬两大品牌,取而代之的则是全新的Core(酷睿)品牌,以及用Core i3、Core i5、Core
i7、Core i9四个级别的CPU进行分类的全新产品命名方式。
在如今的处理器市场上,Core i3系列已经能够很好地承担起了英特尔入门市场的重担,而Core i5、Core i7、Core i9的分类也让英特尔的中高端产品阵容显得更有层次感,方便用户挑选自己心仪的产品。奔腾和赛扬这两大品牌的价值,已经被酷睿完美继承了下来。
(图源:英特尔官网)
正因如此,在我看来,奔腾和赛扬遭到淘汰其实算是一件非常合理的事情。首先,在被酷睿事实性取代后,奔腾和赛扬两大品牌陷入了漫长的定义期。在随后的十五年时间里,奔腾先后经历了中低端、低端到入门级市场的变化,而赛扬则先后经历了入门桌面芯片、手机芯片和移动低端芯片的变化。
现如今,尽管英特尔还会在每次换代时推出新的奔腾、赛扬处理器,但是只要细心观察,我们不难发现被冠以这两个品牌的处理器,其实际差异与区别并不明显(例如赛扬7300和奔腾金牌8500),这两个过去曾叱咤半边天的品牌,放在当前的英特尔CPU产品线中,定位已经显得有些重复和多余。
(图源:英特尔官网)
其次,在被消耗、被遗忘后,奔腾和赛扬已经不存在什么品牌号召力了,英特尔也没有必要继续利用入门产品维系着两个品牌的存在。要问现在的普通消费者怎么选择CPU,他们给你的答案很可能只有两个——英特尔酷睿或是AMD锐龙,至于奔腾和赛扬,那是什么?
个人认为,对消费者来说,英特尔精简产品线显然是件好事。比起分崩离析的奔腾、赛扬和凌动,统一的“英特尔处理器”标识显然更加容易辨认,这让消费者可以简单地辨析出英特尔产品线里面的入门级产品,而不至于被部分无良厂商的“最新款处理器”标识所瞒骗。
当然,如果英特尔真的希望帮助消费者选择产品的话,那么他们接下来就必须为新的品牌的具体型号制定一个简单、合理的命名规则,让普通用户可以从型号名准确地了解产品的实际性能和功能,而不必在参数非常相似的几款入门低端产品中反复思考。
值得注意的是,这次英特尔削减的只是移动端的赛扬和奔腾产品线,而桌面端的奔腾依然会得到保留。对于那些希望继续使用低功耗办公台式机的用户来说,这显然是一大“福音”。
参考文献链接
https://mp.weixin.qq.com/s/T-ZTn4_oGwhXSpPNg0_wOw
https://mp.weixin.qq.com/s/qqTgnG3ndeUiZXdnmzQUfQ
https://mp.weixin.qq.com/s/1ex9MVhpKE5GPX4-bY-fdg
https://mp.weixin.qq.com/s/qwzdeVK4ILAwpFrd0I5M9Q