开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

洞悉MySQL底层架构:游走在缓冲与磁盘之间 1、MySQL

发表于 2020-06-02

提起MySQL,其实网上已经有一大把教程了,为什么我还要写这篇文章呢,大概是因为网上很多网站都是比较零散,而且描述不够直观,不能系统对MySQL相关知识有一个系统的学习,导致不能形成知识体系。为此我撰写了这篇文章,试图让这些底层架构相关知识更加直观易懂:

  • 尽量以图文的方式描述技术原理;
  • 涉及到关键的技术,附加官网或者技术书籍来源,方便大家进一步扩展学习;
  • 涉及到的背景知识尽可能做一个交代,比如讨论到log buffer的刷盘方式,延伸一下IO写磁盘相关知识点。

好了,MySQL从不会到精通系列马上就要开始了(看完之后还是不会的话..请忽略这句话)。

img

可能会有同学问:为啥不直接学更加先进的TiDB,或者是强大的OceanBase。

其实,MySQL作为老牌的应用场景广泛的关系型开源数据库,其底层架构是很值得我们学习的,吸收其设计精华,那么我们在平时的方案设计工作中也可以借鉴,如果项目中用的是MySQL,那么就能够把数据库用的更好了,了解了MySQL底层的执行原理,对于调优工作也是有莫大帮助的。本文我重点讲述MySQL底层架构,涉及到:

  • 内存结构:buffer pool、log buffer、change buffer,buffer pool的页淘汰机制是怎样的;
  • 磁盘结构:系统表空间、独立表空间、通用表空间、undo表空间、redo log;
  • 以及IO相关底层原理、查询SQL执行流程、数据页结构和行结构描述、聚集索引和辅助索引的底层数据组织方式、MVCC多版本并发控制的底层实现原理,以及可重复读、读已提交是怎么通过MVCC实现的。

image-20200530231121252

看完文本文,您将了解到:

  1. **整体架构:**InnoDB存储架构是怎样的 (1、MySQL架构)
  2. **工作原理:**查询语句的底层执行流程是怎样的 (2、查询SQL执行流程)
  3. **IO性能:**文件IO操作写磁盘有哪几种方式,有什么IO优化方式 (3.1.2、关于磁盘IO的方式)
  4. 缓存:InnoDB缓存(buffer pool, log buffer)的刷新方式有哪些(3.1.2.2、innodb_flush_method)
  5. **缓存:**log buffer是在什么时候写入到磁盘的(3.10.2、如何保证数据不丢失 - 其中第四步log buffer持久化到磁盘的时机为)
  6. **缓存:**为什么redo log prepare状态也要写磁盘?(3.10.2、如何保证数据不丢失 - 为什么第二步redo log prepare状态也要写磁盘?)
  7. **缓存:**脏页写盘一般发生在什么时候(3.10.2、如何保证数据不丢失 - 其中第五步:脏页刷新到磁盘的时机为)
  8. **缓存:**为什么唯一索引的更新不可以借助change buffer(3.2、Change Buffer)
  9. 缓存:log buffer的日志刷盘控制参数innodb_flush_log_at_trx_commit对写性能有什么影响(3.4.1、配置参数)
  10. **缓存:**buffer pool的LRU是如何实现的,为什么要这样实现(3.1.1、缓冲池LRU算法)
  11. **表存储:**系统表空间的结构,MySQL InnoDB磁盘存储格式,各种表空间(系统表空间,独立表空间,通用表空间)的作用和优缺点是什么,ibdata、ibd、frm文件分别是干嘛的(3.5、表空间)
  12. **行字段存储:**底层页和行的存储格式(3.6、InnoDB底层逻辑存储结构)
  13. 行字段存储:varchar,null底层是如何存储的,最大可用存储多大的长度(3.6.3.1、MySQL中varchar最大长度是多少)
  14. **行字段存储:**行记录太长了,一页存不下,该怎么存储?(3.6.3.2、行记录超过页大小如何存储)
  15. **索引:**数据库索引的组织方式是怎样的,明白为什么要采用B+树,而不是哈希表、二叉树或者B树(3.7、索引 - 为什么MySQL使用B+树)
  16. **索引:**索引组织方式是怎样的,为什么大字段会影响表性能(查询性能,更新性能)(3.7、索引)
  17. 索引:覆盖索引、联合索引什么情况下会生效(3.7.2、辅助索引)
  18. **索引:**什么是索引下推,索引下推减少了哪方面的开销?(3.7.2、辅助索引 - 索引条件下推)
  19. 索引:Change Buffer对二级索引DML语句有什么优化(3.2、Change Buffer)
  20. **数据完整性:**MySQL是如何保证数据完整性的,redo log、undo log和buffer pool数据完整性的关键作用分别是什么(3.10.2、如何保证数据不丢失)
  21. MVCC:MVCC底层是怎么实现的,可重复读和读已提交是怎么实现的(3.11.2、MVCC实现原理)
  22. 双写缓冲区有什么作用(3.9、Doublewrite Buffer)
  23. Redo Log在一个事务中是在什么时候写入的?binlog和Redo Log有什么区别?(3.10.1、Redo Log在事务中的写入时机)

1、MySQL架构

如下图为MySQL架构涉及到的常用组件:

image-20200517155127246

2、查询SQL执行流程

有如下表格:

image-20200517183833415

我们执行以下sql:

1
sql复制代码select * from t_user where user_id=10000;

2.1、MySQL客户端与服务器建立连接

如下图,建立过程:

  • 客户端通过mysql命令发起连接请求;
  • 经过三次握手后与服务端建立TCP连接;
  • 连接器接收到请求之后使用用户密码进行身份验证;
  • 验证通过之后,获取用户的权限信息缓存起来,该连接后面都是基于该缓存中的权限执行sql;

image-20200517194803475

对于Java应用程序来说,一般会把建立好的连接放入数据库连接池中进行复用,只要这个连接不关闭,就会一直在MySQL服务端保持着,可以通过show processlist命令查看,如下:

image-20200517185119239

注意,这里有个Time,表示这个连接多久没有动静了,上面例子是656秒没有动静,默认地,如果超过8个小时还没有动静,连接器就会自动断开连接,可以通过wait_timeout参数进行控制。

2.2、执行SQL

如下图,执行sql:

image-20200517231413373

  • 服务端接收到客户端的查询sql之后,先尝试从查询缓存中查询该sql是否已经有缓存的结果了,如果有则直接返回结果,如果没有则执行下一步;
  • 分析器拿到sql之后会尝试对sql语句进行词法分析和语法分析,校验语法的正确性,通过之后继续往下执行;
  • 优化器拿到分析器的sql之后,开始继续解析sql,判断到需要走什么索引,根据实际情况重写sql,最终生成执行计划;
  • 执行器根据执行计划执行sql,执行之前会先进行操作权限校验;然后根据表存储引擎调用对饮接口进行查询数据,这里的扫描行数就是指的接口返回的记录数,执行器拿到返回记录之后进一步加工,如本例子:
    • 执行器拿到select * from t_user where user_id=10000的所有记录,在依次判断user_name是不是等于”arthinking”,获取到匹配的记录。

3、InnoDB引擎架构

如下图,为存储引擎的架构:

image-20200530224136957

其实内存中的结构不太好直接观察到,不过磁盘的还是可以看到的,我们找到磁盘中MySQL的数据文件夹看看:

cd innodb_data_home_dir 查看MySQL 数据目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
c复制代码|- ib_buffer_pool  // 保存缓冲池中页面的表空间ID和页面ID,用于重启恢复缓冲池
|- ib_logfile0 // redo log 磁盘文件1
|- ib_logfile1 // redo log 磁盘文件2,默认情况下,重做日志存在磁盘的这两个文件中,循环的方式写入重做日志
|- ibdata1 // 系统表空间文件
|- ibtmp1 // 默认临时表空间文件,可通过innodb_temp_data_file_path属性指定文件位置
|- mysql/
|- mysql-bin.000001 // bin log文件
|- mysql-bin.000001 // bin log文件
...
|- mysql-bin.index // bin log文件索引
|- mysqld.local.err // 错误日志
|- mysqld.local.pid // mysql进程号
|- performance_schema/ // performance_schema数据库
|- sys/ // sys数据库
|- test/ // 数据库文件夹
|- db.opt // test数据库配置文件,包含数据库字符集属性
|- t.frm // 数据表元数据文件,不管是使用独立表空间还是系统表空间,每个表都对应有一个
|- t.ibd // 数据库表独立表空间文件,如果使用的是独立表空间,则一个表对应一个ibd文件,否则保存在系统表空间文件中

innodb_data_home_dir1

ib_buffer_pool2

ib_logfile03

ibtmp14

db.opt5

接下来我们逐一来介绍。

3.1、buffer pool

image-20200530224418964

buffer pool(缓冲池)是主内存中的一个区域,在InnoDB访问表数据和索引数据的时候,会顺便把对应的数据页缓存到缓冲池中。如果直接从缓冲池中直接读取数据将会加快处理速度。在专用服务器上,通常将80%左右的物理内存分配给缓冲池。

为了提高缓存管理效率,缓冲池把页面链接为列表,使用改进版的LRU算法将很少使用的数据从缓存中老化淘汰掉。

3.1.1、缓冲池LRU算法

通过使用改进版的LRU算法来管理缓冲池列表。

当需要把新页面存储到缓冲池中的时候,将淘汰最近最少使用的页面,并将新页面添加到旧子列表的头部。

image-20200519225450188

该算法运行方式:

  • 默认 3/8缓冲池用于旧子列表;
  • 当新页面如缓冲池时,首先将其插入旧子列表头部;
  • 重复访问旧子列表的页面,将使其移动至新子列表的头部;
  • 随着数据库的运行,页面逐步移至列表尾部,缓冲池中未被方位的页面最终将被老化淘汰。

相关优化参数:

  • innodb_old_blocks_pct:控制LRU列表中旧子列表的百分比,默认是37,也就是3/8,可选范围为5~95;
  • innodb_old_blocks_time :指定第一次访问页面后的时间窗口,该时间窗口内访问页面不会使其移动到LRU列表的最前面。默认是1000,也就是1秒。

innodb_old_blocks_time很重要,有了这1秒,对于全表扫描,由于是顺序扫描的,一般同一个数据页的数据都是在一秒内访问完成的,不会升级到新子列表中,一直在旧子列表淘汰数据,所以不会影响到新子列表的缓存。

3.1.2、关于磁盘IO的方式

image-20200530224453194

O_DIRECT是innodb_flush_method参数的一个可选值。

这里先介绍下和数据库性能密切相关的文件IO操作方法

3.1.2.1、文件IO操作方法

数据库系统是基于文件系统的,其性能和设备读写的机制有密切的关系。

open:打开文件6
1
c复制代码int open(const char *pathname, int flags);

系统调用Open会为该进程一个文件描述符fd,常用的flags如下:

  • O_WRONLY:表示我们以”写”的方式打开,告诉内核我们需要向文件中写入数据;
  • O_DSYNC:每次write都等待物理I/O完成,但是如果写操作不影响读取刚写入的数据,则不等待文件属性更新;
  • O_SYNC:每次write都等到物理I/O完成,包括write引起的文件属性的更新;
  • O_DIRECT:执行磁盘IO时绕过缓冲区高速缓存(内核缓冲区),从用户空间直接将数据传递到文件或磁盘设备,称为直接IO(direct IO)。因为没有了OS cache,所以会O_DIRECT降低文件的顺序读写的效率。
write:写文件7
1
c复制代码ssize_t write(int fd, const void *buf, size_t count);

使用open打开文件获取到文件描述符之后,可以调用write函数来写文件,具体表现根据open函数参数的不同而不同弄。

fsync & fdatasync:刷新文件8
1
2
3
4
5
c复制代码#include <unistd.h>

int fsync(int fd);

int fdatasync(int fd);
  • fdatasync:操作完write之后,我们可以调用fdatasync将文件数据块flush到磁盘,只要fdatasync返回成功,则可以认为数据已经写到磁盘了;
  • fsync:与O_SYNC参数类似,fsync还会更新文件metadata到磁盘;
  • sync:sync只是将修改过的块缓冲区写入队列,然后就返回,不等实际写磁盘操作完成;

为了保证文件更新成功持久化到硬盘,除了调用write方法,还需要调用fsync。

大致交互流程如下图:

image-20200520224027300

更多关于磁盘IO的相关内容,可以阅读:On Disk IO, Part 1: Flavors of IO9

**fsync性能问题:**除了刷脏页到磁盘,fsync还会同步文件metadata,而文件数据和metadata通常存放在磁盘不同地方,所以fsync至少需要两次IO操作。

对fsync性能的优化建议:由于以上性能问题,如果能够减少metadata的更新,那么就可以使用fdatasync了。因此需要确保文件的尺寸在write前后没有发生变化。为此,可以创建固定大小的文件进行写,写完则开启新的文件继续写。

3.1.2.2、innodb_flush_method

innodb_flush_method定义用于将数据刷新到InnoDB数据文件和日志文件的方法,这可能会影响I/O吞吐量。

以下是具体参数说明:

属性 值
命令行格式 –innodb-flush-method=value
系统变量 innodb_flush_method
范围 全局
默认值(Windows) unbuffered
默认值(Unix) fsync
有效值(Windows) unbuffered, normal
有效值(Unix) fsync, O_DSYNC, littlesync, nosync, O_DIRECT, O_DIRECT_NO_FSYNC

比较常用的是这三种:

fsync

默认值,使用fsync()系统调用来flush数据文件和日志文件到磁盘;

O_DSYNC

由于open函数的O_DSYNC参数在许多Unix系统上都存中问题,因此InnoDB不直接使用O_DSYNC。

InnoDB用于O_SYNC 打开和刷新日志文件,fsync()刷新数据文件。

表现为:写日志操作是在write函数完成,数据文件写入是通过fsync()系统调用来完成;

O_DIRECT

使用O_DIRECT (在Solaris上对应为directio())打开数据文件,并用于fsync()刷新数据文件和日志文件。此选项在某些GNU/Linux版本,FreeBSD和Solaris上可用。

表现为:数据文件写入直接从buffer pool到磁盘,不经过操作系统缓冲,日志还是需要经过操作系统缓存;

O_DIRECT_NO_FSYNC

在刷新I/O期间InnoDB使用O_DIRECT,并且每次write操作后跳过fsync()系统调用。

此设置适用于某些类型的文件系统,但不适用于其他类型的文件系统。例如,它不适用于XFS。如果不确定所使用的文件系统是否需要fsync()(例如保留所有文件元数据),请改用O_DIRECT。

如下图所示:

image-20200520232414096

为什么使用了O_DIRECT配置后还需要调用fsync()?

参考MySQL的这个bug:Innodb calls fsync for writes with innodb_flush_method=O_DIRECT10

Domas进行的一些测试表明,如果没有fsync,某些文件系统(XFS)不会同步元数据。如果元数据会更改,那么您仍然需要使用fsync(或O_SYNC来打开文件)。

例如,如果在启用O_DIRECT的情况下增大文件大小,它仍将写入文件的新部分,但是由于元数据不能反映文件的新大小,因此如果此刻系统发生崩溃,文件尾部可能会丢失。

为此:当重要的元数据发生更改时,请继续使用fsync或除O_DIRECT之外,也可以选择使用O_SYNC。

MySQL从v5.6.7起提供了O_DIRECT_NO_FSYNC选项来解决此类问题。

3.2、Change Buffer

change buffer是一种特殊的数据结构,当二级索引页(非唯一索引)不在缓冲池中时,它们会缓存这些更改 。当页面通过其他读取操作加载到缓冲池中时,再将由INSERT,UPDATE或DELETE操作(DML)产生的change buffer合并到buffer pool的数据页中。

为什么唯一索引不可以使用chage buffer?

针对唯一索引,如果buffer pool不存在对应的数据页,还是需要先去磁盘加载数据页,才能判断记录是否重复,这一步避免不了。

而普通索引是非唯一的,插入的时候以相对随机的顺序发生,删除和更新也会影响索引树中不相邻的二级索引树,通过使用合并缓冲,避免了在磁盘产生大量的随机IO访问获取普通索引页。

问题

当有许多受影响的行和许多辅助索引要更新时,change buffer合并可能需要几个小时,在此期间,I/O会增加,可能会导致查询效率大大降低,即使在事务提交之后,或者服务器重启之后,change buffer合并操作也会继续发生。相关阅读:Section 14.22.2, “Forcing InnoDB Recovery”

3.3、自适应哈希索引

自适应哈希索引功能由innodb_adaptive_hash_index变量启用 ,或在服务器启动时由--skip-innodb-adaptive-hash-index禁用。

3.4、Log Buffer

log buffer(日志缓冲区)用于保存要写入磁盘上的log file(日志文件)的数据。日志缓存区的内容会定期刷新到磁盘。

日志缓冲区大小由innodb_log_buffer_size变量定义 。默认大小为16MB。较大的日志缓冲区可以让大型事务在提交之前无需将redo log写入磁盘。

如果您有更新,插入或者删除多行的事务,尝试增大日志缓冲区的大小可以节省磁盘I/O。

3.4.1、配置参数

innodb_flush_log_at_trx_commit

innodb_flush_log_at_trx_commit 变量控制如何将日志缓冲区的内容写入并刷新到磁盘。

该参数控制是否严格存储ACID还是尝试获取更高的性能,可以通过该参数获取更好的性能,但是会导致在系统崩溃的过程中导致数据丢失。

可选参数:

  • 0,事务提交之后,日志只记录到log buffer中,每秒写一次日志到缓存并刷新到磁盘,尚未刷新的日志可能会丢失;
  • 1,要完全符合ACID,必须使用该值,表示日志在每次事务提交时写入缓存并刷新到磁盘;
  • 2,每次事务提交之后,日志写到page cache,每秒刷一次到磁盘,尚未刷新的日志可能会丢失;

innodb_flush_log_at_timeout

innodb_flush_log_at_timeout 变量控制日志刷新频率。可让您将日志刷新频率设置为*N秒(其中N*为1 ... 2700,默认值为1)

为了保证数据不丢失,请执行以下操作:

  • 如果启用了binlog,则设置:sync_binlog=1;
  • innodb_flush_log_at_trx_commit=1;

配置效果如下图所示:

image-20200523161540057

3.5、表空间

一个InnoDB表及其索引可以在建在系统表空间中,或者是在一个 独立表空间 中,或在 通用表空间。

  • 当innodb_file_per_table启用时,通常是将表存放在独立表空间中,这是默认配置;
  • 当innodb_file_per_table禁用时,则会在系统表空间中创建表;
  • 要在通用表空间中创建表,请使用 CREATE TABLE ... TABLESPACE语法。有关更多信息,请参见官方文档 14.6.3.3 General Tablespaces。

表空间概览图:

image-20200523161854294

表空间涉及的文件

相关文件默认在磁盘中的innodb_data_home_dir目录下:

1
2
3
4
5
6
arduino复制代码|- ibdata1  // 系统表空间文件
|- ibtmp1 // 默认临时表空间文件,可通过innodb_temp_data_file_path属性指定文件位置
|- test/ // 数据库文件夹
|- db.opt // test数据库配置文件,包含数据库字符集属性
|- t.frm // 数据表元数据文件,不管是使用独立表空间还是系统表空间,每个表都对应有一个
|- t.ibd // 数据库表独立表空间文件,如果使用的是独立表空间,则一个表对应一个ibd文件,否则保存在系统表空间文件中

frm文件

创建一个InnoDB表时,MySQL 在数据库目录中创建一个.frm文件。frm文件包含MySQL表的元数据(如表定义)。每个InnoDB表都有一个.frm文件。

与其他MySQL存储引擎不同, InnoDB它还在系统表空间内的自身内部数据字典中编码有关表的信息。MySQL删除表或数据库时,将删除一个或多个.frm文件以及InnoDB数据字典中的相应条目。

因此,在InnoDB中,您不能仅通过移动.frm 文件来移动表。有关移动InnoDB 表的信息,请参见官方文档14.6.1.4 Moving or Copying InnoDB Tables。

ibd文件

对于在独立表空间创建的表,还会在数据库目录中生成一个 .ibd表空间文件。

在通用表空间中创建的表在现有的常规表空间 .ibd文件中创建。常规表空间文件可以在MySQL数据目录内部或外部创建。有关更多信息,请参见官方文档14.6.3.3 General Tablespaces。

ibdata文件

系统表空间文件,在 InnoDB系统表空间中创建的表在ibdata中创建。

3.5.1、系统表空间

系统表空间由一个或多个数据文件(ibdata文件)组成。其中包含与InnoDB相关对象有关的元数据(InnoDB 数据字典 data dictionary),以及更改缓冲区(change buffer), 双写缓冲区(doublewrite buffer)和撤消日志(undo logs)的存储区 。

InnoDB 如果表是在系统表空间中创建的,则系统表空间中也包含表的表数据和索引数据。

系统表空间的问题

在MySQL 5.6.7之前,默认设置是将所有InnoDB表和索引保留 在系统表空间内,这通常会导致该文件变得非常大。因为系统表空间永远不会缩小,所以如果先加载然后删除大量临时数据,则可能会出现存储问题。

在MySQL 5.7中,默认设置为 独立表空间模式,其中每个表及其相关索引存储在单独的 .ibd文件中。此默认设置使使用**Barracuda文件格式的InnoDB功能更容易使用,例如表压缩**,页外列的有效存储以及大索引键前缀(innodb_large_prefix)。

将所有表数据保留在系统表空间或单独的 .ibd文件中通常会对存储管理产生影响。

InnoDB在MySQL 5.7.6中引入了通用表空间11,这些表空间也由.ibd文件表示 。通用表空间是使用CREATE TABLESPACE语法创建的共享表空间。它们可以在MySQL数据目录之外创建,能够容纳多个表,并支持所有行格式的表。

3.5.2、独立表空间

MySQL 5.7中,配置参数:innodb_file_per_table,默认处于启用状态,这是一个重要的配置选项,会影响InnoDB文件存储,功能的可用性和I/O特性等。

启用之后,每个表的数据和索引是存放在单独的.ibd文件中的,而不是在系统表空间的共享ibdata文件中。

优点

  • 您可以更加灵活的选择数据压缩12的行格式,如:
    • 默认情况下(innodb_page_size=16K),前缀索引13最多包含768个字节。如果开启innodb_large_prefix,且Innodb表的存储行格式为 DYNAMIC 或 COMPRESSED,则前缀索引最多可包含3072个字节,前缀索引也同样适用;
  • TRUNCATE TABLE执行的更快,并且回收的空间不会继续保留,而是让操作系统使用;
  • 可以在单独的存储设备上创建每表文件表空间数据文件,以进行I / O优化,空间管理或备份。请参见 14.6.1.2 Creating Tables Externally;

缺点

  • 独立表空间中的未使用空间只能由同一个表使用,如果管理不当,会造成空间浪费;
  • 多个表需要刷盘,只能执行多次fsync,无法合并多个表的写操作,这可能会导致更多的fsync操作总数;
  • mysqld必须为每个表文件空间保留一个打开的文件句柄,如果表数量多,可能会影响性能;
  • 每个表都需要自己的数据文件,需要更多的文件描述符;

即使启用了innodb_file_per_table参数,每张表空间存放的只是数据、索引和插入缓存Bitmap页,其他数据如回滚信息、插入缓冲索引页、系统事务信息、二次写缓冲等还是存放在原来的共享表空间中。

3.5.3、通用表空间

通用表空间使用CREATE TABLESPACE语法创建。

类似于系统表空间,通用表空间是共享表空间,可以存储多个表的数据。

通用表空间比独立表空间具有潜在的内存优势,服务器在表空间的生存期内将表空间元数据保留在内存中。一个通用表空间通常可以存放多个表数据,消耗更少的表空间元数据内存。

数据文件可以放置在MySQL数据目录或独立于MySQL数据目录。

3.5.4、undo表空间

undo表空间包含undo log。

innodb_rollback_segments变量定义分配给每个撤消表空间的回滚段的数量。

undo log可以存储在一个或多个undo表空间中,而不是系统表空间中。

在默认配置中,撤消日志位于系统表空间中。SSD存储更适合undo log的I/O模式,为此,可以把undo log存放在有别于系统表空间的ssd硬盘中。

innodb_undo_tablespaces 配置选项控制undo表空间的数量。

3.5.5、临时表空间

由用户创建的非压缩临时表和磁盘内部临时表是在共享临时表空间中创建的。

innodb_temp_data_file_path 配置选项指定零时表空间文件的路径,如果未指定,则默认在 innodb_data_home_dir目录中创建一个略大于12MB 的自动扩展数据文件ibtmp1 。

使用ROW_FORMAT=COMPRESSED属性创建的压缩临时表,是在独立表空间中的临时文件目录中创建的 。

服务启动的时候创建临时表空间,关闭的时候销毁临时表空间。如果临时表空间创建失败,则意味着服务启动失败。

3.6、InnoDB底层逻辑存储结构

在介绍索引之前,我们有必要了解一下InnoDB底层的逻辑存储结构,因为索引是基于这个底层逻辑存储结构创建的。截止到目前,我们所展示的都仅仅是物理磁盘中的逻辑视图,接下来我们就来看看底层的视图。

3.6.1、ibd文件组织结构

现在我们打开一个表空间ibd文件,看看里面都是如何组织数据的?

如下图,表空间由段(segment)、区(extent)、页(page)组成。

InnoDB最小的存储单位是页,默认每个页大小是16k。

而InnoDB存储引擎是面向行的(row-oriented),数据按行进行存放,每个页规定最多允许存放的行数=16k/2 - 200,即7992行。

image-20200523185138539

段:如数据段、索引段、回滚段等。InnoDB存储引擎是B+树索引组织的,所以数据即索引,索引即数据。B+树的叶子节点存储的都是数据段的数据。

3.6.2、数据页结构14

名称 占用空间 描述
Fil Header 38 byte 页的基本信息,如所属表空间,上一页和下一页指针。
Page Header 56 byte 数据页专有的相关信息
Infimun + Supremum 26 byte 两个虚拟的行记录,用于限定记录的边界
User Records 动态分配 实际存储的行记录内容
Free Space 动态调整 尚未使用的页空间
Page Directory 动态调整 页中某些记录的相对位置
Fil Trailer 8 byte 校验页是否完整

关于Infimun和Supremum:首次创建索引时,InnoDB会在根页面中自动设置一个最小记录和一个最高记录,并且永远不会删除它们。最低记录和最高记录可以视为索引页开销的一部分。最初,它们都存在于根页面上,但是随着索引的增长,最低记录将存在于第一或最低叶子页上,最高记录将出现在最后或最大关键字页上。

image-20200530120303599

3.6.3、行记录结构描述15

先来讲讲Compact行记录格式,Compact是MySQL5.0引入的,设计目标是高效的存储数据,让一个页能够存放更多的数据,从而实现更快的B+树查找。

名称 描述
变长字段长度列表 字段大小最多用2个字节表示,也就是最多限制长度:2^16=65535个字节;字段大小小于255字节,则用1个字节表示;
NULL标志位 记录该行哪些位置的字段是null值
记录头信息 记录头信息信息,固定占用5个字节
列1数据 实际的列数据,NULL不占用该部分的空间
列2数据
…

记录头用于将连续的记录链接在一起,并用于行级锁定。

每行数据除了用户定义的列外,还有两个隐藏列:

  • 6个字节的事务ID列;
  • 7个字节的回滚指针列;
  • 如果InnoDB没有指定主键,还会增加一个6个字节的rowid列;

而记录头信息包16含如下内容:

名称 大小(bit) 描述
() 1 未知
() 1 未知
deleted_flag 1 该行是否已被删除
min_rec_flag 1 如果该记录是预定义的最小记录,则为1
n_owned 4 该记录拥有的记录数
heap_no 13 索引堆中该条记录的排序号
record_type 3 记录类型:000 普通,001 B+树节点指针,010 Infimum,011 Supremum,1xx 保留
next_record 16 指向页中下一条记录

image-20200530152925354

更详细的页结构参考官网:22.2 InnoDB Page Structure

更详细的行结构参考官网:22.1 InnoDB Record Structure

更详细的行格式参考官网:14.11 InnoDB Row Formats

根据以上格式,可以得出数据页内的记录组织方式:

image-20200530122458403

3.6.3.1、MySQL中varchar最大长度是多少

上面表格描述我们知道,一个字段最长限制是65535个字节,这是存储长度的限制。

而MySQL中对存储是有限制的,具体参考:8.4.7 Limits on Table Column Count and Row Size

  • MySQL对每个表有4096列的硬限制,但是对于给定的表,有效最大值可能会更少;
  • MySQL表的每行行最大限制为65,535字节,这是逻辑的限制;实际存储的时候,表的物理最大行大小略小于页面的一半。如果一行的长度少于一页的一半,则所有行都将存储在本地页面内。如果它超过一页的一半,那么将选择可变长度列用于外部页外存储,直到该行大小控制在半页之内为止。

而实际能够存储的字符是跟编码有关的。

背景知识:

  • MySQL 4.0版本以下,varchar(10),代表10个字节,如果存放UTF8汉字,那么只能存3个(每个汉字3字节);
  • MySQL 5.0版本以上,varchar(10),指的是10个字符,无论存放的是数字、字母还是UTF8汉字(每个汉字3字节),都可以存放10个,最大大小是65532字节;

因此,Mysql5根据编码不同,存储大小也不同。

那么假设我们使用的是utf8编码,那么每个字符最多占用3个字节,也就是最多定义varchar(21845)个字符,如果是ascii编码,一个字符相当于一个字节,最多定义varchar(65535)个字符,下面我们验证下。

我们尝试创建一个这样的字段:

1
2
3
4
5
6
7
8
9
sql复制代码CREATE TABLE `t10` ( `id` int(11) NOT NULL,
`a` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB CHARSET=ascii ROW_FORMAT=Compact;


alter table t10 add `str` varchar(21845) DEFAULT NULL;

alter table t10 add `str` varchar(65535) DEFAULT NULL;

发现提示这个错误:

1
2
sql复制代码mysql> alter table t10 add `str` varchar(65535) DEFAULT NULL;
ERROR 1118 (42000): Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535. This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs

原因是按照以上的行格式介绍,变长字段长度列表记录也需要占用空间,占用2个字节,另外这里是允许为空字段,在8位之内,所以NULL标志位占用1个字节,所以我们总共可以存储的字符数是:

65535 - 2 - 2 - 4 - 4=65534

其中 -2 个字节表示变长字段列表,-1表示NULL标志位,两个-4表示两个int类型字段占用大小

所以实际上能够容纳的varchar大小为:65524,我们验证下:

image-20200524121242172

3.6.3.2、行记录超过页大小如何存储

MySQL表的内部表示具有65,535字节的最大行大小限制。InnoDB 对于4KB,8KB,16KB和32KB innodb_page_size 设置,表的最大行大小(适用于本地存储在数据库页面内的数据)略小于页面的一半 。如果包含 可变长度列的InnoDB 行超过最大行大小,那么将选择可变长度列用于外部页外存储。

可变长度列由于太长而无法容纳在B树页面上,这个时候会把可变长度列存储在单独分配的磁盘页面上,这些页面称为溢出页面,这些列称为页外列。页外列的值存储在由溢出页面构成的单链接列表中。

InnoDB存储引擎支持四种行格式:REDUNDANT,COMPACT, DYNAMIC,和COMPRESSED。不同的行格式,对溢出的阈值和处理方式有所区别,详细参考:14.11 InnoDB Row Formats。

COMPACT行格式处理方式

使用COMPACT行格式的表将前768个字节的变长列值(VARCHAR, VARBINARY和 BLOB和 TEXT类型)存储在B树节点内的索引记录中,其余的存储在溢出页上。

如果列的值等于或小于768个字节,则不使用溢出页,因此可以节省一些I / O。

如果查过了768个字节,那么会按照如下方式进行存储:

image-20200524154903041

DYNAMIC行格式处理方式

DYNAMIC行格式提供与COMPACT行格式相同的存储特性,但改进了超长可变长度列的存储能力和支持大索引键前缀。

InnoDB 可以完全在页外存储过长的可变长度列值(针对 VARCHAR, VARBINARY和 BLOB和 TEXT类型),而聚集索引记录仅包含指向溢出页的20字节指针。大于或等于768字节的固定长度字段被编码为可变长度字段。

image-20200524155015374

表中大字段引发的问题

如果一个表中有过多的可变长度大字段,导致一行记录太长,而整个时候使用的是COMPACT行格式,那么就可能会插入数据报错。

如,页面大小事16k,根据前面描述我们知道,MySQL限制一页最少要存储两行数据,如果很多可变长度大字段,在使用COMPACT的情况下,仍然会把大字段的前面768个字节存在索引页中,可以算出最多支持的大字段:1024 * 16 / 2 / 768 = 10.67,那么超过10个可变长度大字段就会插入失败了。

这个时候可以把row format改为:DYNAMIC。

3.7、索引

前面我们了解了InnoDB底层的存储结构,即:以B+树的方式组织数据页。另外了解了数据页中的数据行的存储方式。

而构建B+树索引的时候必须要选定一个或者多个字段作为索引的值,如果索引选择的是主键,那么我们就称为聚集索引,否则就是二级索引。

为什么MySQL使用B+树?

  • 哈希表虽然可以提供O(1)的单行数据操作性能,但却不能很好的支持排序和范围查找,会导致全表扫描;
  • B树可以再非叶子节点存储数据,但是这可能会导致查询连续数据的时候增加更多的I/O操作;
  • 而B+树数据都存放在叶子节点,叶子节点通过指针相互连接,可以减少顺序遍历时产生的额外随机I/O

更新详细解释: 为什么 MySQL 使用 B+ 树17

3.7.1、聚集索引

了解到上面的底层逻辑存储结构之后,我们进一步来看看InnoDB是怎么通过B+树来组织存储数据的。

首先来介绍下聚集索引。

聚集索引

主键索引的InnoDB术语。

下面我们创建一张测试表,并插入数据,来构造一颗B+树:

1
2
3
4
5
6
7
8
9
10
11
12
13
sql复制代码CREATE TABLE t20 (
id int NOT NULL,
a int NOT NULL,
b int,
c int,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

insert into t20 values(20, 1, 2, 1);
insert into t20 values(40, 1, 2, 5);
insert into t20 values(30, 3, 2, 4);
insert into t20 values(50, 3, 6, 2);
insert into t20 values(10, 1, 1, 1);

可以看到,虽然我们是id乱序插入的,但是插入之后查出来的确是排序好的:

image-20200524162034526

这个排序就是B+索引树构建的。

我们可以通过这个在线的动态演示工具来看看B+树的构造过程,最终结果如下:

image-20200524162043982

实际存放在数据库中的模型因页面大小不一样而有所不同,这里为了简化模型,我们按照B+树的通用模型来解释数据的存储结构。

类似的,我们的数据也是这种组织形式的,该B+树中,我们以主键为索引进行构建,并且把完整的记录存到对应的页下面:

image-20200524170732546

其中蓝色的是索引页,橙色的是数据页。

每个页的大小默认为16k,如果插入新的数据行,这个时候就要申请新的数据页了,然后挪动部分数据过去,重新调整B+树,这个过程称为页分裂,这个过程会影响性能。

相反的,如果InnoDB索引页的填充因子下降到之下MERGE_THRESHOLD,默认情况下为50%(如果未指定),则InnoDB尝试收缩索引树以释放页面。

自增主键的插入是递增顺序插入的,每次添加记录都是追加的,不涉及到记录的挪动,不会触发叶子节点的分裂,而一般业务字段做主键,往往都不是有序插入的,写成本比较高,所以我们更倾向于使用自增字段作为主键。

聚集索引注意事项

  • 当在表上面定义了PRIMARY KEY之后,InnoDB会把它作为聚集索引。为此,为你的每个表定义一个PRIMARY KEY。如果没有唯一并且非空的字段或者一组列,那么请添加一个自增列;
  • 如果您没有为表定义PRIMARY KEY,则MySQL会找到第一个不带null值的UNIQUE索引,并其用作聚集索引;
  • 如果表没有PRIMARY KEY或没有合适的UNIQUE索引,则InnoDB 内部会生成一个隐藏的聚集索引GEN_CLUST_INDEX,作为行ID,行ID是一个6字节的字段,随着数据的插入而自增。

聚集索引查找

根据索引进行查找id=50的记录,如下图,沿着B+树一直往下寻找,最终找到第四页**,然后把该页加载到buffer pool中,在缓存中遍历对比查找**,由于里面的行记录是顺序组织的,所以很快就可以定位到记录了。

image-20200524232033153

3.7.2、辅助索引

除了聚集索引之外的所有索引都称为辅助索引(二级索引)。在InnoDB中,辅助索引中每个记录都包含该行的主键列以及为辅助索引指定的列。

在辅助索引中查找到记录,可以得到记录的主键索引ID,然后可以通过这个主键索引ID去聚集索引中搜索具体的记录,这个过程称为回表操作。

如果主键较长,则辅助索引将使用更多空间,因此具有短的主键是有利的。

下面我们给刚刚的表添加一个组合联合索引

1
2
3
4
sql复制代码-- 添加多一个字段
alter table t20 add column d varchar(20) not null default '';
-- 添加一个联合索引
alter table t20 add index idx_abc(a, b, c);

添加之后组合索引B+树如下,其中索引key为abc三个字段的组合,索引存储的记录为主键ID:

image-20200525000141742

覆盖索引(Using index)

InnoDB存储引擎支持覆盖索引,即从辅助索引中就可以得到查询的记录,而不需要回表去查询聚集索引中的记录,从而减少大量的IO操作。下面的查询既是用到了覆盖索引 idx_abc:

1
sql复制代码select a, b from t20 where a > 2;

执行结果如下:

image-20200525000655179

可以发现,Extra这一列提示Using index,使用到了覆盖索引,扫描的行数为2。注意:这里的扫描行数指的是MySQL执行器从引擎取到两条记录,引擎内部可能会遍历到多条记录进行条件比较。

最左匹配原则

由于InnoDB索引式B+树构建的,因此可以利用索引的“最左前缀”来定位记录。

也就是说,不仅仅是用到索引的全部定义字段会走索引,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左n个字段。

索引条件下推(Using index condition)

索引条件下推 Index Condition Pushdown (ICP),是针对MySQL使用索引从表中检索行的情况的一种优化。

为什么叫下推呢,就是在满足要求的情况下,把索引的条件丢给存储引擎去判断,而不是把完整的记录传回MySQL Server层去判断。

ICP支持range, ref, eq_ref, 和 ref_or_null类型的查找,支持MyISAM和InnoDB存储引擎。

不能将引用子查询的条件下推,触发条件不能下推。详细规则参考:Index Condition Pushdown

如果不使用ICP,则存储引擎将遍历索引以在聚集索引中定位行,并将结果返回给MySQL Server层,MySQL Server层继续根据WHERE条件进行筛选行。

启用ICP后,如果WHERE可以仅使用索引中的列来评估部分条件,则MySQL Server层会将这部分条件压入WHERE条件下降到存储引擎。然后,存储引擎通过使用索引条目来判断索引条件,在满足条件的情况下,才回表去查找记录返回给MySQL Server层。

ICP的目标是减少回表扫描的行数,从而减少I / O操作。对于InnoDB表,ICP仅用于二级索引。

使用索引下推的时候,执行计划中的Extra会提示:Using index condition,而不是Using index,因为必须回表查询整行数据。Using index代表使用到了覆盖索引。

3.8、InnoDB Data Directory

InnoDB数据字典(Data Directory)存放于系统表空间中,主要包含元数据,用于追踪表、索引、表字段等信息。由于历史的原因,InnoDB数据字典中的元数据与.frm文件中的元数据重复了。

3.9、Doublewrite Buffer

双写缓冲区(Doublewrite Buffer)是一个存储区,是InnoDB在tablespace上的128个页(2个区),大小是2MB18。

版本区别:在MySQL 8.0.20之前,doublewrite缓冲区存储区位于InnoDB系统表空间中。从MySQL 8.0.20开始,doublewrite缓冲区存储区位于doublewrite文件中。

本文基于MySQL 5.7编写。

操作系统写文件是以4KB为单位的,那么每写一个InnoDB的page到磁盘上,操作系统需要写4个块。如果写入4个块的过程中出现系统崩溃,那么会导致16K的数据只有一部分写是成功的,这种情况下就是partial page write(部分页写入)问题。

InnoDB这个时候是没法通过redo log来恢复的,因为这个时候页面的Fil Trailer(Fil Trailer 主要存放FIL_PAGE_END_LSN,主要包含页面校验和以及最后的事务)中的数据是有问题的。

为此,每当InnoDB将页面写入到数据文件中的适当位置之前,都会首先将其写入双写缓冲区。只有将缓冲区安全地刷新到磁盘后,InnoDB才会将页面写入最终的数据文件。

image-20200526221407586

如果在页面写入过程中发生操作系统或者mysqld进程崩溃,则InnoDB可以在崩溃恢复期间从双写缓冲区中找到页面的完好副本用于恢复。恢复时,InnoDB扫描双写缓冲区,并为缓冲区中的每个有效页面检查数据文件中的页面是否完整。

如果系统表空间文件(“ ibdata文件 ”)位于支持原子写的Fusion-io设备上,则自动禁用双写缓冲,并且将Fusion-io原子写用于所有数据文件。

3.10、Redo Log

重做日志(Redo Log)主要适用于数据库的崩溃恢复,用于实现数据的完整性。

重做日志由两部分组成:

  • 重做日志缓冲区 Log Buffer;
  • 重做日志文件,重做日志文件在磁盘上由两个名为ib_logfile0和ib_logfile1的物理文件表示。

image-20200526222744741

为了实现数据完整性,在脏页刷新到磁盘之前,必须先把重做日志写入到磁盘。除了数据页,聚集索引、辅助索引以及Undo Log都需要记录重做日志。

3.10.1、Redo Log在事务中的写入时机

在事务中,除了写Redo log,还需要写binlog,为此,我们先来简单介绍下binlog。

3.10.1.1、binlog

全写:Binary Log,二进制log。二进制日志是一组日志文件。其中包含有关对MySQL服务器实例进行的数据修改的信息。

Redo Log是InnoDB引擎特有的,而binlog是MySQL的Server层实现的,所有引擎都可以使用。

Redo Log的文件是循环写的,空间会用完,binlog日志是追加写的,不会覆盖以前的日志。

binlog主要的目的:

  • 主从同步,主服务器将二进制日志中包含的事件发送到从服务器,从服务器执行这些事件,以保持和主服务器相同的数据更改;
  • 某些数据恢复操作需要使用二进制日志,还原到某一个备份点。

binlog主要是用于主从同步和数据恢复,Redo Log主要是用于实现事务数据的完整性,让InnoDB具有不会丢失数据的能力,又称为crash-safe。

binlog日志的两种记录形式:

  • 基于SQL的日志记录:事件包含产生数据更改(插入,新增,删除)的SQL语句;
  • 基于行的日志记录:时间描述对单个行的更改。

混合日志记录默认情况下使用基于语句的日志记录,但根据需要自动切换到基于行的日志记录。

3.10.1.2、Redo Log在事务中的写入时机

简单的介绍完binlog,我们再来看看Redo Log的写入流程。

假设我们这里执行一条sql

1
sql复制代码update t20 set a=10 where id=1;

执行流程如下:

image-20200529000923116

3.10.2、如何保证数据不丢失

前面我们介绍Log Buffer的时候,提到过,为了保证数据不丢失,我们需要执行以下操作:

  • 如果启用了binlog,则设置:sync_binlog=1;
  • innodb_flush_log_at_trx_commit=1;
  • sync_binlog=0:表示每次提交事务都只 write,不 fsync;
  • sync_binlog=1:表示每次提交事务都会执行 fsync;
  • sync_binlog=N(N>1) :表示每次提交事务都 write,但累积 N 个事务后才 fsync。

这两个的作用相当于在上面的流程最后一步,提交事务接口返回Server层之前,把binlog cache和log buffer都fsync到磁盘中了,这样就保证了数据的落盘,不会丢失,即使奔溃了,也可以通过binlog和redo log恢复数据相关流程如下:

image-20200529001035132

在磁盘和内存中的处理流程如下面编号所示:

image-20200529001256504

其中第四步log buffer持久化到磁盘的时机为:

  • log buffer占用的空间即将达到innodb_log_buffer_size一半的时候,后台线程主动写盘;
  • InnoDB后台有个线程,每隔1秒会把log buffer刷到磁盘;
  • 由于log buffer是所有线程共享的,当其他事务线程提交时也会导致已写入log buffer但还未提交的事务的redo log一起刷新到磁盘

其中第五步:脏页刷新到磁盘的时机为:

  • 系统内存不足,需要淘汰脏页的时候,要把脏页同步回磁盘;
  • MySQL空闲的时候;
  • MySQL正常关闭的时候,会把脏页flush到磁盘。

参数innodb_max_dirty_pages_pct是脏页比例上限,默认值是 75%。

为什么第二步 redo log prepare状态也要写磁盘?

因为这里先写了,才能确保在把binlog写到磁盘后崩溃,能够恢复数据:如果判断到redo log是prepare状态,那么查看是否存XID对应的binlog,如果存在,则表示事务成功提交,需要用prepare状态的redo log进行恢复。

这样即使崩溃了,也可以通过redo log来进行恢复了,恢复流程如下:

Redo Log是循环写的,如下图:

  • writepos记录了当前写的位置,一边写位置一边往前推进,当writepos与checkpoint重叠的时候就表示logfile写满了,绿色部分表示是空闲的空间,红色部分是写了redo log的空间;
  • checkpoint处标识了当前的LSN,每当系统崩溃重启,都会从当前checkpoint这个位置执行重做日志,根据重做日志逐个确认数据页是否没问题,有问题就通过redo log进行修复。

image-20200528235418486

LSN Log Sequence Number的缩写。代表日志序列号。在InnoDB中,LSN占用8个字节,单调递增,LSN的含义:

  • 重做日志写入的总量;
  • checkpoint的位置;
  • 页的版本;

除了重做日志中有LSN,每个页的头部也是有存储了该页的LSN,我们前面介绍页面格式的时候有介绍过。

在页中LSN表示该页最后刷新时LSN的大小。19

3.11、Undo Logs

上面说的redo log记录了事务的行为,可以通过其对页进行重做操作,但是食物有时候需要进行回滚,这时候就需要undo log了。20

**关于Undo Log的存储:**InnoDB中有回滚段(rollback segment),每个回滚段记录1024个undo log segment,在每个undo log segment段中进行申请undo页。系统表空间偏移量为5的页记录了所有的rollback segment header所在的页。

image-20200530123839227

3.11.1、undo log的格式

根据行为不同分为两种:

insert undo log

insert undo log:只对事务本身可见,所以insert undo log在事务提交后可直接删除,无需执行purge操作;

insert undo log主要记录了:

next 记录下一个undo log的位置
type_cmpl undo的类型:insert or update
*undo_no 记录事务的ID
*table_id 记录表对象
*len1, col1 记录列和值
*len2, col2 记录列和值
… …
start 记录undo log的开始位置

假设在事务1001中,执行以下sql,t20的table_id为10:

1
sql复制代码insert into t20(id, a, b, c, d) values(12, 2, 3, 1, "init")

那么对应会生成一条undo log:

image-20200530165013967

update undo log

update undo log:执行update或者delete会产生undo log,会影响已存在的记录,为了实现MVCC(后边介绍),update undo log不能再事务提交时立刻删除,需要将事务提交时放入到history list上,等待purge线程进行最后的删除操作。

update undo log主要记录了:

next 记录下一个undo log的位置
type_cmpl undo的类型:insert or update
*undo_no undo日志编号
*table_id 记录表对象
info_bits
*DATA_TRX_ID 事务的ID
*DATA_ROLL_PTR 回滚指针
*len1, i_col1 n_unique_index
*len2, i_col2
…
n_update_fields 以下是update vector信息,表示update操作导致发送改变的列
*pos1, *len1, u_old_col1
*pos2, *len2, u_old_col2
…
n_bytes_below
*pos, *len, col1
*pos, *len, col2
…
start 记录undo log的开始位置

假设在事务1002中,执行以下sql,t20的table_id为10:

1
sql复制代码update t20 set d="update1" where id=60;

那么对应会生成一条undo log:

image-20200530171944498

如上图,每回退应用一个undo log,就回退一个版本,这就是MVCC(Multi versioning concurrency control)的实现原理。

下面我们在执行一个delete sql:

1
sql复制代码delete from t20 where id=60;

对应的undo log变为如下:

image-20200530172640974

如上图,实际的行记录不会立刻删除,而是在行记录头信息记录了一个deleted_flag标志位。最终会在purge线程purge undo log的时候进行实际的删除操作,这个时候undo log也会清理掉。

3.11.2、MVCC实现原理

如上图所示,MySQL只会有一个行记录,但是会把每次执行的sql导致行记录的变动,通过undo log的形式记录起来,undo log通过回滚指针连接在一起,这样我们想回溯某一个版本的时候,就可以应用undo log,回到对应的版本视图了。

我们知道InnoDB是支持RC(Read Commit)和RR(Repeatable Read)事务隔离级别的,而这个是通过一致性视图(consistent read view)实现的。

一个事务开启瞬间,所有活跃的事务(未提交)构成了一个视图数组,InnoDB就是通过这个视图数组来判断行数据是否需要undo到指定的版本:

image-20200530213342612

RR事务隔离级别

假设我们使用了RR事务隔离级别。我们看个例子:

如下图,假设id=60的记录a=1

image-20200530215747142

事务C启动的瞬间,活跃的事务如下图黄色部分所示:

image-20200530222622858

也就是对于事务A、事务B、事务C,他们能够看到的数据只有是行记录中的最大事务IDDATA_TRX_ID<=11的,如果大于,那么只能通过undo进行回滚了。如果TRX_ID=当前事务id,也可以看到,即看到自己的改动。

另外有一个需要注意的:

  • 在RR隔离级别下,当事务更新事务的时候,只能用当前读来获取最新的版本数据来更新,如果当前记录的行锁被其他事务占用,就需要进入所等待;
  • 在RC隔离级别下,每个语句执行都会计算出新的一致性视图。

所以我们分析上面的例子的执行流程:

  • 事务C执行update,执行当前读,拿到的a=1,然后+1,最终a=2,同时添加一个TRX_ID=11的undo log;
    • image-20200530221027891
  • 事务B执行select,使用快照读,记录的DATA_TRX_ID > 11,所以需要通过undo log回滚到DATA_TRX_ID=11的版本,所以拿到的a是1;
  • 事务B执行update,需要使用当前读,拿到最新的记录,a=2,然后加1,最终a=3;
    • image-20200530221734284
  • 事务B执行select,拿到当前最新的版本,为自己的事务id,所以得到a=3;
  • 事务A执行select,使用快照读,记录的DATA_TRX_ID > 11,所以需要通过undo log回滚到DATA_TRX_ID=11的版本,所以拿到的a是1。
  • 如果是RC隔离级别,执行select的时候会计算出新的视图,新的视图能够看到的最大事务ID=14,由于事务B还没提交,事务C提交了,所以可以得到a=2:
    • image-20200530223051094

总结

  • 数据完整性依靠:redo log
  • 事务隔离级别的实现依靠MVCC,MVCC依靠undo log实现
  • IO性能提升方式:buffer pool加快查询效率和普通索引更新的效率,log buffer对日志写的性能提升
  • 查询性能提升依赖于索引,底层用页存储,字段越小页存储越多行记录,查询效率越快;自增字段作为聚集索引可以加快插入操作;
  • 故障恢复:双写缓冲区、redo log
  • 主从同步:binlog

本文内容比较多,看完之后需要多梳理,最后大家可以对照着这个思维导图回忆一下,这些内容是否都记住了:

image-20200531191959183

image-20200531180757758


这篇文章的内容就差不多介绍到这里了,能够阅读到这里的朋友真的是很有耐心,为你点个赞。

本文为arthinking基于相关技术资料和官方文档撰写而成,确保内容的准确性,如果你发现了有何错漏之处,烦请高抬贵手帮忙指正,万分感激。

大家可以关注我的博客:itzhai.com 获取更多文章,我将持续更新后端相关技术,涉及JVM、Java基础、架构设计、网络编程、数据结构、数据库、算法、并发编程、分布式系统等相关内容。

如果您觉得读完本文有所收获的话,可以关注我的账号,或者点赞吧,码字不易,您的支持就是我写作的最大动力,再次感谢!

关注我的公众号,及时获取最新的文章。

更多文章

  • JVM系列专题:公众号发送 JVM

本文作者: arthinking

博客链接: www.itzhai.com/database/in…

洞悉MySQL底层架构:游走在缓冲与磁盘之间

版权声明: 版权归作者所有,未经许可不得转载,侵权必究!联系作者请加公众号。


References

Footnotes

  1. innodb_data_home_dir. Retrieved from https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html#sysvar_innodb_data_home_dir ↩
  2. ib_buffer_pool. Retrieved from https://dev.mysql.com/doc/refman/5.6/en/innodb-preload-buffer-pool.html ↩
  3. ib_logfile0. Retrieved from https://dev.mysql.com/doc/refman/5.7/en/innodb-redo-log.html ↩
  4. ibtmp1. Retrieved from https://dev.mysql.com/doc/refman/5.7/en/innodb-temporary-tablespace.html ↩
  5. db.opt. Retrieved from https://dev.mysql.… ↩
  6. Linux Programmer’s Manual - OPEN(2). (2020-02-09). Retrieved from http://man7.org/linux/man-pages/man2/open.2.html ↩
  7. man-pages.write. (2019-10-10). Retrieved from http://man7.org/l… ↩
  8. man-pages.fdatasync. (2019-03-06). Retrieved from http://man7.org/l… ↩
  9. On Disk IO, Part 1: Flavors of IO. medium.com. Retrieved from https://medium.com/databasss/on-disk-io-part-1-flavours-of-io-8e1ace1de017 ↩
  10. Innodb calls fsync for writes with innodb_flush_method=O_DIRECT. Retrieved from https://bugs.mysql.com/bug.php?id=45892 ↩
  11. 14.6.3.3 General Tablespaces. Retrieved from https://dev.mysql.com/doc/refman/5.7/en/general-tablespaces.html ↩
  12. MYSQL INNODB表压缩. (2018-03-09). Retrieved from https://cloud.tencent.com/developer/article/1056453 ↩
  13. 前缀索引,一种优化索引大小的解决方案. (2015-03-03). Retrieved from https://www.cnblogs.com/studyzy/p/4310653.html ↩
  14. MySQL Internals Manual - innodb page structure[EB/OL]. (2020-05-04). Retrieved 2020-0530, from https://dev.mysql.com/doc/internals/en/innodb-page-structure.html ↩
  15. official.MySQL Internals Manual - innodb record structure[EB/OL]. (2020-… ↩
  16. 姜承尧. MySQL技术内幕-InnoDB存储引擎第二版[M]. 机械工业出版社, 2013-5:104. ↩
  17. 为什么 MySQL 使用 B+ 树. draveness.me. (2019-12-11). Retrieved from https://draveness.me/whys-the-design-mysql-b-plus-tree/ ↩
  18. InnoDB DoubleWrite Buffer as Read Cache using SSDs∗. Retrieved from https://www.usenix.org/legacy/events/fast12/poster_descriptions/Kangdescription2-12-12.pdf ↩
  19. 姜承尧. MySQL技术内幕-InnoDB存储引擎第二版[M]. 机械工业出版社, 2013-5:302-303. ↩
  20. 姜承尧. MySQL技术内幕-InnoDB存储引擎第二版[M]. 机械工业出版社, 2013-5:306. ↩

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

说了这么多次 I/O,但你知道它的原理么

发表于 2020-06-02

O 软件目标

设备独立性

现在让我们转向对 I/O 软件的研究,I/O 软件设计一个很重要的目标就是设备独立性(device independence)。啥意思呢?这意味着我们能够编写访问任何设备的应用程序,而不用事先指定特定的设备。比如你编写了一个能够从设备读入文件的应用程序,那么这个应用程序可以从硬盘、DVD 或者 USB 进行读入,不必再为每个设备定制应用程序。这其实就体现了设备独立性的概念。

再比如说你可以输入一条下面的指令

1
复制代码sort 输入 输出

那么上面这个 输入 就可以接收来自任意类型的磁盘或者键盘,并且 输出 可以写入到任意类型的磁盘或者屏幕。

计算机操作系统是这些硬件的媒介,因为不同硬件它们的指令序列不同,所以需要操作系统来做指令间的转换。

与设备独立性密切相关的一个指标就是统一命名(uniform naming)。设备的代号应该是一个整数或者是字符串,它们不应该依赖于具体的设备。在 UNIX 中,所有的磁盘都能够被集成到文件系统中,所以用户不用记住每个设备的具体名称,直接记住对应的路径即可,如果路径记不住,也可以通过 ls 等指令找到具体的集成位置。举个例子来说,比如一个 USB 磁盘被挂载到了 /usr/cxuan/backup 下,那么你把文件复制到 /usr/cxuan/backup/device 下,就相当于是把文件复制到了磁盘中,通过这种方式,实现了向任何磁盘写入文件都相当于是向指定的路径输出文件。

错误处理

除了设备独立性外,I/O 软件实现的第二个重要的目标就是错误处理(error handling)。通常情况下来说,错误应该交给硬件层面去处理。如果设备控制器发现了读错误的话,它会尽可能的去修复这个错误。如果设备控制器处理不了这个问题,那么设备驱动程序应该进行处理,设备驱动程序会再次尝试读取操作,很多错误都是偶然性的,如果设备驱动程序无法处理这个错误,才会把错误向上抛到硬件层面(上层)进行处理,很多时候,上层并不需要知道下层是如何解决错误的。这就很像项目经理不用把每个决定都告诉老板;程序员不用把每行代码如何写告诉项目经理。这种处理方式不够透明。

同步和异步传输

I/O 软件实现的第三个目标就是 同步(synchronous) 和 异步(asynchronous,即中断驱动)传输。这里先说一下同步和异步是怎么回事吧。

同步传输中数据通常以块或帧的形式发送。发送方和接收方在数据传输之前应该具有同步时钟。而在异步传输中,数据通常以字节或者字符的形式发送,异步传输则不需要同步时钟,但是会在传输之前向数据添加奇偶校验位。下面是同步和异步的主要区别

比较条件 同步传输 异步传输
概念 块头序列开始 它分别在字符前面和后面使用开始位和停止位。
传输方式 以块或帧的形式发送数据 发送字节或者字符
同步方式 同步时钟 无
传输速率 同步传输比较快 异步传输比较慢
时间间隔 同步传输通常是恒定时间 异步传输时间随机
开销 同步开销比较昂贵 异步传输开销比较小
是否存在间隙 不存在 存在
实现 硬件和软件 只有硬件
示例 聊天室,视频会议,电话对话等。 信件,电子邮件,论坛

回到正题。大部分物理IO(physical I/O) 是异步的。物理 I/O 中的 CPU 是很聪明的,CPU 传输完成后会转而做其他事情,它和中断心灵相通,等到中断发生后,CPU 才会回到传输这件事情上来。

I/O 分为两种:物理I/O 和 逻辑I/O(Logical I/O)。

物理 I/O 通常是从磁盘等存储设备实际获取数据。逻辑 I/O 是对存储器(块,缓冲区)获取数据。

缓冲

I/O 软件的最后一个问题是缓冲(buffering)。通常情况下,从一个设备发出的数据不会直接到达最后的设备。其间会经过一系列的校验、检查、缓冲等操作才能到达。举个例子来说,从网络上发送一个数据包,会经过一系列检查之后首先到达缓冲区,从而消除缓冲区填满速率和缓冲区过载。

共享和独占

I/O 软件引起的最后一个问题就是共享设备和独占设备的问题。有些 I/O 设备能够被许多用户共同使用。一些设备比如磁盘,让多个用户使用一般不会产生什么问题,但是某些设备必须具有独占性,即只允许单个用户使用完成后才能让其他用户使用。

下面,我们来探讨一下如何使用程序来控制 I/O 设备。一共有三种控制 I/O 设备的方法

  • 使用程序控制 I/O
  • 使用中断驱动 I/O
  • 使用 DMA 驱动 I/O

使用程序控制 I/O

使用程序控制 I/O 又被称为 可编程I/O,它是指由 CPU 在驱动程序软件控制下启动的数据传输,来访问设备上的寄存器或者其他存储器。CPU 会发出命令,然后等待 I/O 操作的完成。由于 CPU 的速度比 I/O 模块的速度快很多,因此可编程 I/O 的问题在于,CPU 必须等待很长时间才能等到处理结果。CPU 在等待时会采用轮询(polling)或者 忙等(busy waiting) 的方式,结果,整个系统的性能被严重拉低。可编程 I/O 十分简单,如果需要等待的时间非常短的话,可编程 I/O 倒是一个很好的方式。一个可编程的 I/O 会经历如下操作

  • CPU 请求 I/O 操作
  • I/O 模块执行响应
  • I/O 模块设置状态位
  • CPU 会定期检查状态位
  • I/O 不会直接通知 CPU 操作完成
  • I/O 也不会中断 CPU
  • CPU 可能会等待或在随后的过程中返回

使用中断驱动 I/O

鉴于上面可编程 I/O 的缺陷,我们提出一种改良方案,我们想要在 CPU 等待 I/O 设备的同时,能够做其他事情,等到 I/O 设备完成后,它就会产生一个中断,这个中断会停止当前进程并保存当前的状态。一个可能的示意图如下

尽管中断减轻了 CPU 和 I/O 设备的等待时间的负担,但是由于还需要在 CPU 和 I/O 模块之前进行大量的逐字传输,因此在大量数据传输中效率仍然很低。下面是中断的基本操作

  • CPU 进行读取操作
  • I/O 设备从外围设备获取数据,同时 CPU 执行其他操作
  • I/O 设备中断通知 CPU
  • CPU 请求数据
  • I/O 模块传输数据

所以我们现在着手需要解决的就是 CPU 和 I/O 模块间数据传输的效率问题。

使用 DMA 的 I/O

DMA 的中文名称是直接内存访问,它意味着 CPU 授予 I/O 模块权限在不涉及 CPU 的情况下读取或写入内存。也就是 DMA 可以不需要 CPU 的参与。这个过程由称为 DMA 控制器(DMAC)的芯片管理。由于 DMA 设备可以直接在内存之间传输数据,而不是使用 CPU 作为中介,因此可以缓解总线上的拥塞。DMA 通过允许 CPU 执行任务,同时 DMA 系统通过系统和内存总线传输数据来提高系统并发性。

I/O 层次结构

I/O 软件通常组织成四个层次,它们的大致结构如下图所示

每一层和其上下层都有明确的功能和接口。下面我们采用和计算机网络相反的套路,即自下而上的了解一下这些程序。

下面是另一幅图,这幅图显示了输入/输出软件系统所有层及其主要功能。

下面我们具体的来探讨一下上面的层次结构

中断处理程序

在计算机系统中,中断就像女人的脾气一样无时无刻都在产生,中断的出现往往是让人很不爽的。中断处理程序又被称为中断服务程序 或者是 ISR(Interrupt Service Routines),它是最靠近硬件的一层。中断处理程序由硬件中断、软件中断或者是软件异常启动产生的中断,用于实现设备驱动程序或受保护的操作模式(例如系统调用)之间的转换。

中断处理程序负责处理中断发生时的所有操作,操作完成后阻塞,然后启动中断驱动程序来解决阻塞。通常会有三种通知方式,依赖于不同的具体实现

  • 信号量实现中:在信号量上使用 up 进行通知;
  • 管程实现:对管程中的条件变量执行 signal 操作
  • 还有一些情况是发送一些消息

不管哪种方式都是为了让阻塞的中断处理程序恢复运行。

中断处理方案有很多种,下面是 《ARM System Developer’s Guide

Designing and Optimizing System Software》列出来的一些方案

  • 非嵌套的中断处理程序按照顺序处理各个中断,非嵌套的中断处理程序也是最简单的中断处理
  • 嵌套的中断处理程序会处理多个中断而无需分配优先级
  • 可重入的中断处理程序可使用优先级处理多个中断
  • 简单优先级中断处理程序可处理简单的中断
  • 标准优先级中断处理程序比低优先级的中断处理程序在更短的时间能够处理优先级更高的中断
  • 高优先级 中断处理程序在短时间能够处理优先级更高的任务,并直接进入特定的服务例程。
  • 优先级分组中断处理程序能够处理不同优先级的中断任务

下面是一些通用的中断处理程序的步骤,不同的操作系统实现细节不一样

  • 保存所有没有被中断硬件保存的寄存器
  • 为中断服务程序设置上下文环境,可能包括设置 TLB、MMU 和页表,如果不太了解这三个概念,请参考另外一篇文章
  • 为中断服务程序设置栈
  • 对中断控制器作出响应,如果不存在集中的中断控制器,则继续响应中断
  • 把寄存器从保存它的地方拷贝到进程表中
  • 运行中断服务程序,它会从发出中断的设备控制器的寄存器中提取信息
  • 操作系统会选择一个合适的进程来运行。如果中断造成了一些优先级更高的进程变为就绪态,则选择运行这些优先级高的进程
  • 为进程设置 MMU 上下文,可能也会需要 TLB,根据实际情况决定
  • 加载进程的寄存器,包括 PSW 寄存器
  • 开始运行新的进程

上面我们罗列了一些大致的中断步骤,不同性质的操作系统和中断处理程序能够处理的中断步骤和细节也不尽相同,下面是一个嵌套中断的具体运行步骤

设备驱动程序

在上面的文章中我们知道了设备控制器所做的工作。我们知道每个控制器其内部都会有寄存器用来和设备进行沟通,发送指令,读取设备的状态等。

因此,每个连接到计算机的 I/O 设备都需要有某些特定设备的代码对其进行控制,例如鼠标控制器需要从鼠标接受指令,告诉下一步应该移动到哪里,键盘控制器需要知道哪个按键被按下等。这些提供 I/O 设备到设备控制器转换的过程的代码称为 设备驱动程序(Device driver)。

为了能够访问设备的硬件,实际上也就意味着,设备驱动程序通常是操作系统内核的一部分,至少现在的体系结构是这样的。但是也可以构造用户空间的设备驱动程序,通过系统调用来完成读写操作。这样就避免了一个问题,有问题的驱动程序会干扰内核,从而造成崩溃。所以,在用户控件实现设备驱动程序是构造系统稳定性一个非常有用的措施。MINIX 3 就是这么做的。下面是 MINI 3 的调用过程

然而,大多数桌面操作系统要求驱动程序必须运行在内核中。

操作系统通常会将驱动程序归为 字符设备 和 块设备,我们上面也介绍过了

在 UNIX 系统中,操作系统是一个二进制程序,包含需要编译到其内部的所有驱动程序,如果你要对 UNIX 添加一个新设备,需要重新编译内核,将新的驱动程序装到二进制程序中。

然而随着大多数个人计算机的出现,由于 I/O 设备的广泛应用,上面这种静态编译的方式不再有效,因此,从 MS-DOS 开始,操作系统转向驱动程序在执行期间动态的装载到系统中。

设备驱动程序具有很多功能,比如接受读写请求,对设备进行初始化、管理电源和日志、对输入参数进行有效性检查等。

设备驱动程序接受到读写请求后,会检查当前设备是否在使用,如果设备在使用,请求被排入队列中,等待后续的处理。如果此时设备是空闲的,驱动程序会检查硬件以了解请求是否能够被处理。在传输开始前,会启动设备或者马达。等待设备就绪完成,再进行实际的控制。控制设备就是对设备发出指令。

发出命令后,设备控制器便开始将它们写入控制器的设备寄存器。在将每个命令写入控制器后,会检查控制器是否接受了这条命令并准备接受下一个命令。一般控制设备会发出一系列的指令,这称为指令序列,设备控制器会依次检查每个命令是否被接受,下一条指令是否能够被接收,直到所有的序列发出为止。

发出指令后,一般会有两种可能出现的情况。在大多数情况下,设备驱动程序会进行等待直到控制器完成它的事情。这里需要了解一下设备控制器的概念

设备控制器的主要主责是控制一个或多个 I/O 设备,以实现 I/O 设备和计算机之间的数据交换。

设备控制器接收从 CPU 发送过来的指令,继而达到控制硬件的目的

设备控制器是一个可编址的设备,当它仅控制一个设备时,它只有一个唯一的设备地址;如果设备控制器控制多个可连接设备时,则应含有多个设备地址,并使每一个设备地址对应一个设备。

设备控制器主要分为两种:字符设备和块设备

设备控制器的主要功能有下面这些

  • 接收和识别命令:设备控制器可以接受来自 CPU 的指令,并进行识别。设备控制器内部也会有寄存器,用来存放指令和参数
  • 进行数据交换:CPU、控制器和设备之间会进行数据的交换,CPU 通过总线把指令发送给控制器,或从控制器中并行地读出数据;控制器将数据写入指定设备。
  • 地址识别:每个硬件设备都有自己的地址,设备控制器能够识别这些不同的地址,来达到控制硬件的目的,此外,为使 CPU 能向寄存器中写入或者读取数据,这些寄存器都应具有唯一的地址。
  • 差错检测:设备控制器还具有对设备传递过来的数据进行检测的功能。

在这种情况下,设备控制器会阻塞,直到中断来解除阻塞状态。还有一种情况是操作是可以无延迟的完成,所以驱动程序不需要阻塞。在第一种情况下,操作系统可能被中断唤醒;第二种情况下操作系统不会被休眠。

设备驱动程序必须是可重入的,因为设备驱动程序会阻塞和唤醒然后再次阻塞。驱动程序不允许进行系统调用,但是它们通常需要与内核的其余部分进行交互。

与设备无关的 I/O 软件

I/O 软件有两种,一种是我们上面介绍过的基于特定设备的,还有一种是设备无关性的,设备无关性也就是不需要特定的设备。设备驱动程序与设备无关的软件之间的界限取决于具体的系统。下面显示的功能由设备无关的软件实现

与设备无关的软件的基本功能是对所有设备执行公共的 I/O 功能,并且向用户层软件提供一个统一的接口。

缓冲

无论是对于块设备还是字符设备来说,缓冲都是一个非常重要的考量标准。下面是从 ADSL(调制解调器) 读取数据的过程,调制解调器是我们用来联网的设备。

用户程序调用 read 系统调用阻塞用户进程,等待字符的到来,这是对到来的字符进行处理的一种方式。每一个到来的字符都会造成中断。中断服务程序会给用户进程提供字符,并解除阻塞。将字符提供给用户程序后,进程会去读取其他字符并继续阻塞,这种模型如下

这一种方案是没有缓冲区的存在,因为用户进程如果读不到数据会阻塞,直到读到数据为止,这种情况效率比较低,而且阻塞式的方式,会直接阻止用户进程做其他事情,这对用户来说是不能接受的。还有一种情况就是每次用户进程都会重启,对于每个字符的到来都会重启用户进程,这种效率会严重降低,所以无缓冲区的软件不是一个很好的设计。

作为一个改良点,我们可以尝试在用户空间中使用一个能读取 n 个字节缓冲区来读取 n 个字符。这样的话,中断服务程序会把字符放到缓冲区中直到缓冲区变满为止,然后再去唤醒用户进程。这种方案要比上面的方案改良很多。

但是这种方案也存在问题,当字符到来时,如果缓冲区被调出内存会出现什么问题?解决方案是把缓冲区锁定在内存中,但是这种方案也会出现问题,如果少量的缓冲区被锁定还好,如果大量的缓冲区被锁定在内存中,那么可以换进换出的页面就会收缩,造成系统性能的下降。

一种解决方案是在内核中内部创建一块缓冲区,让中断服务程序将字符放在内核内部的缓冲区中。

当内核中的缓冲区要满的时候,会将用户空间中的页面调入内存,然后将内核空间的缓冲区复制到用户空间的缓冲区中,这种方案也面临一个问题就是假如用户空间的页面被换入内存,此时内核空间的缓冲区已满,这时候仍有新的字符到来,这个时候会怎么办?因为缓冲区满了,没有空间来存储新的字符了。

一种非常简单的方式就是再设置一个缓冲区就行了,在第一个缓冲区填满后,在缓冲区清空前,使用第二个缓冲区,这种解决方式如下

当第二个缓冲区也满了的时候,它也会把数据复制到用户空间中,然后第一个缓冲区用于接受新的字符。这种具有两个缓冲区的设计被称为 双缓冲(double buffering)。

还有一种缓冲形式是 循环缓冲(circular buffer)。它由一个内存区域和两个指针组成。一个指针指向下一个空闲字,新的数据可以放在此处。另外一个指针指向缓冲区中尚未删除数据的第一个字。在许多情况下,硬件会在添加新的数据时,移动第一个指针;而操作系统会在删除和处理无用数据时会移动第二个指针。两个指针到达顶部时就回到底部重新开始。

缓冲区对输出来说也很重要。对输出的描述和输入相似

缓冲技术应用广泛,但它也有缺点。如果数据被缓冲次数太多,会影响性能。考虑例如如下这种情况,

数据经过用户进程 -> 内核空间 -> 网络控制器,这里的网络控制器应该就相当于是 socket 缓冲区,然后发送到网络上,再到接收方的网络控制器 -> 接收方的内核缓冲 -> 接收方的用户缓冲,一条数据包被缓存了太多次,很容易降低性能。

错误处理

在 I/O 中,出错是一种再正常不过的情况了。当出错发生时,操作系统必须尽可能处理这些错误。有一些错误是只有特定的设备才能处理,有一些是由框架进行处理,这些错误和特定的设备无关。

I/O 错误的一类是程序员编程错误,比如还没有打开文件前就读流,或者不关闭流导致内存溢出等等。这类问题由程序员处理;另外一类是实际的 I/O 错误,例如向一个磁盘坏块写入数据,无论怎么写都写入不了。这类问题由驱动程序处理,驱动程序处理不了交给硬件处理,这个我们上面也说过。

设备驱动程序统一接口

我们在操作系统概述中说到,操作系统一个非常重要的功能就是屏蔽了硬件和软件的差异性,为硬件和软件提供了统一的标准,这个标准还体现在为设备驱动程序提供统一的接口,因为不同的硬件和厂商编写的设备驱动程序不同,所以如果为每个驱动程序都单独提供接口的话,这样没法搞,所以必须统一。

分配和释放

一些设备例如打印机,它只能由一个进程来使用,这就需要操作系统根据实际情况判断是否能够对设备的请求进行检查,判断是否能够接受其他请求,一种比较简单直接的方式是在特殊文件上执行 open操作。如果设备不可用,那么直接 open 会导致失败。还有一种方式是不直接导致失败,而是让其阻塞,等到另外一个进程释放资源后,在进行 open 打开操作。这种方式就把选择权交给了用户,由用户判断是否应该等待。

注意:阻塞的实现有多种方式,有阻塞队列等

设备无关的块

不同的磁盘会具有不同的扇区大小,但是软件不会关心扇区大小,只管存储就是了。一些字符设备可以一次一个字节的交付数据,而其他的设备则以较大的单位交付数据,这些差异也可以隐藏起来。

用户空间的 I/O 软件

虽然大部分 I/O 软件都在内核结构中,但是还有一些在用户空间实现的 I/O 软件,凡事没有绝对。一些 I/O 软件和库过程在用户空间存在,然后以提供系统调用的方式实现。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

写给大忙人的Redis主从复制,花费五分钟让你面试不尴尬 一

发表于 2020-06-02

相信很多小伙伴都已经配置过主从复制,但是对于redis主从复制的工作流程和常见问题很多都没有深入的了解。咔咔这次用时俩天时间给大家整理一份redis主从复制的全部知识点。

本文实现所需环境
centos7.0
redis4.0

@TOC

一、什么是Redis主从复制?

主从复制就是现在有俩台redis服务器,把一台redis的数据同步到另一台redis数据库上。前者称之为主节点(master),后者为从节点(slave)。数据是只能master往slave同步单向。

但是在实际过程中是不可能只有俩台redis服务器来做主从复制的,这也就意味这每台redis服务器都有可能会称为主节点(master)

下图案例中,我们的slave3既是master的从节点,也是slave的主节点。

先知道这么个概念,更多详解继续查看下文。

在这里插入图片描述

二、为什么需要Redis主从复制?

假设我们现在就一台redis服务器,也就是单机状态。

在这种情况下会出现的第一个问题就是服务器宕机,直接导致数据丢失。如果项目是跟¥占关系的,那造成的后果就可想而知。

第二个情况就是内存问题了,当只有一台服务器时内存肯定会到达峰值的,不可能对一台服务器进行无限升级的。

在这里插入图片描述

所以针对以上俩个问题,我们就多准备几台服务器,配置主从复制。将数据保存在多个服务器上。并且保证每个服务器的数据是同步的。即使有一个服务器宕机了,也不会影响用户的使用。redis可以继续实现高可用、同时实现数据的冗余备份。
这会应该会有很多疑问,master跟slave怎么连接呢? 如何同步数据呢? 假如master服务器宕机了呢?别着急,一点一点解决你的问题。

在这里插入图片描述

三、Redis主从复制的作用

在上边我们说了为什么使用redis的主从复制,那么主从复制的作用就是针对为什么使用它来讲了。

  1. 我们继续使用这个图来谈论
  2. 第一点是数据冗余了,实现了数据的热备份,是持久化之外的另一种方式。
  3. 第二点是针对单机故障问题。当主节点也就是master出现问题时,可以由从节点来提供服务也就是slave,实现了快速恢复故障,也就是服务冗余。
  4. 第三点是读写分离,master服务器主要是写,slave主要用来读数据,可以提高服务器的负载能力。同时可以根据需求的变化,添加从节点的数量。
  5. 第四点是负载均衡,配合读写分离,有主节点提供写服务,从节点提供读服务,分担服务器负载,尤其在写少读多的情况下,通过多个从节点分担读负载,可以大大提高redis服务器的并发量和负载。
  6. 第五点是高可用的基石,主从复制是哨兵和集群能够实施的基础,因此我们可以说主从复制是高可用的基石。

在这里插入图片描述

四、配置Redis主从复制

说了这么多,我们先简单的配置一个主从复制案例,然后在谈实现的原理。

redis存储路径为:usr/local/redis

日志跟配置文件存储在:usr/local/redis/data

首先我们先配置俩个配置文件,分别为redis6379.conf 和 redis6380.conf

在这里插入图片描述

修改配置文件,主要就是修改端口。为了查看方便在把日志文件和持久化文件的名字都用各自的端口来做标识。
在这里插入图片描述

然后分别开启俩个redis服务,一个端口为6379,一个端口为6380。执行命令redis-server redis6380.conf,然后使用redis-cli -p 6380连接,因为redis的默认端口就是6379所以我们启动另外一台redis服务器直接使用redis-server redis6379.conf 然后直接使用redis-cli直接连接就可以。
在这里插入图片描述

这个时候我们就成功的配置了俩个redis服务,一台为6380,一台为6379,这里只是为了演示。实际工作中是需要配置在俩台不同的服务器的。
在这里插入图片描述

  1. 使用客户端命令行启动

我们先得有一个概念,就是在配置主从复制时,所有的操作都是在从节点来操作,也就是slave。

那么我们在从节点执行一个命令为 slaveof 127.0.0.1 6379,执行完就代表我们连接上了。

在这里插入图片描述

我们先测试一下看是否实现主从复制。在master这台服务器上执行俩个set kaka 123 和 set master 127.0.0.1,然后在slave6380端口是可以成功获取到的,也就说明我们的主从复制就已经配置完成了。但是在实现生产环境可不是就这样完事了,后边会在进一步对主从复制进行优化,直到实现高可用。
在这里插入图片描述

  1. 使用配置文件启用

在使用配置文件启动主从复制之前呢!先需要把之前使用客户端命令行连接的断开,在从主机执行slaveof no one即可断开主从复制。

在这里插入图片描述

在哪可以查看从节点已经断开了主节点呢!在主节点的客户端输入命令行info查看
这张图是使用从节点使用客户端命令行连接主节点后,在主节点的客户端输入info打印的信息,可以看到有一个slave0的一个信息。

在这里插入图片描述

这个图是在从节点执行完slaveof no one 后,在主节点打印的info,说明从节点已经跟主节点断开连接了。
在这里插入图片描述

在根据配置文件启动redis服务,redis-server redis6380.conf
当在从节点重新启动后就可以在主节点直接查看到从节点的连接信息。

在这里插入图片描述

测试数据,主节点写的东西,从节点还是会自动同步的。
在这里插入图片描述

  1. 启动redis服务器时启动

这种方式配置也是很简单,在启动redis服务器时直接就启动主从复制,执行命令:redis-server --slaveof host port 即可。

  1. 主从复制启动后的日志信息查看

这个是主节点的日志信息

在这里插入图片描述

这个是从节点的信息,其中有连接主节点信息,还有RDB快照保存。
在这里插入图片描述

五、主从复制工作原理

  1. 主从复制的三个阶段

主从复制完整的工作流程分为以下三个阶段。每一段都有自己的内部工作流程,那么我们会对这三个过程进行谈论。

  • 建立连接过程:这个过程就是slave跟master连接的过程
  • 数据同步过程:是master给slave同步数据的过程
  • 命令传播过程:是反复同步数据
    在这里插入图片描述
  1. 第一阶段:建立连接过程

在这里插入图片描述

上图是一个完整主从复制建立连接工作流程。然后使用简短的话语来描述上边的工作流程。

  1. 设置master的地址和端口,保存master的信息
  2. 建立socket连接(这个连接做的事情下文会说)
  3. 持续发送ping命令
  4. 身份验证
  5. 发送slave端口信息

在建立连接的过程中,从节点会保存master的地址和端口、主节点master保存从节点slave的端口。

  1. 第二阶段:数据同步阶段过程

在这里插入图片描述

这张图是详细描述第一次从节点连接主节点时的数据同步过程。
当从节点第一次连接主节点时,先会执行一次全量复制这次的全量复制是无法避免的。

全量复制执行完成后,主节点就会发送复制积压缓冲区的数据,然后从节点就会执行bgrewriteaof恢复数据,这也就是部分复制。

在这个阶段提到了三个新点,全量复制、部分复制、复制缓冲积压区。会在下文的常见问题里详细说明这几个点。

  1. 第三阶段:命令传播阶段

当master数据库被修改后,主从服务器的数据不一致后,此时就会让主从数据同步到一致,这个过程称之为命令传播。

master会将接收到的数据变更命令发送给slave,slave接收命令后执行命令,让主从数据达到一致。

命令传播阶段的部分复制

  • 在命令传播阶段出现断网的情况,或者网络抖动时会导致连接断开(connection lost)
  • 这个时候主节点master还是会继续往replbackbuffer(复制缓冲积压区)写数据
  • 从节点会继续尝试连接主机(connect to master)
  • 当从节点把自己的runid和复制偏移量发送给主节点,并且执行pysnc命令同步
  • 如果master判断偏移量是在复制缓冲区范围内,就会返回continue命令。并且发送复制缓冲区的数据给从节点。
  • 从节点接收数据执行bgrewriteaof,恢复数据

六. 详细介绍主从复制原理(全量复制+部分复制)

在这里插入图片描述

这个过程就是主从复制最齐全的流程讲解。那么下来我们对每一步进程简单的介绍

  1. 从节点发送指令psync ? 1 psync runid offset 找对应的runid索取数据。但是这里可以考虑一下,当从节点第一次连接的时候根本就不知道主节点的runid 和 offset 。所以第一次发送的指令是psync ? 1意思就是主节点的数据我全要。
  2. 主节点开始执行bgsave生成RDB文件,记录当前的复制偏移量offset
  3. 主节点这个时候会把自己的runid 和 offset 通过 +FULLRESYNC runid offset 指令 通过socket发送RDB文件给从节点。
  4. 从节点接收到+FULLRESYNC 保存主节点的runid和offset 然后清空当前所有数据,通过socket接收RDB文件,开始恢复RDB数据。
  5. 在全量复制后,从节点已经获取到了主节点的runid和offset,开始发送指令 psync runid offset
  6. 主节点接收指令,判断runid是否匹配,判断offset是否在复制缓冲区中。
  7. 主节点判断runid和offset有一个不满足,就会在返回到步骤2继续执行全量复制。这里的runid不匹配只有的可能是从节点重启了这个问题后边会解决,offset(偏移量)不匹配就是复制积压缓冲区溢出了。 如果runid或offset校验通过,从节点的offset和主节点的offset相同时则忽略。 如果runid或offset检验通过,从节点的offset与offset不相同,则会发送 +CONTINUE offset(这个offset为主节点的),通过socket发送复制缓冲区中从节点offset到主节点offset的数据。
  8. 从节点收到+CONTINUE 保存master的offset 通过socket接收到信息后,执行bgrewriteaof,恢复数据。

1-4是全量复制 5-8是部分复制

在主节点的第3步下面 主节点在主从复制的期间是一直在接收客户端的数据,主节点的offset是一直变化的。只有有变化就会给每个slave进行发送,这个发送的过程称之为心跳机制

七. 心跳机制

在命令传播阶段是,主节点与从节点之间一直都需要进行信息互换,使用心跳机制进行维护,实现主节点和从节点连接保持在线。

  • master心跳
+ 指令:ping
+ 默认10秒进行一次,是由参数repl-ping-slave-period决定的
+ 主要做的事情就是判断从节点是否在线
+ 可以使用info replication 来查看从节点租后一次连接时间的间隔,lag为0或者为1就是正常状态。
  • slave心跳任务
+ 指令:replconf ack {offset}
+ 每秒执行一次
+ 主要做的事情是给主节点发送自己的复制偏移量,从主节点获取到最新的数据变更命令,还做一件事情就是判断主节点是否在线。

心跳阶段的注意事项
主节点为保障数据稳定性,当从节点挂掉的数量或者延迟过高时。将会拒绝所有信息同步。

这里有俩个参数可以进行配置调整:

min-slaves-to-write 2

min-slaves-max-lag 8

这俩个参数表示从节点的数量就剩余2个,或者从节点的延迟大于8秒时,主节点就会强制关闭maste功能,停止数据同步。

那么主节点是如何知道从节点挂掉的数量和延迟时间呢! 在心跳机制里边slave 会每隔一秒发送perlconf ack 这个指令,这个指令可携带偏移量,也可以携带从节点的延迟时间和从节点的数量。

八、部分复制的三个核心要素

  1. 服务器的运行id (run id)

我们先看一下这个run id是什么,执行info命令即可看到。在上文中我们查看启动日志信息也可以看到。

在这里插入图片描述

redis在启动时会自动生成一个随机的id(这里需要注意的是每次启动的id都会不一样),是由40个随机的十六进制字符串组成,用来唯一识别一个redis节点。
在主从复制初次启动时,master会把自己的runid发送给slave,slave会保存master的这个id,我们可以使用info命令查看

在这里插入图片描述

当断线重连时,slave把这个id发送给master,如果slave保存的runid与master现在的runid相同,master会尝试使用部分复制(这块能否复制成功还有一个因素就是偏移量)。如果slave保存的runid与master现在的runid不同,则会直接进行全量复制。

  1. 复制积压缓冲区

复制缓冲积压区是一个先进先出的队列,用户存储master收集数据的命令记录。复制缓冲区的默认存储空间是1M。

可以在配置文件修改repl-backlog-size 1mb来控制缓冲区大小,这个比例可以根据自己的服务器内存来修改,咔咔这边是预留出了30%左右。

复制缓冲区到底存储的是什么?

当执行一个命令为set name kaka时,我们可以查看持久化文件查看

在这里插入图片描述

那么复制积压缓冲区就是存储的aof持久化的数据,并且以字节分开,并且每个字节都有自己的偏移量。这个偏移量也就是复制偏移量(offset)
在这里插入图片描述

那为什么会说复制缓冲积压区有可能会导致全量复制呢
在命令传播阶段,主节点会把收集的数据存储到复制缓冲区中,然后在发送给从节点。就是这里出现了问题,当主节点数据量在一瞬间特别大的时候,超出了复制缓冲区的内存,就会有一部分数据会被挤出去,从而导致主节点和从节点的数据不一致。从而进行全量复制。如果这个缓冲区大小设置不合理那么很大可能会造成死循环,从节点就会一直全量复制,清空数据,全量复制。

  1. 复制偏移量(offset)

在这里插入图片描述

主节点复制偏移量是给从节点发送一次记录一次,从节点是接收一次记录一次。
用于同步信息,对比主节点和从节点的差异,当slave断联时恢复数据使用。

这个值也就是来自己于复制缓冲积压区里边的那个偏移量。

九. 主从复制常见的问题

  1. 主节点重启问题(内部优化)

当主节点重启后,runid的值将发生变化,会导致所有的从节点进行全量复制。

这个问题我们无需考虑,知道系统是怎么优化的即可。

在建立完主从复制后主节点会创建master-replid变量,这个生成的策略跟runid一样,长度是41位,runid长度是40位,然后发送给从节点。

在主节点执行shutdown save命令时,进行了一次RDB持久化会把runid 和 offset保存到RDB文件中。可以使用命令redis-check-rdb查看该信息。

在这里插入图片描述

主节点重启后加载RDB文件,将文件中的repl-id 和repl-offset加载到内存中。纵使让所有从节点认为还是之前的主节点。
2. 从节点网络中断偏移量越界导致全量复制


由于网络环境不佳,从节点网络中断。复制积压缓冲区内存过小导致数据溢出,伴随着从节点偏移量越界,导致全量复制。有可能会导致反复的全量复制。

解决方案:修改复制积压缓冲区的大小:repl-backlog-size

设置建议:测试主节点连接从节点的时间,获取主节点每秒平均产生的命令总量write_size_per_second

复制缓冲区空间设置 = 2 * 主从连接时间 * 主节点每秒产生的数据总量

  1. 频繁的网路中断

由于主节点的cpu占用过高,或者从节点频繁连接。出现这种情况造成的结果就是主节点各种资源被严重占用,其中包括但不限于缓冲区,宽带,连接等。

为什么会出现主节点资源被严重占用?

在心跳机制中,从节点每秒会发送一个指令replconf ack指令到主节点。
从节点执行了慢查询,占用大量的cpu
主节点每秒调用复制定时函数replicationCron,然后从节点长时间没有相应。

解决方案:

设置从节点超时释放

设置参数:repl-timeout

这个参数默认为60秒。超过60秒,释放slave。

  1. 数据不一致问题

由于网络因素,多个从节点的数据会不一致。这个因素是没有办法避免的。

关于这个问题给出俩个解决方案:

第一个数据需要高度一致配置一台redis服务器,读写都用一台服务器,这种方式仅限于少量数据,并且数据需高度一直。

第二个监控主从节点的偏移量,如果从节点的延迟过大,暂时屏蔽客户端对该从节点的访问。设置参数为slave-serve-stale-data yes|no。 这个参数一但设置就只能响应info slaveof等少数命令。

  1. 从节点故障

这个问题直接在客户端维护一个可用节点列表,当从节点故障时,切换到其他节点进行工作,这个问题在后边集群会说到。

十. 总结

本文主要讲解了什么是主从复制、主从复制工作的三大阶段以及工作流程、部分复制的三大核心。命令传播阶段的心跳机制。最后说明了主从复制常见问题。

耗时俩天写的文章,这也是咔咔最近耗时最长的一篇文章,以后咔咔发的文章估计都是这样的,不会在把一问题单独出多篇文章来讲解,会一篇文章全部说完。不完善知识点或者错误知识点,随着咔咔的知识点增多在回来改善。文章主要是为了咔咔回顾方便。有什么问题评论区见。

咔咔希望是大家共同交流学习,不对的可以指出来,不喜勿喷。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

25 学习 Go 协程:详解信道/通道

发表于 2020-06-02

Hi,大家好,我是明哥。

在自己学习 Golang 的这段时间里,我写了详细的学习笔记放在我的个人微信公众号 《Go编程时光》,对于 Go 语言,我也算是个初学者,因此写的东西应该会比较适合刚接触的同学,如果你也是刚学习 Go 语言,不防关注一下,一起学习,一起成长。

我的在线博客:golang.iswbm.com
我的 Github:github.com/iswbm/GolangCodingTime


Go 语言之所以开始流行起来,很大一部分原因是因为它自带的并发机制。

如果说 goroutine 是 Go语言程序的并发体的话,那么 channel(信道) 就是 它们之间的通信机制。channel,是一个可以让一个 goroutine 与另一个 goroutine 传输信息的通道,我把他叫做信道,也有人将其翻译成通道,二者都是一个概念。

信道,就是一个管道,连接多个goroutine程序 ,它是一种队列式的数据结构,遵循先入先出的规则。

  1. 信道的定义与使用

每个信道都只能传递一种数据类型的数据,所以在你声明的时候,你得指定数据类型(string int 等等)

1
2
3
4
go复制代码var 信道实例 chan 信道类型

// 定义容量为10的信道
var 信道实例 [10]chan 信道类型

声明后的信道,其零值是nil,无法直接使用,必须配合make函进行初始化。

1
go复制代码信道实例 = make(chan 信道类型)

亦或者,上面两行可以合并成一句,以下我都使用这样的方式进行信道的声明

1
go复制代码信道实例 := make(chan 信道类型)

假如我要创建一个可以传输int类型的信道,可以这样子写。

1
2
go复制代码// 定义信道
pipline := make(chan int)

信道的数据操作,无非就两种:发送数据与读取数据

1
2
3
4
5
go复制代码// 往信道中发送数据
pipline<- 200

// 从信道中取出数据,并赋值给mydata
mydata := <-pipline

信道用完了,可以对其进行关闭,避免有人一直在等待。但是你关闭信道后,接收方仍然可以从信道中取到数据,只是接收到的会永远是 0。

1
go复制代码close(pipline)

对一个已关闭的信道再关闭,是会报错的。所以我们还要学会,如何判断一个信道是否被关闭?

当从信道中读取数据时,可以有多个返回值,其中第二个可以表示 信道是否被关闭,如果已经被关闭,ok 为 false,若还没被关闭,ok 为true。

1
go复制代码x, ok := <-pipline
  1. 信道的容量与长度

一般创建信道都是使用 make 函数,make 函数接收两个参数

  • 第一个参数:必填,指定信道类型
  • 第二个参数:选填,不填默认为0,指定信道的容量(可缓存多少数据)

对于信道的容量,很重要,这里要多说几点:

  • 当容量为0时,说明信道中不能存放数据,在发送数据时,必须要求立马有人接收,否则会报错。此时的信道称之为无缓冲信道。
  • 当容量为1时,说明信道只能缓存一个数据,若信道中已有一个数据,此时再往里发送数据,会造成程序阻塞。 利用这点可以利用信道来做锁。
  • 当容量大于1时,信道中可以存放多个数据,可以用于多个协程之间的通信管道,共享资源。

至此我们知道,信道就是一个容器。

若将它比做一个纸箱子

  • 它可以装10本书,代表其容量为10
  • 当前只装了1本书,代表其当前长度为1

信道的容量,可以使用 cap 函数获取 ,而信道的长度,可以使用 len 长度获取。

1
2
3
4
5
6
7
8
9
10
go复制代码package main

import "fmt"

func main() {
pipline := make(chan int, 10)
fmt.Printf("信道可缓冲 %d 个数据\n", cap(pipline))
pipline<- 1
fmt.Printf("信道中当前有 %d 个数据", len(pipline))
}

输出如下

1
2
复制代码信道可缓冲 10 个数据
信道中当前有 1 个数据
  1. 缓冲信道与无缓冲信道

按照是否可缓冲数据可分为:缓冲信道 与 无缓冲信道

缓冲信道

允许信道里存储一个或多个数据,这意味着,设置了缓冲区后,发送端和接收端可以处于异步的状态。

1
go复制代码pipline := make(chan int, 10)

无缓冲信道

在信道里无法存储数据,这意味着,接收端必须先于发送端准备好,以确保你发送完数据后,有人立马接收数据,否则发送端就会造成阻塞,原因很简单,信道中无法存储数据。也就是说发送端和接收端是同步运行的。

1
2
3
4
go复制代码pipline := make(chan int)

// 或者
pipline := make(chan int, 0)
  1. 双向信道与单向信道

通常情况下,我们定义的信道都是双向通道,可发送数据,也可以接收数据。

但有时候,我们希望对信道的数据流向做一些控制,比如这个信道只能接收数据或者这个信道只能发送数据。

因此,就有了 双向信道 和 单向信道 两种分类。

双向信道

默认情况下你定义的信道都是双向的,比如下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
go复制代码import (
"fmt"
"time"
)

func main() {
pipline := make(chan int)

go func() {
fmt.Println("准备发送数据: 100")
pipline <- 100
}()

go func() {
num := <-pipline
fmt.Printf("接收到的数据是: %d", num)
}()
// 主函数sleep,使得上面两个goroutine有机会执行
time.Sleep(1)
}

单向信道

单向信道,可以细分为 只读信道 和 只写信道。

定义只读信道

1
2
3
go复制代码var pipline = make(chan int)
type Receiver = <-chan int // 关键代码:定义别名类型
var receiver Receiver = pipline

定义只写信道

1
2
3
go复制代码var pipline = make(chan int)
type Sender = chan<- int // 关键代码:定义别名类型
var sender Sender = pipline

仔细观察,区别在于 <- 符号在关键字 chan 的左边还是右边。

  • <-chan 表示这个信道,只能从里发出数据,对于程序来说就是只读
  • chan<- 表示这个信道,只能从外面接收数据,对于程序来说就是只写

有同学可能会问:为什么还要先声明一个双向信道,再定义单向通道呢?比如这样写

1
2
go复制代码type Sender = chan<- int 
sender := make(Sender)

代码是没问题,但是你要明白信道的意义是什么?(以下是我个人见解

信道本身就是为了传输数据而存在的,如果只有接收者或者只有发送者,那信道就变成了只入不出或者只出不入了吗,没什么用。所以只读信道和只写信道,唇亡齿寒,缺一不可。

当然了,若你往一个只读信道中写入数据 ,或者从一个只写信道中读取数据 ,都会出错。

完整的示例代码如下,供你参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
go复制代码import (
"fmt"
"time"
)
//定义只写信道类型
type Sender = chan<- int

//定义只读信道类型
type Receiver = <-chan int

func main() {
var pipline = make(chan int)

go func() {
var sender Sender = pipline
fmt.Println("准备发送数据: 100")
sender <- 100
}()

go func() {
var receiver Receiver = pipline
num := <-receiver
fmt.Printf("接收到的数据是: %d", num)
}()
// 主函数sleep,使得上面两个goroutine有机会执行
time.Sleep(1)
}
  1. 遍历信道

遍历信道,可以使用 for 搭配 range关键字,在range时,要确保信道是处于关闭状态,否则循环会阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
go复制代码import "fmt"

func fibonacci(mychan chan int) {
n := cap(mychan)
x, y := 1, 1
for i := 0; i < n; i++ {
mychan <- x
x, y = y, x+y
}
// 记得 close 信道
// 不然主函数中遍历完并不会结束,而是会阻塞。
close(mychan)
}

func main() {
pipline := make(chan int, 10)

go fibonacci(pipline)

for k := range pipline {
fmt.Println(k)
}
}
  1. 用信道来做锁

当信道里的数据量已经达到设定的容量时,此时再往里发送数据会阻塞整个程序。

利用这个特性,可以用当他来当程序的锁。

示例如下,详情可以看注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
go复制代码package main

import {
"fmt"
"time"
}

// 由于 x=x+1 不是原子操作
// 所以应避免多个协程对x进行操作
// 使用容量为1的信道可以达到锁的效果
func increment(ch chan bool, x *int) {
ch <- true
*x = *x + 1
<- ch
}

func main() {
// 注意要设置容量为 1 的缓冲信道
pipline := make(chan bool, 1)

var x int
for i:=0;i<1000;i++{
go increment(pipline, &x)
}

// 确保所有的协程都已完成
// 以后会介绍一种更合适的方法(Mutex),这里暂时使用sleep
time.Sleep(3)
fmt.Println("x 的值:", x)
}

输出如下

1
复制代码x 的值:1000

如果不加锁,输出会小于1000。

系列导读

01. 开发环境的搭建(Goland & VS Code)

02. 学习五种变量创建的方法

03. 详解数据类型:**整形与浮点型**

04. 详解数据类型:byte、rune与string

05. 详解数据类型:数组与切片

06. 详解数据类型:字典与布尔类型

07. 详解数据类型:指针

08. 面向对象编程:结构体与继承

09. 一篇文章理解 Go 里的函数

10. Go语言流程控制:if-else 条件语句

11. Go语言流程控制:switch-case 选择语句

12. Go语言流程控制:for 循环语句

13. Go语言流程控制:goto 无条件跳转

14. Go语言流程控制:defer 延迟调用

15. 面向对象编程:接口与多态

16. 关键字:make 和 new 的区别?

17. 一篇文章理解 Go 里的语句块与作用域

18. 学习 Go 协程:goroutine

19. 学习 Go 协程:详解信道/通道

20. 几个信道死锁经典错误案例详解

21. 学习 Go 协程:WaitGroup

22. 学习 Go 协程:互斥锁和读写锁

23. Go 里的异常处理:panic 和 recover

24. 超详细解读 Go Modules 前世今生及入门使用

25. Go 语言中关于包导入必学的 8 个知识点

26. 如何开源自己写的模块给别人用?

27. 说说 Go 语言中的类型断言?

28. 这五点带你理解Go语言的select用法


本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【ES从入门到实战】二十六、全文检索-ElasticSear

发表于 2020-06-01

接第25节

3、使用

在上一小节中实现了创建索引,这一小节来试一下数据的检索功能。
在代码中实现 搜索address中包含mill的所有人的年龄分布以及平均薪资 这个功能,如果是在 kibana 中,使用的是下面的DSL语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码GET /bank/_search
{
"query": { //查询
"match": {
"address": "mill"
}
},
"aggs": { //聚合
"ageAgg": { //年龄分布
"terms": {
"field": "age",
"size": 10 //只取10中聚合的结果
}
},
"balanceAvg":{//平均薪资
"avg": {
"field": "balance"
}
}
}
}

结果如下图所示:

在这里插入图片描述

要在 SpringBoot 集成环境中该如何实现呢?下面来使用代码实现上面的功能。

1)、测试类 PafcmallSearchApplicationTests.java 中添加测试方法searchData():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
复制代码/**
* 检索数据
*
* @throws IOException
*/
@Test
void searchData() throws IOException {
// 1、创建检索请求
SearchRequest searchRequest = new SearchRequest();
// 指定索引
searchRequest.indices("bank");
// 指定DSL,检索条件
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// 1.1)、构造检索条件
// sourceBuilder.query(QueryBuilders.termQuery("user", "kimchy"));
// sourceBuilder.from(0);
// sourceBuilder.size(5);
// sourceBuilder.timeout(new TimeValue(60, TimeUnit.SECONDS));

searchSourceBuilder.query(QueryBuilders.matchQuery("address","mill"));

// 聚合数据
// 1.2)、根据年龄分布聚合
TermsAggregationBuilder ageAgg = AggregationBuilders.terms("ageAgg").field("age").size(10);
searchSourceBuilder.aggregation(ageAgg);

// 1.3)、计算平薪资
AvgAggregationBuilder balanceAvg = AggregationBuilders.avg("balanceAvg").field("balance");
searchSourceBuilder.aggregation(balanceAvg);

System.out.println("检索条件:"+searchSourceBuilder.toString());
searchRequest.source(searchSourceBuilder);

// 2、执行检索
SearchResponse searchResponse = client.search(searchRequest, PafcmallElasticsearchConfig.COMMON_OPTIONS);

// 3、分析结果 searchResponse
System.out.println(searchResponse.toString());

//3.1)、获取所有查到的数据
SearchHits hits = searchResponse.getHits(); // 获取到最外围的 hits
SearchHit[] searchHits = hits.getHits(); // 内围的 hits 数组
for (SearchHit hit : searchHits) {
/**
* "_index":"bank",
* "_type":"account",
* "_id":"970",
* "_score":5.4032025,
* "_source":{
*/
// hit.getIndex();hit.getType()''
String str = hit.getSourceAsString();
Account account = JSON.parseObject(str, Account.class);

System.out.println(account.toString());

}
//3.1)、获取这次检索到的分析数据
Aggregations aggregations = searchResponse.getAggregations();
// 可以遍历获取聚合数据
// for (Aggregation aggregation : aggregations.asList()) {
// System.out.println("当前聚合:"+aggregation.getName());
// aggregation.getXxx
// }
// 也可使使用下面的方式
Terms ageAgg1 = aggregations.get("ageAgg");
for (Terms.Bucket bucket : ageAgg1.getBuckets()) {
String keyAsString = bucket.getKeyAsString();
System.out.println("年龄:"+keyAsString+" ==> 有 "+bucket.getDocCount()+" 个");
}

Avg balanceAvg1 = aggregations.get("balanceAvg");
System.out.println("平均薪资:"+balanceAvg);
}

2)、添加收集结果的测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码/**
* 测试用账号类
*/
@ToString
@Data
static class Account {
private int account_number;
private int balance;
private String firstname;
private String lastname;
private int age;
private String gender;
private String address;
private String employer;
private String email;
private String city;
private String state;
}

3)、执行测试方法,结果如下:

在这里插入图片描述

在这里插入图片描述

可以看到结果第一行中有一个 boost 参数,这个是系统自动为我们添加的,之前在 kibana 中使用 DSL 语言检索数据的时候是没有的。那么这个 boost 到底是什么呢?
参考官方文档,可以得出结论。
在这里插入图片描述

在这里插入图片描述

再来看查询结果,使用 json 工具格式化可以看到返回符合条件的数据有 4 条,和之前 kibana 中查出的一致:

在这里插入图片描述

以上,便是 SpringBoot 整合 ES 的全部内容,更多高级用法可以参考 ES 的官方文档进行尝试。

更多检索信息请参考 java-rest-high-search

参考文档-java-rest

总结

当然 ES 的在实际的生产中应用广泛:

比如使用 ELK 组件用来进行日志的收集或者进行全文的检索:

在这里插入图片描述

或者用来收集异常信息,做成可视化的界面来提供分析等:

在这里插入图片描述

更多应用场景,还需要和实际的生产结合起来,也需要我们自己去尝试和探索。


参考:

Elasticsearch Reference

elastic

全文搜索引擎 Elasticsearch 入门教程

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Velocity开发指南

发表于 2020-06-01

简介

Velocity 历史悠久的免费java模板引擎。官网:velocity.apache.org/

Velocity是基于Java的模板引擎,这是一种简单而强大的开发工具,可让您轻松创建和呈现用于格式化和显示数据的文档

*.vm :Velocity 模板文件

VTL : Velocity Template Language

使用Velocity模板引擎时的需要关注两部分:Velocity模板和Java代码调用。

Velocity模板由VTL和引擎上下文对象构成;

Java代码调用部分则负责初始Velocity引擎、构建引擎上下文对象、加载Velocity模板和启动模版渲染。

而Velocity模板与Java代码调用部分通信的纽带就是引擎上下文对象了。

Velocity被移植到不同的平台上,如.Net的NVelocity和js的Velocity.js,虽然各平台在使用和实现上略有差别,但大部分语法和引擎核心的实现是一致的,因此学习成本降低不少哦。

版本要求

Velocity2.1以上版本要求jdk1.8以上

Velocity2.2的maven 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码<dependency>  
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.2</version>
</dependency>
​<dependency>
<groupId>org.apache.velocity.tools</groupId>
<artifactId>velocity-tools-generic</artifactId>
<version>3.0</version>
</dependency>​
<dependency>
<groupId>org.apache.velocity.tools</groupId>
<artifactId>velocity-tools-view</artifactId>
<version>3.0</version>
</dependency>​
<dependency>
<groupId>org.apache.velocity.tools</groupId>
<artifactId>velocity-tools-view-jsp</artifactId>
<version>3.0</version>
</dependency>

其它所有版本官方下载地址:archive.apache.org/dist/veloci…

Velocity1.7要求jdk1.4版本以上。官网地址:velocity.apache.org/engine/1.7/

1.7 api doc地址:tool.oschina.net/apidocs/api…

快速入门

Example2.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
复制代码import java.io.StringWriter;
import org.apache.velocity.app.Velocity;
import org.apache.velocity.VelocityContext;

public class Example2
{
public static void main( String args[] )
{
/* 首先,初始化运行时引擎,使用默认的配置 */

Velocity.init();

/* 创建Context对象,然后把数据放进去 */

VelocityContext context = new VelocityContext();

context.put("name", "Velocity");
context.put("project", "Jakarta");

/* 渲染模板 */

StringWriter w = new StringWriter();

Velocity.mergeTemplate("testtemplate.vm", context, w );
System.out.println(" template : " + w );

/* 渲染字符串 */

String s = "We are using $project $name to render this.";
w = new StringWriter();
Velocity.evaluate( context, w, "mystring", s );
System.out.println(" string : " + w );
}
}

testtemplate.vm

1
复制代码Hi! This $name from the $project project.

总结

首先,你还是需要先创建一个context,放进你需要的数据。

然后合并内容

Velocity的Java编码

以下内容转自:www.cnblogs.com/fsjohnhuang… 肥仔John

模板与宿主环境通信

模板指的是使用VTL编写的Velocity模板,宿主环境指的是Java代码调用部分。而两者通信的纽带就是引擎上下文对象( VelocityContext实例 ),下面是常用的 VelocityContext实例 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码// 构造函数,入参为上下文的键值对集
VelocityContext(Map context)
// 添加上下文的键值对
Object put(String key, Object value)
// 从上下文获取指定键的值
Object get(String key)
// 检查上下文中是否存在指定的键值对
boolean containsKey(Object key)
// 获取所有键
Object[] getKeys()
// 移除指定键
Object remove(Object key)
// 获取上下文链中邻近的上下文对象
Context getChainedContext()

宿主环境向模板传值

1
2
3
4
5
6
7
复制代码// 1. 通过构造函数传值
HashMap<String, String> baseCtx = new HashMap<String, String>();
baseCtx.put("version", "1");
VelocityContext ctx = new VelocityContext(baseCtx);

// 2. 通过put传值
ctx.put("author", "fsjohnhuang");

注意键值对中值的数据类型为

Integer、Long等简单数据类型的装箱类型;

String类型;

Object子类;

Object[] 数组类型,从1.6开始Velocity将数组类型视为 java.util.List 类型看待,因此模板中可调用 size() 、 get(intindex) 和 isEmpty() 的变量方法;

java.util.Collection子类;

java.util.Map子类;

java.util.Iterator对象;

java.util.Enumeration对象。

除此之外,我们还可以将一个静态类赋予到上下文对象中,如 java.lang.Math静态类

1
复制代码ctx.put("Math", java.lang.Math.class);

模板向宿主环境传值

1. 通信示例1——通过引擎上下文对象获取变量

模板文件frm.vm

1
2
3
4
复制代码#set($action="./submit")
<form action="$action">
.........
</form>

Java代码部分

1
2
3
4
5
6
复制代码VelocityContext ctx = new VelocityContext();
VelocityEngine ve = new VelocityEngine();
StringWriter sw = new StringWriter();
ve.mergeTemplate("frm.vm", ctx, sw);
String actionStr = ctx.get("action");
System.out.println(actionStr); // 显示./submit

2. 通信示例2——通过副作用修改变量、属性值

模板文件change.vm

1
2
复制代码$people.put("john", "john huang")
#set($version = $version + 1)

Java代码部分

1
2
3
4
5
6
7
8
9
复制代码VelocityContext ctx = new VelocityContext();
ctx.put("version", 1);
HashMap<String, String> people = new HashMap<String,String>();
ctx.put("people", people);
VelocityEngine ve = new VelocityEngine();
StringWriter sw = new StringWriter();
ve.mergeTemplate("change.vm", ctx, sw);
System.out.println(ctx.get("version")); // 显示2
System.out.println(people.get("john")); //显示john huang

上述示例表明在模板中对引用类型实例进行操作时,操作结果将影响到该引用类型实例本身,因此必须谨慎操作。

引擎上下文链

也叫容器链,目前最常用的就是提供层次数据访问和工具箱

1
2
3
4
5
6
7
8
9
10
11
12
复制代码VelocityContext context1 = new VelocityContext();

context1.put("name","Velocity");
context1.put("project", "Jakarta");
context1.put("duplicate", "I am in context1");

VelocityContext context2 = new VelocityContext( context1 );

context2.put("lang", "Java" );
context2.put("duplicate", "I am in context2");

template.merge( context2, writer );

所谓引擎上下文链就是将原有的上下文对象赋予给新建的上下文对象,从而达到上下文内的键值对复用。具体代码如下:

1
2
3
4
5
6
7
8
复制代码VelocityContext ctx1 = new VelocityContext();
ctx1.put("name", "fsjohuang");
ctx1.put("version", 1);
VelocityContext ctx2 = new VelocityContext(ctx1);
ctx2.put("version", 2);

System.out.println(ctx2.get("name")); // 显示fsjohnhuang
System.out.println(ctx2.get("version")); // 显示2

就是当前上下文对象没有该键值对时,则查询上下文链的对象有没有该键值对,有则返回,无则继续找链上的其他上下文对象,直到找到该键值对或遍历完所有链上的上下文对象。

但VelocityContext实例除了put、get方法外,还有remove、getKeys、containsKey方法,它们的行为又是如何的呢?下面我们通过源码来了解吧!

官网中涉及的java编码部分

自定义属性

/opt/templates

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码...

import java.util.Properties;
...

public static void main( String args[] )
{
/* 首先,我们还是初始化运行时引擎 */

Properties p = new Properties();
p.setProperty("file.resource.loader.path", "/opt/templates");
Velocity.init( p );

...

虽然Velocity允许你创建自己的容器类来满足特殊的需求和技术(比如像一个直接访问LDAP服务器的容器),一个叫VelocityContext的基本实现类已经作为发行版的一部分提供给你。

VelocityContext适合所有的一般需求,所以我们强烈推荐你使用VelocityContext这个容器。只有在特殊情况和高级应用中,才需要你扩展或者创建你自己的容器实现。

for和foreach()遍历对象的支持

Velocity支持几种集合类型在VTL中使用foreach()语句:

Object []

普通对象数组 如果一个类中提供了迭代器接口,Velocity会自动包装你的数组

Velocity现在允许模板设计者把数组当作定长链表来处理(Velocity 1.6中就是这样)

java.util.Collection

Velocity通过iterator()方法返回一个迭代器在循环中使用

java.util.Map

Velocity通过接口的values()方法返回一个Collection接口,iterator()方法在它上面调用来检索用于循环的迭代器。

java.util.Iterator

目前只是暂时支持,迭代器不能重置

如果一个未初始化的迭代器被放进了容器,并且在多个foreach()语句中使用,如果第一个foreach()失败了,后面的都会被阻塞,因为迭代器不会重启

java.util.Enumeration

和java.util.Iterator一样

对于Iterator和Enumeration,推荐只有在万不得已的情况下才把它们放进容器,你也应该尽可能地让Velocity找到合适的、可复用的迭代接口。

1
2
3
4
5
6
复制代码Vector v = new Vector();
v.addElement("Hello");
v.addElement("There");

context.put("words", v.iterator() );//不推荐
context.put("words", v );

对静态类的支持

context.put(“Math”, Math.class);

这样你就可以在模板中用$Math引用调用java.lang.Math中的任何公有静态方法。

java.lang.Math这样的类不提供任何公有的构造函数,但是它包含了有用的静态方法

Servlet使用Velocity

web.xml 中配置Velocity

velocity.apache.org/tools/devel…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
复制代码<!-- Define Velocity template handler -->
<servlet>
<servlet-name>velocity</servlet-name>
<servlet-class>
org.apache.velocity.tools.view.VelocityViewServlet
</servlet-class>

<!--
Unless you plan to put your tools.xml and velocity.properties
under different folders or give them different names, then these
two init-params are unnecessary. The
VelocityViewServlet will automatically look for these files in
the following locations.
-->
<init-param>
<param-name>org.apache.velocity.toolbox</param-name>
<param-value>/WEB-INF/tools.xml</param-value>
</init-param>

<init-param>
<param-name>org.apache.velocity.properties</param-name>
<param-value>/WEB-INF/velocity.properties</param-value>
</init-param>
</servlet>

<!-- Map *.vm files to Velocity -->
<servlet-mapping>
<servlet-name>velocity</servlet-name>
<url-pattern>*.vm</url-pattern>
</servlet-mapping>

tools.xml就像定义了一个工具箱,里面放着很多工具,比如有个“扳手”。

具体示例:考虑考虑让我们的朋友乔恩(Jon)从真实的工具箱中抓取我们的“扳手”。乔恩只需要知道我们想要哪个扳手。他不需要知道扳手做什么,也不需要知道我们打算如何做。

Velocity Toolbox的工作方式与上面的例子相同,我们仅需指定所需的工具,然后Velocity引擎就可以在vm模板中使用任何在工具箱Tool.xml中定义好的公共方法来处理其余的工作。

PipeWrench.java

1
2
3
4
5
复制代码public class PipeWrench {
public String getSize() {
return "Large Pipe Wrench!";
}
}

tools.xml

1
2
3
4
5
6
复制代码<?xml version="1.0"?>
<tools>
<toolbox scope="application">
<tool key="wrench" class="PipeWrench"/>
</toolbox>
</tools>

.vm模板中可以使用:

$wrench.size.

VM模板

官方VTL指南:

velocity.apache.org/engine/2.2/…

VTL: Velocity Template Language

以下内容转自:www.cnblogs.com/fsjohnhuang… 肥仔John

注释

1. 行注释

1
复制代码## 行注释内容

2. 块注释

1
复制代码#*块注释内容1块注释内容2*#

3. 文档注释

1
复制代码#**文档注释内容1文档注释内容2*#

踩过的坑  块注释和文档注释虽然均不输出到最终结果上,但会导致最终结果出现一行空行。使用行注释则不会出现此情况。

直接输出的内容

也就是不会被引擎解析的内容。

1
复制代码#[[直接输出的内容1直接输出的内容2]]#

引用

引用语句就是对引擎上下文对象中的属性进行操作

语法方面分为常规语法( $属性 )和正规语法( ${属性} )

在普通模式下上述两种写法,当引擎上下文对象中没有对应的属性时,最终结果会直接输出 $属性 或 ${属性} ,若要不输出则需要改写为 $!属性 和 $!{属性} 。

1. 变量(就是引擎上下文对象的属性)

1
2
3
4
复制代码$变量名, 常规写法,若上下文中没有对应的变量,则输入字符串"$变量名"
${变量名}, 常规写法,若上下文中没有对应的变量,则输入字符串"${变量名}"
$!变量名, 常规写法,若上下文中没有对应的变量,则输入空字符串""
$!{变量名}, 常规写法,若上下文中没有对应的变量,则输入空字符串""

变量的命名规则: 由字母、下划线(_)、破折号(-)和数字组成,而且以字母开头。

变量的数据类型为:

Integer、Long等简单数据类型的装箱类型;

String类型;

Object子类;

Object[] 数组类型,从1.6开始Velocity将数组类型视为 java.util.List 类型看待,因此模板中可调用 size() 、 get(int index) 和 isEmpty() 的变量方法;

java.util.Collection子类;

java.util.Map子类;

java.util.Iterator对象;

java.util.Enumeration对象。

2. 属性(就是引擎上下文对象的属性的属性)

1
2
3
4
复制代码$变量名.属性, 常规写法
${变量名.属性}, 正规写法
$!变量名.属性, 常规写法
$!{变量名.属性}, 正规写法

属性搜索规则:

Velocity采用一种十分灵活的方式搜索变量的属性, 具体如下:

// 假如引用$var.prop,那么Velocity将对prop进行变形,然后在$var对象上尝试调用
// 变形和尝试的顺序如下

  1. $var.getprop()
  2. $var.getProp()
  3. $var.get(“prop”)
  4. $var.isProp()

// 对于$var.Prop则如下

  1. $var.getProp()
  2. $var.getprop()
  3. $var.get(“Prop”)
  4. $var.isProp()

因此获取 java.util.Map 对象的键值时可以简写为 $map.key ,Velocity会自动转为 $map.get(“key”) 来搜索!

3. 方法(就是引擎上下文对象的属性的方法)

1
2
3
4
复制代码$变量名.方法([入参1[, 入参2]*]?), 常规写法
${变量名.方法([入参1[, 入参2]*]?)}, 正规写法
$!变量名.方法([入参1[, 入参2]*]?), 常规写法
$!{变量名.方法([入参1[, 入参2]*]?)}, 正规写法

引用方法实际就是方法调用操作,关注点返回值、入参和副作用的情况如下:

  1. 方法的返回值将输出到最终结果中
  2. 入参的数据类型
1
2
3
4
复制代码$变量 或 $属性,数据类型参考第一小节;
范围操作符(如:[1..2]或[$arg1..$arg2]),将作为java.util.ArrayList处理
字典字面量(如:{a:"a",b:"b"}),将作为java.util.Map处理
数字字面量(如:1),将自动装箱或拆箱匹配方法定义中的int或Integer入参
  1. 副作用
1
2
复制代码// 若操作如java.util.Map.put方法,则会修改Java代码部分中的Map对象键值对
$map.put("key", "new value")

指令

指令主要用于定义重用模块、引入外部资源、流程控制。指令以 # 作为起始字符。

#set:向引擎上下文对象添加属性或对已有属性进行修改

格式: #set($变量 = 值) ,具体示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码#set($var1 = $other)
#set($var1.prop1 = $other)
#set($var = 1)
#set($var = true)
#set($var = [1,2])
#set($var = {a:"a", b:"b"})
#set($var = [1..3])
#set($var = [$arg1..$arg2])
#set($var = $var1.method())
#set($var = $arg1 + 1)
#set($var = "hello")
#set($var = "hello $var1") // 双引号可实现字符串拼接(coffeescript也是这样哦!),假设$var1为fsjohnhuang,则$var为hello fsjohnhuang
#set($var = 'hello $var1') // 单引号将不解析其中引用,假设$var1为fsjohnhuang,则$var为hello $var1

作用域明显是全局有效的。

#if:条件判断

格式:

1
2
3
4
5
6
7
复制代码#if(判断条件)
.........
#elseif(判断条件)
.........
#else
.........
#end

通过示例了解判断条件:

1
2
3
4
5
6
7
8
9
10
11
复制代码#if($name)   //$name不为false、null和undefined则视为true
$name
#elseif($job == "net") // ==和!=两边的变量将调用其toString(),并对比两者的返回值
Net工程师
#elseif($age <= 16) // 支持<=,>=,>,<逻辑运算符
未成年劳工
#elseif(!$married) // 支持!逻辑运算符
未婚
#elseif($age >= 35 && !$married) // 支持&&,||关系运算符
大龄未婚青年
#end

#foreach:循环

格式:

1
2
3
复制代码#foreach($item in $items)
..........
#end

$item 的作用范围为#foreach循环体内。

$items 的数据类型为 Object[]数组 、 [1..2] 、 [1,2,3,4] 、 {a:”a”,b:”b”} 和含 public Iterator iterator() 方法的对象,具体如下:

1
2
3
4
复制代码java.util.Collection子类,Velocity会调用其iterator方法获取Iterator对象
java.util.Map子类,Velocity会调用value()获取Collection对象,然后调用调用其iterator方法获取Iterator对象
java.util.Iterator对象,直接将该Iterator对象添加到上下文对象中时,由于Iterator对象为只进不退的操作方式,因此无法被多个#foreach指令遍历
java.util.Enumeration对象,直接将该Enumeration对象添加到上下文对象中时,由于Iterator对象为只进不退的操作方式,因此无法被多个#foreach指令遍历

内置属性$foreach.count ,用于指示当前循环的次数,从0开始。可以通过配置项 directive.foreach.maxloops 来限制最大的循环次数,默认值为-1(不限制)。

示例——使用Vector和Iterator的区别:

模板:

1
2
3
4
5
6
复制代码#macro(show)
#foreach($letter in $letters)
$letter
#end
#end
#show()

java代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码Vector<String> v = new Vector<String>();
v.add("a");
v.add("b");
VelocityContext ctx = new VelocityContext();
ctx.put("letters",v);
Template t = Velocity.getTemplate("模板路径");
StringWriter sw = new StringWriter();
t.merge(ctx,sw);
System.out.println(sw.toString());
// 结果
// a
// b
// a
// b

ctx.put("letters",v.iterator());
// 结果
// a
//

#break:跳出循环

1
2
3
4
5
6
复制代码#foreach($item in $items)
#if($item == "over")
#break;
#end
$item
#end

#stop:中止模板解析操作

1
2
3
4
5
6
复制代码#set($cmd="stop")
$cmd
#if($cmd == "stop")
#stop
#end
$cmd // 该语句将不执行

#include引入外部资源

(引入的资源不被引擎所解析)

格式: #include(resource[ otherResource]*)

resource、otherResource可以为单引号或双引号的字符串,也可以为$变量,内容为外部资源路径。注意为相对路径,则以引擎配置的文件加载器加载路径作为参考系,而不是当前模板文件的路径为参考系。

#parse引入外部资源

(引入的资源将被引擎所解析)

格式: #parse(resource)

resource可以为单引号或双引号的字符串,也可以为$变量,内容为外部资源路径。注意为相对路径,则以引擎配置的文件加载器加载路径作为参考系,而不是当前模板文件的路径为参考系。

由于#parse指令可能会引起无限递归引入的问题,因此可通过配置项 directive.parse.max.depth来限制最大递归引入次数,默认值为10.

#macro:定义重用模块(可带参数)

定义格式:

1
2
3
复制代码#macro(宏名 [$arg[ $arg]*]?)
.....
#end

调用格式:

1
复制代码#宏名([$arg[ $arg]]?)

示例1——定义与调用位于同一模板文件时,无需遵守先定义后使用的规则:

1
2
3
4
复制代码#log("What a happy day")
#macro(log $msg)
log message: $msg
#end

示例2——定义与调用位于不同的模板文件时,需要遵守先定义后使用的规则:

1
2
3
4
5
6
复制代码## 模板文件macro.vm#macro(log $msg)
log message: $msg
#end
## 模板文件main.vm
#parse("macro.vm")
#log("What a happy day")

原理解析:Velocity引擎会根据模板生成语法树并缓冲起来然后再执行,因此宏定义和调用位于同一模板文件时,调用宏的时候它已经被引擎识别并初始化了(类似js中的hosit)。

若定义与调用位于不同的模板文件中时,由于 #parse 是引擎解析模板文件时才被执行来引入外部资源并对其中的宏定义进行初始化,因此必须遵循先定义后使用的规则。

我们可配置全局宏库,配置方式如下:

1
2
3
4
5
复制代码Properties props = new Properties();
// velocimacro.library的值为模板文件的路径,多个路径时用逗号分隔
// velocimacro.library的默认值为VM_global_library.vm
props.setProperty("velocimacro.library", "global_macro1.vm,global_macro2.vm");
VelocityEngine ve = new VelocityEngine(props);

另外#macro还有另一种传参方式——$!bodyContent

1
2
3
4
5
复制代码#macro(say)
$!bodyContent
#end
#@say()Hello World#end
// 结果为Hello World

#define:定义重用模块(不带参数)

1
2
3
4
复制代码#define($log)
hello log!
#end
$log

可视为弱版#macro,一般不用,了解就好了。

#evaluate:动态计算

示例:

1
2
复制代码#set($name = "over")
#evalute("#if($name=='over')over#{else}not over#end") // 输出over

一般不用,了解就好了。

转义符

通过 \ 对 $ 和 #进行转义,导致解析器不对其进行解析处理。

1
2
3
4
5
6
7
8
9
10
复制代码#set($test='hello')
$test ## 结果:hello
\$test ## 结果:$test
\\$test ## 结果:\hello
\\\$test ## 结果:\$test

$!test ## 结果:hello
$\!test ## 结果:$!test
$\\!test ## 结果:$\!test
$\\\!test ## 结果:$\\!test

模板实践

内容引自:www.cnblogs.com/fsjohnhuang… 肥仔

示例结果是生成如下的html表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码<form action="./submit">
<div>
<label for="title">标题:</label>
<input type="text" id="title" name="title"/>
</div>
<div>
<label for="brief">摘要:</label>
<input type="text" id="brief" name="brief"/>
</div>
<div>
<label for="sex">性别:</label>
<select id="sex" name="sex">
<option value="0">男</option>
<option value="1">女</option>
</select>
</div>
<div>
<label for="job">职业:</label>
<select id="job" name="job">
<option value="0">Java工程师</option>
<option value="1">Net工程师</option>
</select>
</div>
</form>

引入依赖项——velocity-1.7-dep.jar

模板文件frm.vm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码##表单模板
##@author fsjohnhuang
##@version 1.0
## 引入外部模板文件
#parse('macro.vm')
## 主逻辑
<form action="$action">
#foreach($input in $inputs)
#input($input.title $input.id)
#end
#foreach($select in $selects)
#select($select.title $select.id $select.items)
#end
</form>

模板文件macro.vm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码## 生成input表单元素区域的宏
#macro(input $title $id)
<div>
<label for="$id">$title</label>
<input type="text" id="$id" name="$id"/>
</div>
#end
## 生成select表单元素区域的宏
#macro(select $title $id $items)
<div>
<label for="$id">$title</label>
<select id="$id" name="$id">
## VTL指令紧贴左侧才能确保结果的排版正常(不会有多余空格)
#foreach($key in $items.keySet())
<option value="$key">$items.get($key)</option>
#end
</select>
</div>
#end

Java代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
复制代码public static void main(String[] args) {
// 初始化模板引擎
Properties props = new Properties();
props.put("file.resource.loader.path", ".\\vm");
VelocityEngine ve = new VelocityEngine(props);
// 配置引擎上下文对象
VelocityContext ctx = new VelocityContext();
ctx.put("action", "./submit");
ArrayList<HashMap<String, String>> inputs = new ArrayList<HashMap<String,String>>();
HashMap<String, String> input1 = new HashMap<String, String>();
input1.put("id", "title");
input1.put("title", "标题:");
inputs.add(input1);
HashMap<String, String> input2 = new HashMap<String, String>();
input2.put("id", "brief");
input2.put("title", "摘要:");
inputs.add(input2);
ctx.put("inputs", inputs);
ArrayList<HashMap<String, Object>> selects = new ArrayList<HashMap<String,Object>>();
HashMap<String, Object> select1 = new HashMap<String, Object>();
selects.add(select1);
select1.put("id", "sex");
select1.put("title", "性别:");
HashMap<Integer, String> kv1 = new HashMap<Integer, String>();
kv1.put(0, "男");
kv1.put(1, "女");
select1.put("items", kv1);
HashMap<String, Object> select2 = new HashMap<String, Object>();
selects.add(select2);
select2.put("id", "job");
select2.put("title", "职业:");
HashMap<Integer, String> kv2 = new HashMap<Integer, String>();
kv2.put(0, "Java工程师");
kv2.put(1, "Net工程师");
select2.put("items", kv2);
ctx.put("selects", selects);
// 加载模板文件
Template t = ve.getTemplate("test.vm");
StringWriter sw = new StringWriter();
// 渲染模板
t.merge(ctx, sw);
System.out.print(sw.toString());
}

参考文章

开发指南原文地址:

velocity.apache.org/engine/deve…

用户指南原文地址:

velocity.apache.org/engine/deve…

中文翻译开发指南地址:

ifeve.com/velocity-gu…

肥仔 john优秀网文地址:

www.cnblogs.com/fsjohnhuang…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

是时候使用 Lumen 7 + API Resource 开

发表于 2020-06-01

使用 Lumen 7 + Api Resource  进行 API 开发

使用 Lumen 7 + Api Resource 进行 API 开发

写在前面

工作中使用 Laravel 开发 API 项目已经有些年头了,发现每次启动新的 Api 项目的时都会在 Laravel 基础上进行一些预处理,包括针对 API 项目的结构设计,统一响应结构的封装,异常的捕获处理以及授权模块的配置等。总是在做一些重复的工作,那索性将这些常用的基础封装做成一个「启动模板」好了。

项目地址:戳这儿

更新内容

  • 规范 Reponse 中的 message 提示(2020-06-02)

为什么是 Lumen ?

现如今,中大型项目中通常使用前后端分离方式开发,前后端分别通过不同的代码仓库各自维护着项目代码,Laravel 只负责项目中的 API 部分,提供 API 给前端调用。这种场景下,使用 Laravel 进行开发 API 稍微显得有点“臃肿”了。

相比之下,Lumen 针对项目中的 API 开发场景,精简了Laravel 中的很多部分,更适合 API 开发。有了 Laravel 使用经验,切换到 Lumen 也较为容易。

概览

  • 适配 Laravel 7 中新增的 HttpClient 客户端
  • 使用 Laravel 原生的 Api Resource
  • 规范统一的响应结构
  • 使用 Jwt-auth 方式授权
  • 支持日志记录到 MongoDB
  • 合理有效地『Repository & Service』架构设计(😏)

规范的响应结构

摘选自:RESTful 服务最佳实践

code——包含一个整数类型的HTTP响应状态码。
status——包含文本:”success”,”fail”或”error”。HTTP状态响应码在500-599之间为”fail”,在400-499之间为”error”,其它均为”success”(例如:响应状态码为1XX、2XX和3XX)。
message——当状态值为”fail”和”error”时有效,用于显示错误信息。参照国际化(il8n)标准,它可以包含信息号或者编码,可以只包含其中一个,或者同时包含并用分隔符隔开。
data——包含响应的body。当状态值为”fail”或”error”时,data仅包含错误原因或异常名称。

说明

整体响应结构设计参考如上,相对严格地遵守了 RESTful 设计准则,返回合理的 HTTP 状态码。

考虑到业务通常需要返回不同的“业务描述处理结果”,在所有响应结构中都支持传入符合业务场景的message。

  • data:
    • 查询单条数据时直接返回对象结构,减少数据层级;
    • 查询列表数据时返回数组结构;
    • 创建或更新成功,返回修改后的数据;(也可以不返回数据直接返回空对象)
    • 删除成功时返回空对象
  • status:
    • error, 客服端出错,HTTP 状态响应码在400-599之间。如,传入错误参数,访问不存在的数据资源等
    • fail,服务端出错,HTTP 状态响应码在500-599之间。如,代码语法错误,空对象调用函数,连接数据库失败,undefined index等
    • success, HTTP 响应状态码为1XX、2XX和3XX,用来表示业务处理成功。
  • message: 描述执行的请求操作处理的结果;也可以支持国际化,根据实际业务需求来切换。
  • code: HTTP 响应状态码;可以根据实际业务需求,调整成业务操作码

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
复制代码<?php


namespace App\Http;

use Illuminate\Http\Resources\Json\JsonResource;
use \Illuminate\Http\Response as HttpResponse;

class Response
{
public function errorNotFound($message = 'Not Found')
{
$this->fail($message, HttpResponse::HTTP_NOT_FOUND);
}

/**
* @param string $message
* @param int $code
* @param null $data
* @param array $header
* @param int $options
* @throws \Illuminate\Http\Exceptions\HttpResponseException
*/
public function fail(string $message = '', int $code = HttpResponse::HTTP_INTERNAL_SERVER_ERROR, $data = null, array $header = [], int $options = 0)
{
$status = ($code >= 400 && $code <= 499) ? 'error' : 'fail';
$message = (!$message && isset(HttpResponse::$statusTexts[$code])) ? HttpResponse::$statusTexts[$code] : 'Service error';

response()->json([
'status' => $status,
'code' => $code,
'message' => $message,// 错误描述
'data' => (object) $data,// 错误详情
], $code, $header, $options)->throwResponse();
}

public function errorBadRequest($message = 'Bad Request')
{
$this->fail($message, HttpResponse::HTTP_BAD_REQUEST);
}

public function errorForbidden($message = 'Forbidden')
{
$this->fail($message, HttpResponse::HTTP_FORBIDDEN);
}

public function errorInternal($message = 'Internal Error')
{
$this->fail($message, HttpResponse::HTTP_INTERNAL_SERVER_ERROR);
}

public function errorUnauthorized($message = 'Unauthorized')
{
$this->fail($message, HttpResponse::HTTP_UNAUTHORIZED);
}

public function errorMethodNotAllowed($message = 'Method Not Allowed')
{
$this->fail($message, HttpResponse::HTTP_METHOD_NOT_ALLOWED);
}

public function accepted($message = 'Accepted')
{
return $this->success(null, $message, HttpResponse::HTTP_ACCEPTED);
}

/**
* @param JsonResource|array|null $data
* @param string $message
* @param int $code
* @param array $headers
* @param int $option
* @return \Illuminate\Http\JsonResponse|JsonResource
*/
public function success($data, string $message = '', $code = HttpResponse::HTTP_OK, array $headers = [], $option = 0)
{
$message = (!$message && isset(HttpResponse::$statusTexts[$code])) ? HttpResponse::$statusTexts[$code] : 'OK';
$additionalData = [
'status' => 'success',
'code' => $code,
'message' => $message
];

if ($data instanceof JsonResource) {
return $data->additional($additionalData);
}

return response()->json(array_merge($additionalData, ['data' => $data ?: (object) $data]), $code, $headers, $option);
}

/**
* @param JsonResource|array|null $data
* @param string $message
* @param string $location
* @return \Illuminate\Http\JsonResponse|JsonResource
*/
public function created($data = null, $message = 'Created', string $location = '')
{
$response = $this->success($data, $message, HttpResponse::HTTP_CREATED);
if ($location) {
$response->header('Location', $location);
}

return $response;
}

public function noContent($message = 'No content')
{
return $this->success(null, $message, HttpResponse::HTTP_NO_CONTENT);
}
}

使用

在需要进行 HTTP 响应的地方使用 \\App\\Traits\\Helpers对\\App\\Http\\Response中封装的响应方法进行调用。

通常使用是在 Controller 层中根据业务处理的结果进行响应,所以在 \\App\\Http\\Controllers基类中已经引入了 Helperstrait,可以直接在 Controller 中进行如下调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码// 操作成功情况
$this->response->success($data,$message);
$this->response->created($data,$message);
$this->response->accepted($message);
$this->response->noContent($message);

// 操作失败或异常情况
$this->response->fail($message);
$this->response->errorNotFound();
$this->response->errorBadRequest();
$this->response->errorForbidden();
$this->response->errorInternal();
$this->response->errorUnauthorized();
$this->response->errorMethodNotAllowed();

操作成功时的响应结构

  • 返回单条数据
1
2
3
4
5
6
7
8
9
复制代码{
"data": {
"nickname": "Jiannei",
"email": "longjian.huang@foxmail.com"
},
"status": "success",
"code": 200,
"message": "成功"
}
  • 返回列表数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码{
"data": [
{
"nickname": "Jiannei",
"email": "longjian.huang@foxmail.com"
},
{
"nickname": "Qian",
"email": "1234567891@foxmail.com"
},
{
"nickname": "Turbo",
"email": "123456789@foxmail.com"
}
// ...
],
"links": {
"first": "http://lumen-api.test/users?page=1",
"last": null,
"prev": null,
"next": null
},
"meta": {
"current_page": 1,
"from": 1,
"path": "http://lumen-api.test/users",
"per_page": 15,
"to": 13
},
"status": "success",
"code": 200,
"message": "成功"
}

操作失败时的响应结构

1
2
3
4
5
6
复制代码{
"status": "fail",
"code": 500,
"message": "Service error",
"data": {}
}

异常捕获时的响应结构

整体格式与业务操作成功和业务操作失败时的一致,相比失败时,data 部分会增加额外的异常信息展示,方便项目开发阶段进行快速地问题定位。

  • 自定义实现了 ValidationException 的响应结构
1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码{
"status": "error",
"code": 422,
"message": "Validation error",
"data": {
"email": [
"The email has already been taken."
],
"password": [
"The password field is required."
]
}
}
  • NotFoundException异常捕获的响应结构

关闭 debug 时:

1
2
3
4
5
6
7
8
复制代码{
"status": "error",
"code": 404,
"message": "Service error",
"data": {
"message": "No query results for model [App\\Models\\User] 19"
}
}

开启 debug 时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
复制代码{
"status": "error",
"code": 404,
"message": "Service error",
"data": {
"message": "No query results for model [App\\Models\\User] 19",
"exception": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException",
"file": "/var/www/lumen-api-starter/vendor/laravel/lumen-framework/src/Exceptions/Handler.php",
"line": 107,
"trace": [
{
"file": "/var/www/lumen-api-starter/app/Exceptions/Handler.php",
"line": 55,
"function": "render",
"class": "Laravel\\Lumen\\Exceptions\\Handler",
"type": "->"
},
{
"file": "/var/www/lumen-api-starter/vendor/laravel/lumen-framework/src/Routing/Pipeline.php",
"line": 72,
"function": "render",
"class": "App\\Exceptions\\Handler",
"type": "->"
},
{
"file": "/var/www/lumen-api-starter/vendor/laravel/lumen-framework/src/Routing/Pipeline.php",
"line": 50,
"function": "handleException",
"class": "Laravel\\Lumen\\Routing\\Pipeline",
"type": "->"
}
// ...
]
}
}
  • 其他类型异常捕获时的响应结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
复制代码{
"status": "fail",
"code": 500,
"message": "syntax error, unexpected '$user' (T_VARIABLE)",
"data": {
"message": "syntax error, unexpected '$user' (T_VARIABLE)",
"exception": "ParseError",
"file": "/var/www/lumen-api-starter/app/Http/Controllers/UsersController.php",
"line": 34,
"trace": [
{
"file": "/var/www/lumen-api-starter/vendor/composer/ClassLoader.php",
"line": 322,
"function": "Composer\\Autoload\\includeFile"
},
{
"function": "loadClass",
"class": "Composer\\Autoload\\ClassLoader",
"type": "->"
},
{
"function": "spl_autoload_call"
}
// ...
]
}
}

特别说明:使用 Postman 等 Api 测试工具的使用需要添加 X-Requested-With:XMLHttpRequest或者Accept:application/jsonheader 信息来表明是 Api 请求,否则在异常捕获到后返回的可能不是预期的 JSON 格式响应。

丰富的日志模式支持

  • 支持记录日志(包括业务错误记录的日志和捕获的异常信息等)到 MongoDB,方便线上问题的排查
  • 记录到 MongoDB 的日志,支持以每日、每月以及每年按表进行拆分
  • 支持记录 sql 语句

Repository & Service 模式架构

使用了andersao/l5-repository 进行进行项目结构设计,补充添加了 Service 层。

职责说明

待补充。

规范

命名规范:待补充

使用规范:待补充

Packages

  • guzzlehttp/guzzle
  • jenssegers/mongodb
  • tymon/jwt-auth
  • prettus/l5-repository
  • overtrue/laravel-query-logger

其他

依照惯例,如对您的日常工作有所帮助或启发,欢迎单击三连 star + fork + follow。

如果有任何批评建议,通过邮箱(longjian.huang@foxmial.com)的方式(如果我每天坚持看邮件的话)可以联系到我。

总之,欢迎各路英雄好汉。

参考

  • RESTful API 最佳实践
  • RESTful 服务最佳实践
  • DingoApi

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

重学 Java 设计模式:实战单例模式

发表于 2020-06-01

作者:小傅哥

博客:bugstack.cn

沉淀、分享、成长,让自己和他人都能有所收获!😄

一、前言

5个创建型模式的最后一个

在设计模式中按照不同的处理方式共包含三大类;创建型模式、结构型模式和行为模式,其中创建型模式目前已经介绍了其中的四个;工厂方法模式、抽象工厂模式、生成器模式和原型模式,除此之外还有最后一个单例模式。

掌握了的知识才是自己的

在本次编写的重学 Java 设计模式的编写中尽可能多的用各种场景案例还介绍设计的使用,包括我们已经使用过的场景;各种类型奖品发放、多套Redis缓存集群升级、装修公司报价清单和百份考卷题目与答案乱序,通过这些场景案例的实践感受设计模式的思想。但这些场景都是作者通过经验分离出来的,还并不是读者的知识,所以你如果希望可以融会贯通的掌握那么一定要亲力亲为的操作,事必躬亲的完成。

书不是看的是用的

在这里还是想强调一下学习方法,总有很多小伙伴对学习知识有疑惑,明明看了、看的时候也懂了,但到了实际使用的时候却用不上。或者有时候在想是不要是有更加生动的漫画或者什么对比会好些,当然这些方式可能会加快一个新人对知识的理解速度。但只要你把学习视频当电影看、学习书籍当故事看,就很难掌握这项技术栈。只有你把它用起来,逐字逐句的深挖,一点点的探求,把各项遇到的盲点全部扫清,才能让你真的掌握这项技能。

二、开发环境

  1. JDK 1.8
  2. Idea + Maven
  3. 涉及工程1个,可以通过关注公众号:bugstack虫洞栈,回复源码下载获取(打开获取的链接,找到序号18)

三、单例模式介绍

单例模式,图片来自 refactoringguru.cn

单例模式可以说是整个设计中最简单的模式之一,而且这种方式即使在没有看设计模式相关资料也会常用在编码开发中。

因为在编程开发中经常会遇到这样一种场景,那就是需要保证一个类只有一个实例哪怕多线程同时访问,并需要提供一个全局访问此实例的点。

综上以及我们平常的开发中,可以总结一条经验,单例模式主要解决的是,一个全局使用的类频繁的创建和消费,从而提升提升整体的代码的性能。

四、案例场景

本章节的技术所出现的场景非常简单也是我们日常开发所能见到的,例如;

  1. 数据库的连接池不会反复创建
  2. spring中一个单例模式bean的生成和使用
  3. 在我们平常的代码中需要设置全局的的一些属性保存

在我们的日常开发中大致上会出现如上这些场景中使用到单例模式,虽然单例模式并不复杂但是使用面却比较广。

五、7种单例模式实现

单例模式的实现方式比较多,主要在实现上是否支持懒汉模式、是否线程安全中运用各项技巧。当然也有一些场景不需要考虑懒加载也就是懒汉模式的情况,会直接使用static静态类或属性和方法的方式进行处理,供外部调用。

那么接下来我们就通过实现不同方式的实现进行讲解单例模式。

0. 静态类使用

1
2
3
4
5
复制代码public class Singleton_00 {

public static Map<String,String> cache = new ConcurrentHashMap<String, String>();

}
  • 以上这种方式在我们平常的业务开发中非常场常见,这样静态类的方式可以在第一次运行的时候直接初始化Map类,同时这里我们也不需要到延迟加载在使用。
  • 在不需要维持任何状态下,仅仅用于全局访问,这个使用使用静态类的方式更加方便。
  • 但如果需要被继承以及需要维持一些特定状态的情况下,就适合使用单例模式。

1. 懒汉模式(线程不安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public class Singleton_01 {

private static Singleton_01 instance;

private Singleton_01() {
}

public static Singleton_01 getInstance(){
if (null != instance) return instance;
return new Singleton_01();
}

}
  • 单例模式有一个特点就是不允许外部直接创建,也就是new Singleton_01(),因此这里在默认的构造函数上添加了私有属性 private。
  • 目前此种方式的单例确实满足了懒加载,但是如果有多个访问者同时去获取对象实例你可以想象成一堆人在抢厕所,就会造成多个同样的实例并存,从而没有达到单例的要求。

2. 懒汉模式(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public class Singleton_02 {

private static Singleton_02 instance;

private Singleton_02() {
}

public static synchronized Singleton_02 getInstance(){
if (null != instance) return instance;
return new Singleton_02();
}

}
  • 此种模式虽然是安全的,但由于把锁加到方法上后,所有的访问都因需要锁占用导致资源的浪费。如果不是特殊情况下,不建议此种方式实现单例模式。

3. 饿汉模式(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public class Singleton_03 {

private static Singleton_03 instance = new Singleton_03();

private Singleton_03() {
}

public static Singleton_03 getInstance() {
return instance;
}

}
  • 此种方式与我们开头的第一个实例化Map基本一致,在程序启动的时候直接运行加载,后续有外部需要使用的时候获取即可。
  • 但此种方式并不是懒加载,也就是说无论你程序中是否用到这样的类都会在程序启动之初进行创建。
  • 那么这种方式导致的问题就像你下载个游戏软件,可能你游戏地图还没有打开呢,但是程序已经将这些地图全部实例化。到你手机上最明显体验就一开游戏内存满了,手机卡了,需要换了。

4. 使用类的内部类(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码public class Singleton_04 {

private static class SingletonHolder {
private static Singleton_04 instance = new Singleton_04();
}

private Singleton_04() {
}

public static Singleton_04 getInstance() {
return SingletonHolder.instance;
}

}
  • 使用类的静态内部类实现的单例模式,既保证了线程安全有保证了懒加载,同时不会因为加锁的方式耗费性能。
  • 这主要是因为JVM虚拟机可以保证多线程并发访问的正确性,也就是一个类的构造方法在多线程环境下可以被正确的加载。
  • 此种方式也是非常推荐使用的一种单例模式

5. 双重锁校验(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码public class Singleton_05 {

private volatile static Singleton_05 instance;

private Singleton_05() {
}

public static Singleton_05 getInstance(){
if(null != instance) return instance;
synchronized (Singleton_05.class){
if (null == instance){
instance = new Singleton_05();
}
}
return instance;
}

}
  • 双重锁的方式是方法级锁的优化,减少了部分获取实例的耗时。
  • 同时这种方式也满足了懒加载。
  • volatile关键字会强制的保证线程的可见性,而不加这个关键字,JVM也会尽力去保证可见性,但如果CPU一直处于繁忙状态就不确定了。

6. CAS「AtomicReference」(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码public class Singleton_06 {

private static final AtomicReference<Singleton_06> INSTANCE = new AtomicReference<Singleton_06>();

private static Singleton_06 instance;

private Singleton_06() {
}

public static final Singleton_06 getInstance() {
for (; ; ) {
Singleton_06 instance = INSTANCE.get();
if (null != instance) return instance;
INSTANCE.compareAndSet(null, new Singleton_06());
return INSTANCE.get();
}
}

public static void main(String[] args) {
System.out.println(Singleton_06.getInstance()); // org.itstack.demo.design.Singleton_06@2b193f2d
System.out.println(Singleton_06.getInstance()); // org.itstack.demo.design.Singleton_06@2b193f2d
}

}
  • java并发库提供了很多原子类来支持并发访问的数据安全性;AtomicInteger、AtomicBoolean、AtomicLong、AtomicReference。
  • AtomicReference 可以封装引用一个V实例,支持并发访问如上的单例方式就是使用了这样的一个特点。
  • 使用CAS的好处就是不需要使用传统的加锁方式保证线程安全,而是依赖于CAS的忙等算法,依赖于底层硬件的实现,来保证线程安全。相对于其他锁的实现没有线程的切换和阻塞也就没有了额外的开销,并且可以支持较大的并发性。
  • 当然CAS也有一个缺点就是忙等,如果一直没有获取到将会处于死循环中。

7. Effective Java作者推荐的枚举单例(线程安全)

1
2
3
4
5
6
7
8
复制代码public enum Singleton_07 {

INSTANCE;
public void test(){
System.out.println("hi~");
}

}

约书亚·布洛克(英语:Joshua J. Bloch,1961年8月28日-),美国著名程序员。他为Java平台设计并实作了许多的功能,曾担任Google的首席Java架构师(Chief Java Architect)。

  • Effective Java 作者推荐使用枚举的方式解决单例模式,此种方式可能是平时最少用到的。
  • 这种方式解决了最主要的;线程安全、自由串行化、单一实例。

调用方式

1
2
3
复制代码@Test
public void test() {
Singleton_07.INSTANCE.test();

这种写法在功能上与共有域方法相近,但是它更简洁,无偿地提供了串行化机制,绝对防止对此实例化,即使是在面对复杂的串行化或者反射攻击的时候。虽然这中方法还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。

但也要知道此种方式在存在继承场景下是不可用的。

六、总结

  • 虽然只是一个很平常的单例模式,但在各种的实现上真的可以看到java的基本功的体现,这里包括了;懒汉、饿汉、线程是否安全、静态类、内部类、加锁、串行化等等。
  • 在平时的开发中如果可以确保此类是全局可用不需要做懒加载,那么直接创建并给外部调用即可。但如果是很多的类,有些需要在用户触发一定的条件后(游戏关卡)才显示,那么一定要用懒加载。线程的安全上可以按需选择。
  • 建议在学习的过程中一定要加以实践,否则很难完完整整的掌握一整套的知识体系。例如案例中的出现的Effective Java一书也非常建议大家阅读。另外推荐下这位大神的Github:github.com/jbloch

七、推荐阅读

  • 重学 Java 设计模式:实战原型模式-模拟考试试卷乱序题目和答案
  • Java开发架构篇:初识领域驱动设计DDD落地
  • Java开发架构篇:DDD模型领域层决策规则树服务设计
  • Java开发架构篇:领域驱动设计架构基于SpringCloud搭建微服务
  • 源码分析(面试常问题目) | Mybatis接口没有实现类为什么可以执行增删改查
  • 讲道理,只要你是一个爱折腾的程序员,毕业找工作真的不需要再花钱培训!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

再见吧 buildSrc, 拥抱 Composing bui

发表于 2020-06-01

前言

长期以来困扰我们的一个问题就是构建速度,AndroidStudio 的构建速度严重影响 Android 开发者的工作效率,尤其是更新一个版本号,导致整个项目重新构建,在网络慢的情况下,这是无法忍受的。

buildSrc 这种方式,在最近几年是非常流行的,因为它有以下优点:

  • 共享 buildSrc 库工件的引用,全局只有一个地方可以修改它
  • 支持 AndroidStudio 自动补全

  • 支持 AndroidStudio 单击跳转

有优点的同时也有缺点,来看一下 Gradle 文档

A change in buildSrc causes the whole project to become out-of-date. Thus, when making small incremental changes, the –no-rebuild command-line option is often helpful to get faster feedback. Remember to run a full build regularly or at least when you’re done, though.

buildSrc的更改会导致整个项目过时,因此,在进行小的增量更改时,– –no-rebuild命令行选项通常有助于获得更快的反馈。不过,请记住要定期或至少在完成后运行完整版本。

汇总一句话就是说,buildSrc 依赖更新将重新构建整个项目,那么有没有一种方法支持自动补全和单击跳转,有不用重新构建整个项目,Composing builds 就可以实现,接下来我们来演示一下 buildSrc 和 Composing builds 它们的 build 的时间,相关代码我已经上传到 GitHub 了:ComposingBuilds-vs-buildSrc

通过这篇文章你将学习到以下内容,将在文末总结部分会给出相应的答案

  • 什么是 buildSrc?
  • 什么是 Composing builds?
  • 如何使用 Composing builds 和 buildSrc
  • buildSrc 和 Composing builds 优势劣势对比?
  • Composing builds 编译速度怎么样?
  • buildSrc 如何迁移到 Composing builds?
  • 管理 Gradle 依赖都有那几种方式?以及效率怎么样?

这篇文章涉及很多重要的知识点,请耐心读下去,我相信应该会给大家带来很多不一样的东西。

Composing builds 和 buildSrc 对比

接下来我们来演示一下 buildSrc 和 Composing builds 它们的优势劣势对比,在分析之前,先来了解一下基本概念

什么是 buildSrc

摘自 Gradle 文档:当运行 Gradle 时会检查项目中是否存在一个名为 buildSrc 的目录。然后 Gradle 会自动编译并测试这段代码,并将其放入构建脚本的类路径中, 对于多项目构建,只能有一个 buildSrc 目录,该目录必须位于根项目目录中, buildSrc 是 Gradle 项目根目录下的一个目录,它可以包含我们的构建逻辑,与脚本插件相比,buildSrc 应该是首选,因为它更易于维护、重构和测试代码

什么是 Composing builds

摘自 Gradle 文档:复合构建只是包含其他构建的构建. 在许多方面,复合构建类似于 Gradle 多项目构建,不同之处在于,它包括完整的 builds ,而不是包含单个 projects

  • 组合通常独立开发的构建,例如,在应用程序使用的库中尝试错误修复时
  • 将大型的多项目构建分解为更小,更孤立的块,可以根据需要独立或一起工作

buildSrc vs Composing builds

为了正确对比这两种方式,新建了两个空的项目分别是 Project-buildSrc 和 Project-ComposingBuild,这两个项目引用的依赖都是一样的,Project-buildSrc 包含 buildSrc,Project-ComposingBuild 包含 Composing builds。

Composing-builds-vs-buildSr

Project-buildSrc 和 Project-ComposingBuild 它们的结构都差不多,接下来我们来看一下,编译速度 和 使用上有什么不同。

编译速度

Project-buildSrc 和 Project-ComposingBuild 这两个项目,它们的 androidx.appcompat:appcompat 的版本是 1.0.2,现在我们从 1.0.2 升级到 1.1.0 来看一下它们 Build 的时间。

  • Project-buildSrc:修改了版本号 1.0.2 -> 1.1.0 重新 Build 用时 37s

Project-buildSrc

  • Project-ComposingBuild:修改了版本号 1.0.2 -> 1.1.0 重新 Build 用时 8s

Project-ComposingBuild

当修改了版本号,Project-buildSrc 项目 Build 的时间几乎是 Project-ComposingBuild 项目的 4.6 倍( PS: 每个人的环境不同,时间上会有差异,但是 Project-buildSrc 的时间总是大于 Project-ComposingBuild )

在更大的项目中,网络慢的情况下,这种差异会更加明显,几分钟的构建都是常事,在 buildSrc 中做微小的更改,可能需要花很长时间构建,等待团队其他成员在他们提取更改之后,都将导致项目重新构建,这个代价是非常昂贵的。

它们在使用上有什么不同呢

Project-buildSrc

  • 在项目根目录下新建一个名为 buildSrc 的文件夹( 名字必须是 buildSrc,因为运行 Gradle 时会检查项目中是否存在一个名为 buildSrc 的目录 )
  • 在 buildSrc 文件夹里创建名为 build.gradle.kts 的文件,添加以下内容
1
2
3
4
5
6
scss复制代码plugins {
`kotlin-dsl`
}
repositories{
jcenter()
}
  • 在 buildSrc/src/main/java/包名/ 目录下新建 Deps.kt 文件,添加以下内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码object Versions {
......

val appcompat = "1.1.0"

......
}

object Deps {
......

val appcompat = "androidx.appcompat:appcompat:${Versions.appcompat}"

......
}
  • 重启你的 Android Studio,项目里就会多出一个名为 buildSrc 的 module,实现上面演示的效果

Project-ComposingBuild

  • 新建的 module 名称 versionPlugin
  • 在 versionPlugin 文件夹下的 build.gradle 文件内,添加以下内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
arduino复制代码buildscript {
repositories {
jcenter()
}
dependencies {
// 因为使用的 Kotlin 需要需要添加 Kotlin 插件
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72"
}
}

apply plugin: 'kotlin'
apply plugin: 'java-gradle-plugin'
repositories {
// 需要添加 jcenter 否则会提示找不到 gradlePlugin
jcenter()
}

gradlePlugin {
plugins {
version {
// 在 app 模块需要通过 id 引用这个插件
id = 'com.hi.dhl.plugin'
// 实现这个插件的类的路径
implementationClass = 'com.hi.dhl.plugin.Deps'
}
}
}
  • 在 versionPlugin/src/main/java/包名/ 目录下新建 Deps.kt 文件,添加以下内容
1
2
3
4
5
6
7
8
kotlin复制代码class Deps : Plugin<Project> {
override fun apply(project: Project) {
}

companion object {
val appcompat = "androidx.appcompat:appcompat:1.1.0"
}
}
  • 在 settings.gradle 文件内添加 includeBuild 'versionPlugin' 重启你的 Android Studio
  • 在 app 模块 build.gradle 文件内首行添加以下内容,就可以实现上面演示的效果
1
2
3
4
bash复制代码plugins{
// 这个 id 就是在 versionPlugin 文件夹下 build.gradle 文件内定义的 id
id "com.hi.dhl.plugin"
}

ps:plugins{} 需要放在 app 模块 build.gradle 文件内首行位置

Project-ComposingBuild 比 Project-buildSrc 多了两步操作需要在 settings.gradle 和 build.gradle 引入插件,两者在使用都是差不多的

如何快速使用 buildSrc

  • 访问 ComposingBuilds-vs-buildSrc 拷贝 buildSrc 文件夹到你的项目的根目录
  • 重启你的 Android Studio,项目里就会多出一个名为 buildSrc 的 module

如何快速使用 Composing builds

  • 访问 ComposingBuilds-vs-buildSrc 拷贝 versionPlugin 文件夹到你的项目的根目录
  • 按照上面的配置方式,分配在 settings.gradle 和 app 模块的 build.gradle 引用插件即可

总结

总共从以下几个方面对比了 Composing builds 和 buildSrc

  • 目录结构:它们的基本目录结构是相同的,可以根据自己的项目进行不同的扩展
  • 编译速度:当修改了版本号,Project-buildSrc 项目 Build 的时间几乎是 Project-ComposingBuild 项目的 4.6 倍( PS: 每个人的环境不同,时间上会有差异,但是 Project-buildSrc 的时间总是大于 Project-ComposingBuild )
  • 使用上的区别:Composing builds 比 buildSrc 多了两步操作需要在 settings.gradle 和 build.gradle 引入插件

Project-buildSrc 和 Project-ComposingBuild 相关代码已经上传到 GitHub 了:ComposingBuilds-vs-buildSrc

到目前为止大概管理 Gradle 依赖提供了 4 种不同方法:

  • 手动管理 :在每个 module 中定义插件依赖库,每次升级依赖库时都需要手动更改(不建议使用)
  • 使用 ext 的方式管理插件依赖库 :这是 Google 推荐管理依赖的方法 Android官方文档
  • Kotlin + buildSrc:自动补全和单击跳转,依赖更新时 将重新 构建整个项目
  • Composing builds:自动补全和单击跳转,依赖更新时 不会重新 构建整个项目

buildSrc 如何迁移到 Composing builds?

如果当前项目使用的是 buildSrc 方式,迁移到 Composing builds 很简单,需要将 buildSrc 内容拷贝的 Composing builds 中,然后删掉 buildSrc 文件夹就可以即可

参考文献

  • Organizing Gradle Projects
  • Composing builds
  • Android官方文档,使用 ext 的方式管理插件依赖库

结语

致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,可以关注我,如果你喜欢这篇文章欢迎 star,一起来学习,期待与你一起成长

计划建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请帮我点个赞,我会陆续完成更多 Jetpack 新成员的项目实践。

算法

由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序

  • 数据结构: 数组、栈、队列、字符串、链表、树……
  • 算法: 查找算法、搜索算法、位运算、排序、数学、……

每道题目都会用 Java 和 kotlin 去实现,并且每道题目都有解题思路,如果你同我一样喜欢算法、LeetCode,可以关注我 GitHub 上的 LeetCode 题解:Leetcode-Solutions-with-Java-And-Kotlin,一起来学习,期待与你一起成长

Android 10 源码系列

正在写一系列的 Android 10 源码分析的文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,如果你同我一样喜欢研究 Android 源码,可以关注我 GitHub 上的 Android10-Source-Analysis,文章都会同步到这个仓库

  • 0xA01 Android 10 源码分析:APK 是如何生成的
  • 0xA02 Android 10 源码分析:APK 的安装流程
  • 0xA03 Android 10 源码分析:APK 加载流程之资源加载
  • 0xA04 Android 10 源码分析:APK 加载流程之资源加载(二)
  • 0xA05 Android 10 源码分析:Dialog 加载绘制流程以及在 Kotlin、DataBinding 中的使用
  • 0xA06 Android 10 源码分析:WindowManager 视图绑定以及体系结构
  • 更多

Android 应用系列

  • 如何高效获取视频截图
  • 如何在项目中封装 Kotlin + Android Databinding
  • [译][Google工程师] 刚刚发布了 Fragment 的新特性 “Fragment 间传递数据的新方式” 以及源码分析
  • [译][2.4K Start] 放弃 Dagger 拥抱 Koin
  • [译][5k+] Kotlin 的性能优化那些事
  • [译][Google工程师] 详解 FragmentFactory 如何优雅使用 Koin 以及部分源码分析
  • [译] 解密 RxJava 的异常处理机制

工具系列

  • 为数不多的人知道的 AndroidStudio 快捷键(一)
  • 为数不多的人知道的 AndroidStudio 快捷键(二)
  • 关于 adb 命令你所需要知道的
  • 10分钟入门 Shell 脚本编程

逆向系列

  • 基于 Smali 文件 Android Studio 动态调试 APP
  • 解决在 Android Studio 3.2 找不到 Android Device Monitor 工具

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

吐血输出:2万字长文带你细细盘点五种负载均衡策略。 Dubb

发表于 2020-05-31

Dubbo的五种负载均衡策略

2020 年 5 月 15 日,Dubbo 发布 2.7.7 release 版本。其中有这么一个 Features


新增一个负载均衡策略。

熟悉我的老读者肯定是知道的,Dubbo 的负载均衡我都写过专门的文章,对每个负载均衡算法进行了源码的解读,还分享了自己调试过程中的一些骚操作。

新的负载均衡出来了,那必须的得解读一波。

先看一下提交记录:

https://github.com/chickenlj/incubator-dubbo/commit/6d2ba7ec7b5a1cb7971143d4262d0a1bfc826d45


负载均衡是基于 SPI 实现的,我们看到对应的文件中多了一个名为 shortestresponse 的 key。

这个,就是新增的负载均衡策略了。看名字,你也知道了这个策略的名称就叫:最短响应。

所以截止 2.7.7 版本,官方提供了五种负载均衡算法了,他们分别是:

  1. ConsistentHashLoadBalance 一致性哈希负载均衡
  2. LeastActiveLoadBalance 最小活跃数负载均衡
  3. RandomLoadBalance 加权随机负载均衡
  4. RoundRobinLoadBalance 加权轮询负载均衡
  5. ShortestResponseLoadBalance 最短响应时间负载均衡

前面四种我已经在之前的文章中进行了详细的分析。有的读者反馈说想看合辑,所以我会在这篇文章中把之前文章也整合进来。

所以,需要特别强调一下的是,这篇文章集合了之前写的三篇负载均衡的文章。看完最短响应时间负载均衡这一部分后,如果你看过我之前的那三篇文章,你可以温故而知新,也可以直接拉到文末看看我推荐的一个活动,然后点个赞再走。如果你没有看过那三篇,这篇文章如果你细看,肯定有很多收获,以后谈起负载均衡的时候若数家珍,但是肯定需要看非常非常长的时间,做好心理准备。

我已经预感到了,这篇文章妥妥的会超过 2 万字。属于硬核劝退文章,想想就害怕。


最短响应时间负载均衡
==========

首先,我们看一下这个类上的注解,先有个整体的认知。

org.apache.dubbo.rpc.cluster.loadbalance.ShortestResponseLoadBalance


我来翻译一下是什么意思:

  1. 从多个服务提供者中选择出调用成功的且响应时间最短的服务提供者,由于满足这样条件的服务提供者有可能有多个。所以当选择出多个服务提供者后要根据他们的权重做分析。
  2. 但是如果只选择出来了一个,直接用选出来这个。
  3. 如果真的有多个,看它们的权重是否一样,如果不一样,则走加权随机算法的逻辑。
  4. 如果它们的权重是一样的,则随机调用一个。

再配个图,就好理解了,可以先不管图片中的标号:


有了上面的整体概念的铺垫了,接下来分析源码的时候就简单了。

源码一共就 66 行,我把它分为 5 个片段去一一分析。


这里一到五的标号,对应上面流程图中的标号。我们一个个的说。

标号为①的部分


这一部分是定义并初始化一些参数,为接下来的代码服务的,翻译一下每个参数对应的注释:

length 参数:服务提供者的数量。

shortestResponse 参数:所有服务提供者的估计最短响应时间。(这个地方我觉得注释描述的不太准确,看后面的代码可以知道这只是一个零时变量,在循环中存储当前最短响应时间是多少。)

shortCount 参数:具有相同最短响应时间的服务提供者个数,初始化为 0。

shortestIndexes 参数:数组里面放的是具有相同最短响应时间的服务提供者的下标。

weights 参数:每一个服务提供者的权重。

totalWeight 参数:多个具有相同最短响应时间的服务提供者对应的预热(预热这个点还是挺重要的,在下面讲最小活跃数负载均衡的时候有详细说明)权重之和。

firstWeight 参数:第一个具有最短响应时间的服务提供者的权重。

sameWeight 参数:多个满足条件的提供者的权重是否一致。

标号为②的部分


这一部分代码的关键,就在上面框起来的部分。而框起来的部分,最关键的地方,就在于第一行。


获取调用成功的平均时间。

成功调用的平均时间怎么算的?

调用成功的请求数总数对应的总耗时 / 调用成功的请求数总数 = 成功调用的平均时间。

所以,在下面这个方法中,首先获取到了调用成功的请求数总数:


这个 succeeded 参数是怎么来的呢?


答案就是:总的请求数减去请求失败的数量,就是请求成功的总数!


那么为什么不能直接获取请求成功的总数呢?

别问,问就是没有这个选项啊。你看,在 RpcStatus 里面没有这个参数呀。


请求成功的总数我们有了,接下来成功总耗时怎么拿到的呢?


答案就是:总的请求时间减去请求失败的总时间,就是请求成功的总耗时!


那么为什么不能直接获取请求成功的总耗时呢?

别问,问就是……

我们看一下 RpcStatus 中的这几个参数是在哪里维护的:

org.apache.dubbo.rpc.RpcStatus#endCount(org.apache.dubbo.rpc.RpcStatus, long, boolean)


其中的第二个入参是本次请求调用时长,第三个入参是本次调用是否成功。

具体的方法不必细说了吧,已经显而易见了。

再回去看框起来的那三行代码:

  1. 第一行获取到了该服务提供者成功请求的平均耗时。
  2. 第二行获取的是该服务提供者的活跃数,也就是堆积的请求数。
  3. 第三行获取的就是如果当前这个请求发给这个服务提供者预计需要等待的时间。乘以 active 的原因是因为它需要排在堆积的请求的后面嘛。

这里,我们就获取到了如果选择当前循环中的服务提供者的预计等待时间是多长。

后面的代码怎么写?

当然是出来一个更短的就把这个踢出去呀,或者出来一个一样长时间的就记录一下,接着去 pk 权重了。

所以,接下来 shortestIndexes 参数和 weights 参数就排上用场了:


另外,多说一句的,它里面有这样的一行注释:


和 LeastActiveLoadBalance 负载均衡策略一致,我给你截图对比一下:


可以看到,确实是非常的相似,只是一个是判断谁的响应时间短,一个是判断谁的活跃数低。

标号为③的地方

标号为③的地方是这样的:


里面参数的含义我们都知道了,所以,标号为③的地方的含义就很好解释了:经过选择后只有一个服务提供者满足条件。所以,直接使用这个服务提供者。

标号为④的地方


这个地方我就不展开讲了(后面的加权随机负载均衡那一小节有详细说明),熟悉的朋友一眼就看出来这是加权随机负载均衡的写法了。

不信?我给你对比一下:


你看,是不是一模一样的。


标号为⑤的地方
=======


一行代码,没啥说的。就是从多个满足条件的且权重一样的服务提供者中随机选择一个。

如果一定要多说一句的话,我截个图吧:


可以看到,这行代码在最短响应时间、加权随机、最小活跃数负载均衡策略中都出现了,且都在最后一行。

好了,到这里最短响应时间负载均衡策略就讲完了,你再回过头去看那张流程图,会发现其实流程非常的清晰,完全可以根据代码结构画出流程图。一个是说明这个算法是真的不复杂,另一个是说明好的代码会说话。

优雅

你知道 Dubbo 加入这个新的负载均衡算法提交了几个文件吗?

四个文件,其中还包含两个测试文件:


这里就是策略模式和 SPI 的好处。对原有的负载均衡策略没有任何侵略性。只需要按照规则扩展配置文件,实现对应接口即可。

这是什么?

这就是值得学习优雅!


那我们优雅的进入下一议题。

最小活跃数负载均衡

这一小节所示源码,没有特别标注的地方均为 2.6.0 版本。

为什么没有用截止目前(我当时写这段文章的时候是2019年12月01日)的最新的版本号 2.7.4.1 呢?因为 2.6.0 这个版本里面有两个 bug 。从 bug 讲起来,印象更加深刻。

最后会对 2.6.0/2.6.5/2.7.4.1 版本进行对比,通过对比学习,加深印象。

我这里补充一句啊,仅仅半年的时间,版本号就从 2.7.4.1 到了 2.7.7。其中还包含一个 2.7.5 这样的大版本。

所以还有人说 Dubbo 不够活跃?(几年前的文章现在还有人在发。)


对吧,我们不吵架,我们摆事实,聊数据嘛。

Demo 准备

我看源码的习惯是先搞个 Demo 把调试环境搭起来。然后带着疑问去抽丝剥茧的 Debug,不放过在这个过程中在脑海里面一闪而过的任何疑问。

这一小节分享的是Dubbo负载均衡策略之一最小活跃数(LeastActiveLoadBalance)。所以我先搭建一个 Dubbo 的项目,并启动三个 provider 供 consumer 调用。

三个 provider 的 loadbalance 均配置的是 leastactive。权重分别是默认权重、200、300。


**默认权重是多少?**后面看源码的时候,源码会告诉你。

三个不同的服务提供者会给调用方返回自己是什么权重的服务。


启动三个实例。(注:上面的 provider.xml 和 DemoServiceImpl 其实只有一个,每次启动的时候手动修改端口、权重即可。)


到 zookeeper 上检查一下,服务提供者是否正常:


可以看到三个服务提供者分别在 20880、20881、20882 端口。(每个红框的最后5个数字就是端口号)。

最后,我们再看服务消费者。消费者很简单,配置consumer.xml


直接调用接口并打印返回值即可。


断点打在哪?


相信很多朋友也很想看源码,但是不知道从何处下手。处于一种在源码里面”乱逛”的状态,一圈逛下来,收获并不大。

这一部分我想分享一下我是怎么去看源码。首先我会带着问题去源码里面寻找答案,即有针对性的看源码。

如果是这种框架类的,正如上面写的,我会先翻一翻官网(Dubbo 的官方文档其实写的挺好了),然后搭建一个简单的 Demo 项目,然后 Debug 跟进去看。Debug 的时候当然需要是设置断点的,那么这个断点如何设置呢?

第一个断点,当然毋庸置疑,是打在调用方法的地方,比如本文中,第一个断点是在这个地方:


接下里怎么办?

你当然可以从第一个断点处,一步一步的跟进去。但是在这个过程中,你发现了吗?大多数情况你都是被源码牵着鼻子走的。本来你就只带着一个问题去看源码的,有可能你Debug了十分钟,还没找到关键的代码。也有可能你Debug了十分钟,问题从一个变成了无数个。

所以不要慌,我们点支烟,慢慢分析。


首先怎么避免被源码牵着四处乱逛呢?

我们得找到一个突破口,还记得我在《很开心,在使用mybatis的过程中我踩到一个坑》这篇文章中提到的逆向排查的方法吗?这次的文章,我再次展示一下该方法。

看源码之前,我们的目标要十分明确,就是想要找到 Dubbo 最小活跃数算法的具体实现类以及实现类的具体逻辑是什么。

根据我们的 provider.xml 里面的:


很明显,我们知道 loadbalance 是关键字。所以我们拿着 loadbalance 全局搜索,可以看到 Dubbo 包下面的 LoadBalance。


这是一个 SPI 接口 com.alibaba.dubbo.rpc.cluster.LoadBalance:


其实现类为:

com.alibaba.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance

AbstractLoadBalance 是一个抽象类,该类里面有一个抽象方法doSelect。这个抽象方法其中的一个实现类就是我们要分析的最少活跃次数负载均衡的源码。


同时,到这里我们知道了 LoadBalance 是一个 SPI 接口,说明我们可以扩展自己的负载均衡策略。抽象方法 doSelect 有四个实现类。这个四个实现类,就是 Dubbo 官方提供的负载均衡策略(截止 2.7.7 版本之前),他们分别是:

  1. ConsistentHashLoadBalance 一致性哈希算法
  2. LeastActiveLoadBalance 最小活跃数算法
  3. RandomLoadBalance 加权随机算法
  4. RoundRobinLoadBalance 加权轮询算法

我们已经找到了 LeastActiveLoadBalance 这个类了,那么我们的第二个断点打在哪里已经很明确了。


目前看来,两个断点就可以支撑我们的分析了。

有的朋友可能想问,那我想知道 Dubbo 是怎么识别出我们想要的是最少活跃次数算法,而不是其他的算法呢?其他的算法是怎么实现的呢?从第一个断点到第二个断点直接有着怎样的调用链呢?

在没有彻底搞清楚最少活跃数算法之前,这些统统先记录在案但不予理睬。一定要明确目标,带着一个问题进来,就先把带来的问题解决了。之后再去解决在这个过程中碰到的其他问题。在这样环环相扣解决问题的过程中,你就慢慢的把握了源码的精髓。这是我个人的一点看源码的心得。供诸君参考。

模拟环境

既然叫做最小活跃数策略。那我们得让现有的三个消费者都有一些调用次数。所以我们得改造一下服务提供者和消费者。

服务提供者端的改造如下:

!


PS:这里以权重为 300 的服务端为例。另外的两个服务端改造点相同。

客户端的改造点如下:


一共发送 21 个请求:其中前 20 个先发到服务端让其 hold 住(因为服务端有 sleep),最后一个请求就是我们需要 Debug 跟踪的请求。

运行一下,让程序停在断点的地方,然后看看控制台的输出:




权重为300的服务端共计收到9个请求

权重为200的服务端共计收到6个请求

默认权重的服务端共计收到5个请求

我们还有一个请求在 Debug。直接进入到我们的第二个断点的位置,并 Debug 到下图所示的一行代码(可以点看查看大图):


正如上面这图所说的:weight=100 回答了一个问题,active=0 提出的一个问题。

weight=100 回答了什么问题呢?

默认权重是多少?是 100。

我们服务端的活跃数分别应该是下面这样的

  • 权重为300的服务端,active=9
  • 权重为200的服务端,active=6
  • 默认权重(100)的服务端,active=5

但是这里为什么截图中的active会等于 0 呢?这是一个问题。

继续往下 Debug 你会发现,每一个服务端的 active 都是 0。所以相比之下没有一个 invoker 有最小 active 。于是程序走到了根据权重选择 invoker 的逻辑中。


active为什么是0?


active 为 0 说明在 Dubbo 调用的过程中 active 并没有发生变化。那 active 为什么是 0,其实就是在问 active 什么时候发生变化?

要回答这个问题我们得知道 active 是在哪里定义的,因为在其定义的地方,必有其修改的方法。

下面这图说明了active是定义在RpcStatus类里面的一个类型为AtomicInteger 的成员变量。


在 RpcStatus 类中,有三处()调用 active 值的方法,一个增加、一个减少、一个获取:


很明显,我们需要看的是第一个,在哪里增加。

所以我们找到了 beginCount(URL,String) 方法,该方法只有两个 Filter 调用。ActiveLimitFilter,见名知意,这就是我们要找的东西。


com.alibaba.dubbo.rpc.filter.ActiveLimitFilter具体如下:


看到这里,我们就知道怎么去回答这个问题了:为什么active是0呢?因为在客户端没有配置ActiveLimitFilter。所以,ActiveLimitFilter没有生效,导致active没有发生变化。

怎么让其生效呢?已经呼之欲出了。


好了,再来试验一次:




加上Filter之后,我们通过Debug可以看到,对应权重的活跃数就和我们预期的是一致的了。

1.权重为300的活跃数为6

2.权重为200的活跃数为11

3.默认权重(100)的活跃数为3


根据活跃数我们可以分析出来,最后我们Debug住的这个请求,一定会选择默认权重的invoker去执行,因为他是当前活跃数最小的invoker。如下所示:


虽然到这里我们还没开始进行源码的分析,只是把流程梳理清楚了。但是把Demo完整的搭建了起来,而且知道了最少活跃数负载均衡算法必须配合ActiveLimitFilter使用,位于RpcStatus类的active字段才会起作用,否则,它就是一个基于权重的算法。

比起其他地方直接告诉你,要配置ActiveLimitFilter才行哦,我们自己实验得出的结论,能让我们的印象更加深刻。

我们再仔细看一下加上ActiveLimitFilter之后的各个服务的活跃数情况:

  • 权重为300的活跃数为6
  • 权重为200的活跃数为11
  • 默认权重(100)的活跃数为3

你不觉得奇怪吗,为什么权重为200的活跃数是最高的?


其在业务上的含义是:我们有三台性能各异的服务器,A服务器性能最好,所以权重为300,B服务器性能中等,所以权重为200,C服务器性能最差,所以权重为100。

当我们选择最小活跃次数的负载均衡算法时,我们期望的是性能最好的A服务器承担更多的请求,而真实的情况是性能中等的B服务器承担的请求更多。这与我们的设定相悖。

如果你说20个请求数据量太少,可能是巧合,不足以说明问题。说明你还没被我带偏,我们不能基于巧合编程。

所以为了验证这个地方确实有问题,我把请求扩大到一万个。


同时,记得扩大 provider 端的 Dubbo 线程池:


由于每个服务端运行的代码都是一样的,所以我们期望的结果应该是权重最高的承担更多的请求。但是最终的结果如图所示:


各个服务器均摊了请求。这就是我文章最开始的时候说的Dubbo 2.6.0 版本中最小活跃数负载均衡算法的Bug之一。

接下来,我们带着这个问题,去分析源码。

剖析源码

com.alibaba.dubbo.rpc.cluster.loadbalance.LeastActiveLoadBalance的源码如下,我逐行进行了解读。可以点开查看大图,细细品读,非常爽:


下图中红框框起来的部分就是一个基于权重选择invoker的逻辑:


我给大家画图分析一下:


请仔细分析图中给出的举例说明。同时,上面这图也是按照比例画的,可以直观的看到,对于某一个请求,区间(权重)越大的服务器,就越可能会承担这个请求。所以,当请求足够多的时候,各个服务器承担的请求数,应该就是区间,即权重的比值。

其中第 81 行有调用 getWeight 方法,位于抽象类 AbstractLoadBalance 中,也需要进行重点解读的代码。

com.alibaba.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance 的源码如下,我也进行了大量的备注:


在 AbstractLoadBalance 类中提到了一个预热的概念。官网中是这样的介绍该功能的:

权重的计算过程主要用于保证当服务运行时长小于服务预热时间时,对服务进行降权,避免让服务在启动之初就处于高负载状态。服务预热是一个优化手段,与此类似的还有 JVM 预热。主要目的是让服务启动后“低功率”运行一段时间,使其效率慢慢提升至最佳状态。

从上图代码里面的公式(演变后):*计算后的权重=(uptime/warmup)weight** 可以看出:随着服务启动时间的增加(uptime),计算后的权重会越来越接近weight。从实际场景的角度来看,随着服务启动时间的增加,服务承担的流量会慢慢上升,没有一个陡升的过程。所以这是一个优化手段。同时 Dubbo 接口还支持延迟暴露。

在仔细的看完上面的源码解析图后,配合官网的总结加上我的灵魂画作,相信你可以对最小活跃数负载均衡算法有一个比较深入的理解:

  1. 遍历 invokers 列表,寻找活跃数最小的 Invoker
  2. 如果有多个 Invoker 具有相同的最小活跃数,此时记录下这些 Invoker 在 invokers 集合中的下标,并累加它们的权重,比较它们的权重值是否相等
  3. 如果只有一个 Invoker 具有最小的活跃数,此时直接返回该 Invoker 即可
  4. 如果有多个 Invoker 具有最小活跃数,且它们的权重不相等,此时处理方式和 RandomLoadBalance 一致
  5. 如果有多个 Invoker 具有最小活跃数,但它们的权重相等,此时随机返回一个即可


所以我觉得最小活跃数负载均衡的全称应该叫做:有最小活跃数用最小活跃数,没有最小活跃数根据权重选择,权重一样则随机返回的负载均衡算法。

Bug在哪里?

Dubbo2.6.0最小活跃数算法Bug一


问题出在标号为 ① 和 ② 这两行代码中:

标号为 ① 的代码在url中取出的是没有经过 getWeight 方法降权处理的权重值,这个值会被累加到权重总和(totalWeight)中。

标号为 ② 的代码取的是经过 getWeight 方法处理后的权重值。

取值的差异会导致一个问题,标号为 ② 的代码的左边,offsetWeight 是一个在 [0,totalWeight) 范围内的随机数,右边是经过 getWeight 方法降权后的权重。所以在经过 leastCount 次的循环减法后,offsetWeight 在服务启动时间还没到热启动设置(默认10分钟)的这段时间内,极大可能仍然大于 0。导致不会进入到标号为 ③ 的代码中。直接到标号为 ④ 的代码处,变成了随机调用策略。这与设计不符,所以是个 bug。

前面章节说的情况就是这个Bug导致的。

这个Bug对应的issues地址和pull request分为:

https://github.com/apache/dubbo/issues/904

https://github.com/apache/dubbo/pull/2172

那怎么修复的呢?我们直接对比 Dubbo 2.7.4.1 的代码:


可以看到获取weight的方法变了:从url中直接获取变成了通过getWeight方法获取。获取到的变量名称也变了:从weight变成了afterWarmup,更加的见名知意。

还有一处变化是获取随机值的方法的变化,从Randmo变成了ThreadLoaclRandom,性能得到了提升。这处变化就不展开讲了,有兴趣的朋友可以去了解一下。

Dubbo2.6.0最小活跃数算法Bug二

这个Bug我没有遇到,但是我在官方文档上看了其描述(官方文档中的版本是2.6.4),引用如下:


官网上说这个问题在2.6.5版本进行修复。我对比了2.6.0/2.6.5/2.7.4.1三个版本,发现每个版本都略有不同。如下所示:


图中标记为①的三处代码:

2.6.0版本的是有Bug的代码,原因在上面说过了。

2.6.5版本的修复方式是获取随机数的时候加一,所以取值范围就从**[0,totalWeight)变成了[0,totalWeight]**,这样就可以避免这个问题。

2.7.4.1版本的取值范围还是[0,totalWeight),但是它的修复方法体现在了标记为②的代码处。2.6.0/2.6.5版本标记为②的地方都是if(offsetWeight<=0),而2.7.4.1版本变成了if(offsetWeight<0)。

你品一品,是不是效果是一样的,但是更加优雅了。


朋友们,魔鬼,都在细节里啊!

好了,进入下一议题。

一致性哈希负载均衡

这一部分是对于Dubbo负载均衡策略之一的一致性哈希负载均衡的详细分析。对源码逐行解读、根据实际运行结果,配以丰富的图片,可能是东半球讲一致性哈希算法在Dubbo中的实现最详细的文章了。


本小节所示源码,没有特别标注的地方,均为2.7.4.1版本。

在撰写本文的过程中,发现了Dubbo2.7.0版本之后的一个bug。会导致性能问题,如果你们的负载均衡配置的是一致性哈希或者考虑使用一致性哈希的话,可以了解一下。

哈希算法

在介绍一致性哈希算法之前,我们看看哈希算法,以及它解决了什么问题,带来了什么问题。


如上图所示,假设0,1,2号服务器都存储的有用户信息,那么当我们需要获取某用户信息时,因为我们不知道该用户信息存放在哪一台服务器中,所以需要分别查询0,1,2号服务器。这样获取数据的效率是极低的。

对于这样的场景,我们可以引入哈希算法。


还是上面的场景,但前提是每一台服务器存放用户信息时是根据某一种哈希算法存放的。所以取用户信息的时候,也按照同样的哈希算法取即可。

假设我们要查询用户号为100的用户信息,经过某个哈希算法,比如这里的userId mod n,即100 mod 3结果为1。所以用户号100的这个请求最终会被1号服务器接收并处理。

这样就解决了无效查询的问题。

但是这样的方案会带来什么问题呢?

扩容或者缩容时,会导致大量的数据迁移。最少也会影响百分之50的数据。


为了说明问题,我们加入一台服务器3。服务器的数量n就从3变成了4。还是查询用户号为100的用户信息时,100 mod 4结果为0。这时,请求就被0号服务器接收了。

当服务器数量为3时,用户号为100的请求会被1号服务器处理。

当服务器数量为4时,用户号为100的请求会被0号服务器处理。

所以,当服务器数量增加或者减少时,一定会涉及到大量数据迁移的问题。可谓是牵一发而动全身。

对于上诉哈希算法其优点是简单易用,大多数分库分表规则就采取的这种方式。一般是提前根据数据量,预先估算好分区数。

其缺点是由于扩容或收缩节点导致节点数量变化时,节点的映射关系需要重新计算,会导致数据进行迁移。所以扩容时通常采用翻倍扩容,避免数据映射全部被打乱,导致全量迁移的情况,这样只会发生50%的数据迁移。

假设这是一个缓存服务,数据的迁移会导致在迁移的时间段内,有缓存是失效的。

缓存失效,可怕啊。还记得我之前的文章吗,《当周杰伦把QQ音乐干翻的时候,作为程序猿我看到了什么?》就是讲缓存击穿、缓存穿透、缓存雪崩的场景和对应的解决方案。

一致性哈希算法

为了解决哈希算法带来的数据迁移问题,一致性哈希算法应运而生。

对于一致性哈希算法,官方说法如下:

一致性哈希算法在1997年由麻省理工学院提出,是一种特殊的哈希算法,在移除或者添加一个服务器时,能够尽可能小地改变已存在的服务请求与处理请求服务器之间的映射关系。一致性哈希解决了简单哈希算法在分布式哈希表( Distributed Hash Table,DHT) 中存在的动态伸缩等问题。

什么意思呢?我用大白话加画图的方式给你简单的介绍一下。

一致性哈希,你可以想象成一个哈希环,它由0到2^32-1个点组成。A,B,C分别是三台服务器,每一台的IP加端口经过哈希计算后的值,在哈希环上对应如下:


当请求到来时,对请求中的某些参数进行哈希计算后,也会得出一个哈希值,此值在哈希环上也会有对应的位置,这个请求会沿着顺时针的方向,寻找最近的服务器来处理它,如下图所示:


一致性哈希就是这么个东西。那它是怎么解决服务器的扩容或收缩导致大量的数据迁移的呢?

看一下当我们使用一致性哈希算法时,加入服务器会发什么事情。

当我们加入一个D服务器后,假设其IP加端口,经过哈希计算后落在了哈希环上图中所示的位置。


这时影响的范围只有图中标注了五角星的区间。这个区间的请求从原来的由C服务器处理变成了由D服务器请求。而D到C,C到A,A到B这个区间的请求没有影响,加入D节点后,A、B服务器是无感知的。

所以,在一致性哈希算法中,如果增加一台服务器,则受影响的区间仅仅是新服务器(D)在哈希环空间中,逆时针方向遇到的第一台服务器(B)之间的区间,其它区间(D到C,C到A,A到B)不会受到影响。

在加入了D服务器的情况下,我们再假设一段时间后,C服务器宕机了:


当C服务器宕机后,影响的范围也是图中标注了五角星的区间。C节点宕机后,B、D服务器是无感知的。

所以,在一致性哈希算法中,如果宕机一台服务器,则受影响的区间仅仅是宕机服务器(C)在哈希环空间中,逆时针方向遇到的第一台服务器(D)之间的区间,其它区间(C到A,A到B,B到D)不会受到影响。

综上所述,在一致性哈希算法中,不管是增加节点,还是宕机节点,受影响的区间仅仅是增加或者宕机服务器在哈希环空间中,逆时针方向遇到的第一台服务器之间的区间,其它区间不会受到影响。

是不是很完美?

不是的,理想和现实的差距是巨大的。

一致性哈希算法带来了什么问题?


当节点很少的时候可能会出现这样的分布情况,A服务会承担大部分请求。这种情况就叫做数据倾斜。

怎么解决数据倾斜呢?加入虚拟节点。

怎么去理解这个虚拟节点呢?

首先一个服务器根据需要可以有多个虚拟节点。假设一台服务器有n个虚拟节点。那么哈希计算时,可以使用IP+端口+编号的形式进行哈希值计算。其中的编号就是0到n的数字。由于IP+端口是一样的,所以这n个节点都是指向的同一台机器。

如下图所示:


在没有加入虚拟节点之前,A服务器承担了绝大多数的请求。但是假设每个服务器有一个虚拟节点(A-1,B-1,C-1),经过哈希计算后落在了如上图所示的位置。那么A服务器的承担的请求就在一定程度上(图中标注了五角星的部分)分摊给了B-1、C-1虚拟节点,实际上就是分摊给了B、C服务器。

一致性哈希算法中,加入虚拟节点,可以解决数据倾斜问题。

当你在面试的过程中,如果听到了类似于数据倾斜的字眼。那大概率是在问你一致性哈希算法和虚拟节点。

在介绍了相关背景后,我们可以去看看一致性哈希算法在Dubbo中的应用了。

一致性哈希算法在Dubbo中的应用

前面我们说了Dubbo中负载均衡的实现是通过org.apache.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance中的 doSelect 抽象方法实现的,一致性哈希负载均衡的实现类如下所示:

org.apache.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance


由于一致性哈希实现类看起来稍微有点抽象,不太好演示,所以我想到了一个”骚”操作。前面的文章说过 LoadBalance 是一个 SPI 接口:


既然是一个 SPI 接口,那我们可以自己扩展一个一模一样的算法,只是在算法里面加入一点输出语句方便我们观察情况。怎么扩展 SPI 接口就不描述了,只要记住代码里面的输出语句都是额外加的,此外没有任何改动即可,如下:


整个类如下图片所示,请先看完整个类,有一个整体的概念后,我会进行方法级别的分析。

图片很长,其中我加了很多注释和输出语句,可以点开大图查看,一定会帮你更加好的理解一致性哈希在Dubbo中的应用:

改造之后,我们先把程序跑起来,有了输出就好分析了。


服务端代码如下:


其中的端口是需要手动修改的,我分别启动服务在20881和20882端口。

项目中provider.xml配置如下:


consumer.xml配置如下:


然后,启动在20881和20882端口分别启动两个服务端。客户端消费如下:


运行结果输出如下,可以先看个大概的输出,下面会对每一部分输出进行逐一的解读。


好了,用例也跑起来了,日志也有了。接下来开始结合代码和日志进行方法级别的分析。

首先是doSelect方法的入口:


从上图我们知道了,第一次调用需要对selectors进行put操作,selectors的 key 是接口中定义的方法,value 是 ConsistentHashSelector 内部类。

ConsistentHashSelector通过调用其构造函数进行初始化的。invokers(服务端)作为参数传递到了构造函数中,构造函数里面的逻辑,就是把服务端映射到哈希环上的过程,请看下图,结合代码,仔细分析输出数据:


从上图可以看出,当 ConsistentHashSelector 的构造方法调用完成后,8个虚拟节点在哈希环上已经映射完成。两台服务器,每一台4个虚拟节点组成了这8个虚拟节点。

doSelect方法继续执行,并打印出每个虚拟节点的哈希值和对应的服务端,请仔细品读下图:


说明一下:上面图中的哈希环是没有考虑比例的,仅仅是展现了两个服务器在哈希环上的相对位置。而且为了演示说明方便,仅仅只有8个节点。假设我们有4台服务器,每台服务器的虚拟节点是默认值(160),这个情况下哈希环上一共有160*4=640个节点。

哈希环映射完成后,接下来的逻辑是把这次请求经过哈希计算后,映射到哈希环上,并顺时针方向寻找遇到的第一个节点,让该节点处理该请求:


还记得地址为 468e8565 的 A 服务器是什么端口吗?前面的图片中有哦,该服务对应的端口是 20882 。


最后我们看看输出结果:


和我们预期的一致。整个调用就算是完成了。

再对两个方法进行一个补充说明。

第一个方法是 selectForKey,这个方法里面逻辑如下图所示:


虚拟节点都存储在 TreeMap 中。顺时针查询的逻辑由 TreeMap 保证。看一下下面的 Demo 你就明白了。


第二个方法是 hash 方法,其中的 & 0xFFFFFFFFL 的目的如下:


&是位运算符,而 0xFFFFFFFFL 转换为四字节表现后,其低32位全是1,所以保证了哈希环的范围是 [0,Integer.MAX_VALUE]:


所以这里我们可以改造这个哈希环的范围,假设我们改为 100000。十进制的 100000 对于的 16 进制为 186A0 。所以我们改造后的哈希算法为:


再次调用后可以看到,计算后的哈希值都在10万以内。但是分布极不均匀,说明修改数据后这个哈希算法不是一个优秀的哈希算法:


以上,就是对一致性哈希算法在Dubbo中的实现的解读。需要特殊说明一下的是,一致性哈希负载均衡策略和权重没有任何关系。

我又发现了一个BUG

前面我介绍了Dubbo 2.6.5版本之前,最小活跃数算法的两个 bug。

很不幸,这次我又发现了Dubbo 2.7.4.1版本,一致性哈希负载均衡策略的一个bug,我提交了issue 地址如下:

https://github.com/apache/dubbo/issues/5429


我在这里详细说一下这个Bug现象、原因和我的解决方案。

现象如下,我们调用三次服务端:


输出日志如下(有部分删减):


可以看到,在三次调用的过程中并没有发生服务的上下线操作,但是每一次调用都重新进行了哈希环的映射。而我们预期的结果是应该只有在第一次调用的时候进行哈希环的映射,如果没有服务上下线的操作,后续请求根据已经映射好的哈希环进行处理。

上面输出的原因是由于每次调用的invokers的identityHashCode发生了变化:


我们看一下三次调用invokers的情况:


经过debug我们可以看出因为每次调用的invokers地址值不是同一个,所以System.identityHashCode(invokers)方法返回的值都不一样。

接下来的问题就是为什么每次调用的invokers地址值都不一样呢?

经过Debug之后,可以找到这个地方:

org.apache.dubbo.rpc.cluster.RouterChain#route


问题就出在这个TagRouter中:

org.apache.dubbo.rpc.cluster.router.tag.TagRouter#filterInvoker


所以,在TagRouter中的stream操作,改变了invokers,导致每次调用时其

System.identityHashCode(invokers)返回的值不一样。所以每次调用都会进行哈希环的映射操作,在服务节点多,虚拟节点多的情况下会有一定的性能问题。

到这一步,问题又发生了变化。这个TagRouter怎么来的呢?

如果了解Dubbo 2.7.x版本新特性的朋友可能知道,标签路由是Dubbo2.7引入的新功能。


通过加载下面的配置加载了RouterFactrory:

META-INF\dubbo\internal\org.apache.dubbo.rpc.cluster.RouterFactory(Dubbo 2.7.0版本之前)

META-INF\dubbo\internal\com.alibaba.dubbo.rpc.cluster.RouterFactory(Dubbo 2.7.0之前)

下面是Dubbo 2.6.7(2.6.x的最后一个版本)和Dubbo 2.7.0版本该文件的对比:


可以看到确实是在 Dubbo 2.7.0 之后引入了 TagRouter。

至此,Dubbo 2.7.0 版本之后,一致性哈希负载均衡算法的 Bug 的来龙去脉也介绍清楚了。

解决方案是什么呢?特别简单,把获取 identityHashCode 的方法从 System.identityHashCode(invokers) 修改为 invokers.hashCode() 即可。

此方案是我提的 issue 里面的评论,这里 System.identityHashCode 和 hashCode 之间的联系和区别就不进行展开讲述了,不清楚的大家可以自行了解一下。

(我的另外一篇文章:够强!一行代码就修复了我提的Dubbo的Bug。)


改完之后,我们再看看运行效果:


可以看到第二次调用的时候并没有进行哈希环的映射操作,而是直接取到了值,进行调用。

加入节点,画图分析

最后,我再分析一种情况。在A、B、C三个服务器(20881、20882、20883端口)都在正常运行,哈希映射已经完成的情况下,我们再启动一个D节点(20884端口),这时的日志输出和对应的哈希环变化情况如下:


根据日志作图如下:


根据输出日志和上图再加上源码,你再细细回味一下。我个人觉得还是讲的非常详细了。

一致性哈希的应用场景

当大家谈到一致性哈希算法的时候,首先的第一印象应该是在缓存场景下的使用,因为在一个优秀的哈希算法加持下,其上下线节点对整体数据的影响(迁移)都是比较友好的。

但是想一下为什么 Dubbo 在负载均衡策略里面提供了基于一致性哈希的负载均衡策略?它的实际使用场景是什么?

我最开始也想不明白。我想的是在 Dubbo 的场景下,假设需求是想要一个用户的请求一直让一台服务器处理,那我们可以采用一致性哈希负载均衡策略,把用户号进行哈希计算,可以实现这样的需求。但是这样的需求未免有点太牵强了,适用场景略小。

直到有天晚上,我睡觉之前,电光火石之间突然想到了一个稍微适用的场景了。

如果需求是需要保证某一类请求必须顺序处理呢?

如果你用其他负载均衡策略,请求分发到了不同的机器上去,就很难保证请求的顺序处理了。比如A,B请求要求顺序处理,现在A请求先发送,被负载到了A服务器上,B请求后发送,被负载到了B服务器上。而B服务器由于性能好或者当前没有其他请求或者其他原因极有可能在A服务器还在处理A请求之前就把B请求处理完成了。这样不符合我们的要求。

这时,一致性哈希负载均衡策略就上场了,它帮我们保证了某一类请求都发送到固定的机器上去执行。比如把同一个用户的请求发送到同一台机器上去执行,就意味着把某一类请求发送到同一台机器上去执行。所以我们只需要在该机器上运行的程序中保证顺序执行就行了,比如你加一个队列。

一致性哈希算法+队列,可以实现顺序处理的需求。

好了,一致性哈希负载均衡算法就写到这里。

继续进入下一个议题。

加权轮询负载均衡

这一小节是对于Dubbo负载均衡策略之一的加权随机算法的详细分析。

从 2.6.4 版本聊起,该版本在某些情况下存在着比较严重的性能问题。由问题入手,层层深入,了解该算法在 Dubbo 中的演变过程,读懂它的前世今生。

什么是轮询?

在描述加权轮询之前,先解释一下什么是轮询算法,如下图所示:


假设我们有A、B、C三台服务器,共计处理6个请求,服务处理请求的情况如下:

  1. 第一个请求发送给了A服务器
  2. 第二个请求发送给了B服务器
  3. 第三个请求发送给了C服务器
  4. 第四个请求发送给了A服务器
  5. 第五个请求发送给了B服务器
  6. 第六个请求发送给了C服务器
  7. ……

上面这个例子演示的过程就叫做轮询。可以看出,所谓轮询就是将请求轮流分配给每台服务器。

轮询的优点是无需记录当前所有服务器的链接状态,所以它一种无状态负载均衡算法,实现简单,适用于每台服务器性能相近的场景下。

轮询的缺点也是显而易见的,它的应用场景要求所有服务器的性能都相同,非常的局限。

大多数实际情况下,服务器性能是各有差异,针对性能好的服务器,我们需要让它承担更多的请求,即需要给它配上更高的权重。

所以加权轮询,应运而生。

什么是加权轮询?

为了解决轮询算法应用场景的局限性。当遇到每台服务器的性能不一致的情况,我们需要对轮询过程进行加权,以调控每台服务器的负载。

经过加权后,每台服务器能够得到的请求数比例,接近或等于他们的权重比。比如服务器 A、B、C 权重比为 5:3:2。那么在10次请求中,服务器 A 将收到其中的5次请求,服务器 B 会收到其中的3次请求,服务器 C 则收到其中的2次请求。


这里要和加权随机算法做区分哦。直接把前面介绍的加权随机算法画的图拿过来:


上面这图是按照比例画的,可以直观的看到,对于某一个请求,区间(权重)越大的服务器,就越可能会承担这个请求。所以,当请求足够多的时候,各个服务器承担的请求数,应该就是区间,即权重的比值。

假设有A、B、C三台服务器,权重之比为5:3:2,一共处理10个请求。

那么负载均衡采用加权随机算法时,很有可能A、B服务就处理完了这10个请求,因为它是随机调用。

采用负载均衡采用轮询加权算法时,A、B、C服务一定是分别承担5、3、2个请求。

Dubbo2.6.4版本的实现

对于Dubbo2.6.4版本的实现分析,可以看下图,我加了很多注释,其中的输出语句都是我加的:


示例代码还是沿用之前文章中的Demo,这里分别在 20881、20882、20883 端口启动三个服务,各自的权重分别为 1,2,3。

客户端调用 8 次:


输出结果如下:


可以看到第七次调用后mod=0,回到了第一次调用的状态。形成了一个闭环。

再看看判断的条件是什么:


其中mod在代码中扮演了极其重要的角色,mod根据一个方法的调用次数不同而不同,取值范围是[0,weightSum)。

因为weightSum=6,所以列举mod不同值时,最终的选择结果和权重变化:


可以看到20881,20882,20883承担的请求数量比值为1:2:3。同时我们可以看出,当 mod >= 1 后,20881端口的服务就不会被选中了,因为它的权重被减为0了。当 mod >= 4 后,20882端口的服务就不会被选中了,因为它的权重被减为0了。

结合判断条件和输出结果,我们详细分析一下(下面内容稍微有点绕,如果看不懂,多结合上面的图片看几次):

第一次调用

mod=0,第一次循环就满足代码块①的条件,直接返回当前循环的invoker,即20881端口的服务。此时各端口的权重情况如下:


第二次调用

mod=1,需要进入代码块②,对mod进行一次递减。

第一次循环对20881端口的服务权重减一,mod-1=0。

第二次循环,mod=0,循环对象是20882端口的服务,权重为2,满足代码块①,返回当前循环的20882端口的服务。

此时各端口的权重情况如下:


第三次调用

mod=2,需要进入代码块②,对mod进行两次递减。

第一次循环对20881端口的服务权重减一,mod-1=1;

第二次循环对20882端口的服务权重减一,mod-1=0;

第三次循环时,mod已经为0,当前循环的是20883端口的服务,权重为3,满足代码块①,返回当前循环的20883端口的服务。

此时各端口的权重情况如下:


第四次调用

mod=3,需要进入代码块②,对mod进行三次递减。

第一次循环对20881端口的服务权重减一,从1变为0,mod-1=2;

第二次循环对20882端口的服务权重减一,从2变为1,mod-1=1;

第三次循环对20883端口的服务权重减一,从3变为2,mod-1=0;

第四次循环的是20881端口的服务,此时mod已经为0,但是20881端口的服务的权重已经变为0了,不满足代码块①和代码块②,进入第五次循环。

第五次循环的是20882端口的服务,当前权重为1,mod=0,满足代码块①,返回20882端口的服务。

此时各端口的权重情况如下:


第五次调用

mod=4,需要进入代码块②,对mod进行四次递减。

第一次循环对20881端口的服务权重减一,从1变为0,mod-1=3;

第二次循环对20882端口的服务权重减一,从2变为1,mod-1=2;

第三次循环对20883端口的服务权重减一,从3变为2,mod-1=1;

第四次循环的是20881端口的服务,此时mod为1,但是20881端口的服务的权重已经变为0了,不满足代码块②,mod不变,进入第五次循环。

第五次循环时,mod为1,循环对象是20882端口的服务,权重为1,满足代码块②,权重从1变为0,mod从1变为0,进入第六次循环。

第六次循环时,mod为0,循环对象是20883端口的服务,权重为2,满足条件①,返回当前20883端口的服务。

此时各端口的权重情况如下:


第六次调用

第六次调用,mod=5,会循环九次,最终选择20883端口的服务,读者可以自行分析一波,分析出来了,就了解的透透的了。


第七次调用

第七次调用,又回到mod=0的状态:



2.6.4版本的加权轮询就分析完了,但是事情并没有这么简单。这个版本的加权轮询是有性能问题的。


该问题对应的issue地址如下:

https://github.com/apache/dubbo/issues/2578

问题出现在invoker返回的时机上:


截取issue里面的一个回答:


10分钟才选出一个invoker,还怎么玩?

有时间可以读一读这个issue,里面各路大神针对该问题进行了激烈的讨论,第一种改造方案被接受后,很快就被推翻,被第二种方案代替,可以说优化思路十分值得学习,很精彩,接下来的行文路线就是按照该issue展开的。

推翻,重建。

上面的代码时间复杂度是O(mod),而第一次修复之后时间复杂度降低到了常量级别。可以说是一次非常优秀的优化,值得我们学习,看一下优化之后的代码:


其关键优化的点是这段代码,我加入输出语句,便于分析。


输出日志如下:


把上面的输出转化到表格中去,7次请求的选择过程如下:


该算法的原理是:

把服务端都放到集合中(invokerToWeightList),然后获取服务端个数(length),并计算出服务端权重最大的值(maxWeight)。

index表示本次请求到来时,处理该请求的服务端下标,初始值为0,取值范围是[0,length)。

currentWeight表示当前调度的权重,初始值为0,取值范围是[0,maxWeight)。

当请求到来时,从index(就是0)开始轮询服务端集合(invokerToWeightList),如果是一轮循环的开始(index=0)时,则对currentWeight进行加一操作(不会超过maxWeight),在循环中找出第一个权重大于currentWeight的服务并返回。

这里说的一轮循环是指index再次变为0所经历过的循环,这里可以把index=0看做是一轮循环的开始。每一轮循环的次数与Invoker的数量有关,Invoker数量通常不会太多,所以我们可以认为上面代码的时间复杂度为常数级。

从issue上看出,这个算法最终被merged了。


但是很快又被推翻了:


这个算法不够平滑。什么意思呢?

翻译一下上面的内容就是:服务器[A, B, C]对应权重[5, 1, 1]。进行7次负载均衡后,选择出来的序列为[A, A, A, A, A, B, C]。前5个请求全部都落在了服务器A上,这将会使服务器A短时间内接收大量的请求,压力陡增。而B和C此时无请求,处于空闲状态。而我们期望的结果是这样的[A, A, B, A, C, A, A],不同服务器可以穿插获取请求。

我们设置20881端口的权重为5,20882、20883端口的权重均为1。

进行实验,发现确实如此:可以看到一共进行7次请求,第1次到5次请求都分发给了权重为5的20881端口的服务,前五次请求,20881和20882都处于空闲状态:


转化为表格如下:


从表格的最终结果一栏也可以直观的看出,七次请求对应的服务器端口为:


分布确实不够均匀。

再推翻,再重建,平滑加权。

从issue中可以看到,再次重构的加权算法的灵感来源是Nginx的平滑加权轮询负载均衡:


看代码之前,先介绍其计算过程。

假设每个服务器有两个权重,一个是配置的weight,不会变化,一个是currentWeight会动态调整,初始值为0。当有新的请求进来时,遍历服务器列表,让它的currentWeight加上自身权重。遍历完成后,找到最大的currentWeight,并将其减去权重总和,然后返回相应的服务器即可。


如果你还是不知道上面的表格是如何算出来的,我再给你详细的分析一下第1、2个请求的计算过程:

第一个请求计算过程如下:


第二个请求计算过程如下:


后面的请求你就可以自己分析了。

从表格的最终结果一栏也可以直观的看出,七次请求对应的服务器端口为:


可以看到,权重之比同样是5:1:1,但是最终的请求分发的就比较的”平滑”。对比一下:


对于平滑加权算法,我想多说一句。我觉得这个算法非常的神奇,我是彻底的明白了它每一步的计算过程,知道它最终会形成一个闭环,但是我想了很久,我还是不知道背后的数学原理是什么,不明白为什么会形成一个闭环,非常的神奇。

很正常,我不纠结的,程序猿的工作不就是这样吗?我也不知道为什么,它能工作。别问,问就是玄学,如果一定要说出点什么的话,我想,我愿称之为:绝活吧。


但是我们只要能够理解我前面所表达的平滑加权轮询算法的计算过程,知道其最终会形成闭环,就能理解下面的代码。配合代码中的注释食用,效果更佳。

以下代码以及注释来源官网:

http://dubbo.apache.org/zh-cn/docs/source\_code\_guide/loadbalance.html


总结
==

好了,到这里关于Dubbo的五种负载均衡策略就讲完了。简单总结一下:(加权随机算法在讲最小活跃数算法的时候提到过,因为原理十分简单,这里就不专门拿出章节来描述了。)

最短响应时间负载均衡:在所有服务提供者中选出平均响应时间最短的一个,如果能选出来,则使用选出来的一个。如果不能选出来多个,再根据权重选,如果权重也一样,则随机选择。

一致性哈希负载均衡:在一致性哈希算法中,不管是增加节点,还是宕机节点,受影响的区间仅仅是增加或者宕机服务器在哈希环空间中,逆时针方向遇到的第一台服务器之间的区间,其它区间不会受到影响。为了解决数据倾斜的问题,引入了虚拟节点的概念。一致性哈希算法是 Dubbo 中唯一一个与权重没有任何关系的负载均衡算法,可以保证相同参数的请求打到同一台机器上。

最小活跃数负载均衡:需要配合 activeFilter 使用,活跃数在方法调用前后进行维护,响应越快的服务器堆积的请求越少,对应的活跃数也少。Dubbo 在选择的时候遵循下面的规则,有最小活跃数用最小活跃数,没有最小活跃数根据权重选择,权重一样则随机返回的负载均衡算法。

加权随机算法:随机,顾名思义,就是从多个服务提供者中随机选择一个出来。加权,就是指需要按照权重设置随机概率。常见场景就是对于性能好的机器可以把对应的权重设置的大一点,而性能相对较差的,权重设置的小一点。哎,像极了这个社会上的某些现象,对外宣传是随机摇号,背后指不定有一群权重高的人呢。

加权轮询负载均衡:轮询就是雨露均沾的意思,所有的服务提供者都需要调用。而当轮询遇到加权则可以让请求(不论多少)严格按照我们的权重之比进行分配。比如有A、B、C三台服务器,权重之比为5:3:2,一共处理10个请求。那么采用负载均衡采用轮询加权算法时,A、B、C服务一定是分别承担5、3、2个请求。同时需要注意的是加权轮询算法的两次升级过程,以及最终的“平滑”的解决方案。

再说一件事

本文主要聊的是负载均衡嘛,让我想起了 2019 年阿里巴巴第五届中间件挑战赛的初赛赛题也是实现一个负载均衡策略。

具体的赛题可以看这里:

https://tianchi.aliyun.com/competition/entrance/231714/information


这种比赛还是很有意思的,你报名之后仅仅是读懂赛题,然后自己多想想怎么实现,哪怕是不提交代码,在比赛完成后看前几名的赛题分析,再去把他们的代码拉下来看看,你就会发现,其实最终的思路都大同小异,差别会体现在参数调优和代码优化程度上。

当然最大的差别还是会体现在语言的层面。如果不限制参数语言的话,Java 系的选手一定是被 C 系选手吊打的。

但是,被吊打不重要,重要的是真的能学到很多的东西,而这些东西,在绝大部分工作中是很难学到的。

最近,阿里巴巴第六届中间件挑战赛也开始了,可以看一下这个链接:

https://tianchi.aliyun.com/competition/entrance/231790/introduction?spm=5176.12281968.1008.3.65818188YmzFqa

这次是分为三个赛道,选择性更多了。


作为这个比赛的长期关注者(持续关注三年了吧),这次作为一个自来水免费宣传一波。

朋友,我真心建议你去看一下,报个名,玩一玩,收获真的很大的。

当然,如果能在报名的时候邀请人那一栏填【why技术】,我真心感谢你。


最后说一句(求关注)
==========

点个“赞”吧,周更很累的,不要白嫖我,需要一点正反馈。


才疏学浅,难免会有纰漏,如果你发现了错误的地方,还请你留言指出来,我对其加以修改。

感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

我是 why,一个被代码耽误的文学创作者,不是大佬,但是喜欢分享,是一个又暖又有料的四川好男人。

欢迎关注我的微信公众号:why技术。在这里我会分享一些java技术相关的知识,用匠心敲代码,对每一行代码负责。偶尔也会荒腔走板的聊一聊生活,写一写书评、影评。感谢你的关注,愿你我共同进步。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…808809810…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%