调度器?

1. Go 语言的调度器

Process-+Thread
        |
        |
        +Thread +Goroutines
                |
                +Goroutines

Go 语言的调度器通过使用和 CPU 数量相等的线程:减少线程频繁切换的内存开销,然后在每个线程上面使用开销更低的 Goroutines 来减少操作系统硬件的负载。

单线程调度器 · 0.x 只包含 40 多行代码; 程序中只能存在一个活跃线程,由 G-M 模型组成;

多线程调度器 · 1.0 允许运行多线程的程序; 全局锁导致竞争严重;

任务窃取调度器 · 1.1 引入了处理器 P,构成了目前的 G-M-P 模型; 在处理器 P 的基础上实现了基于工作窃取的调度器; 在某些情况下,Goroutine 不会让出线程,进而造成饥饿问题; 时间过长的垃圾回收(Stop-the-world,STW)会导致程序长时间无法工作;

2. 抢占式调度

  • 基于协做的抢占式调度器,实现思路是:通过编译器在函数调用的时候插入检查抢占的指令,在函数调用的时候检查当前的 Goroutine 是否发起了抢占请求,实现了基于协作的抢占式调度。Goroutine 可能通过垃圾回收或者循环长时间的占用资源而导致程序暂停。
  • 基于信号的抢占式调度器,实现思路是:垃圾回收在扫描栈的时候触发抢占式调度。

单线程调度器 1-获取调度器的全局锁; 2-调用 routime.gosave()保存栈寄存器和程序计数器 3-调用 runtime.nextgandunlock 获取下一个需要运行的 Goroutine 并接锁调度器 4-修改全局 M 上面要执行的 Goroutine 5-runtime.gogo 获取下一个需要运行的 Goroutine

多线程调度器 1-抢占锁 2-引入了 GOMAXPROCS 变量帮助我们灵活控制程序中的最大处理器数,即活跃线程数。 保存寄存器和程序计数器 3-获取下一个 Goroutine 并释放锁 4-修改 M 上的 Goroutine 5-然后运行下一个 Goroutine 存在的问题: 调度器和锁是全局的资源,所有的调度状态都是中心化存储,锁竞争的问题严重。 线程之间相互传递可运行的 Goroutine,引入大量的延迟。 每个线程都需要处理内存缓存,导致大量的内存占用。 系统调用频繁阻塞和解除阻塞正在运行的线程,增加了额外的开销。

任务窃取调度器 处理器持有一个可以运行的 Goroutine 组成的环形的运行队列 runq,还反向持有一个线程 M,处理器会从处理器队列中选择队列头的 Goroutine 放在线程上进行执行。 把每一个线程绑定到独立的 CPU,然后这些线程被不同的处理器管理,不同的处理器对任务进行工作窃取实现对任务的再分配。

基于协作的抢占式调度: 1- 编译器在调用函数前插入 runtime.morestack 2- 程序在运行的时候会在垃圾回收时暂停程序,系统监控发现 Goroutine 运行超过 10ms 的时候发起抢占式的请求。 3-真正到了函数发生调用的时候,执行编译器插入的函数,判断 Goroutine 的字段 stackguard0 字段是否为 StackPreempt;如果 stackguard0 是 StackPreempt,就会触发抢占让出当前线程;

基于信号的抢占式调度: 垃圾回收和栈扫描的时候

3. GMP 总结:

M 是一个系统线程,由操作系统调度器调度 G 是一个 Goroutine 代表一个待执行的任务 P 是一个运行在线程上的本地调度器。

G 是 Go 语言调度器中待执行的任务,只存在在 Go 语言运行时,它是 G 哦语言在用户态提供的线程,

我们知道,Go 的 goroutine 是用户态的线程(user-space threads),用户态的线程是需要自己去调度的,Go 有运行时的 scheduler 去帮我们完成调度这件事情。关于 Go 的调度模型 GMP 模型我在此不做赘述,如果不了解,可以看我另一篇文章(Go 调度原理)

在Go的早期版本中,确实使用了GM模型,并且存在一个全局的锁(通常被称为schedlock)来保护Goroutine的调度。这种设计在并发度较低时可以工作得很好,但随着Goroutine数量的增加,这个全局锁成为了一个瓶颈。

以下是使用GM模型和全局锁的一些问题:

全局锁的竞争:当多个OS线程(M)尝试调度Goroutines时,它们都需要获取这个全局锁。这导致了大量的锁竞争,尤其是在多核CPU上。

缺乏局部性:在GM模型中,所有的Goroutines都在一个全局队列中。这意味着每次调度Goroutine时,都可能需要从全局队列中获取,这降低了缓存局部性。

缺乏可伸缩性:由于全局锁和全局队列,GM模型在多核机器上的可伸缩性受到限制。

为了解决这些问题,Go的开发者引入了P(处理器)的概念,并转向了GMP模型。在GMP模型中:

每个P都有自己的本地Goroutine队列,这提高了缓存局部性并减少了锁竞争。 全局锁被移除,取而代之的是更细粒度的锁,如P的本地锁。 P的数量通常与CPU核心数相同,这为Go的调度提供了一个自然的并行度,确保了在多核机器上的可伸缩性。 因此,GMP模型不仅解决了全局锁的问题,还为Go提供了一个更高效、更可伸缩的并发模型。

4. 当 channel 缓存满了之后会发生什么?这其中的原理是怎样的?

当名为 G1 的 goroutine 往 channel 写入数据的时候,这个时候,如果 channel 已经满了,调用调度器,然后让 G1 让出 M 的资源,(G1 变为等待的状态)M 调用其他可运行队列中的 G,使得 G 变为可以被调用的状态 那么 go 的调度器会主动让 G1 等待,并从中让出 M,变为等待的 G1 将会抽象为含有 G1 指针和 Send 元素的 sudog,结构体保存到 hchan 的 sendq,等待被呼醒,

那么,G1 什么时候被唤醒呢?这个时候 G2 隆重登场。 读出数据的过程, 从缓冲区取出数据,然后 copy 数据到 G2,然后发送队列出队列,把 G1 要 send 的数据数据进入到缓冲区,然后把 G1 唤醒,把 G1 放到可以运行的队列当中 G2 从缓存队列中取出数据,channel 会将等待队列中的 G1 推出,将 G1 当时 send 的数据推到缓存中,然后调用 Go 的 scheduler,唤醒 G1,并把 G1 放到可运行的 Goroutine 队列中。

5. Map 的数据结构是什么样子的?

创建 Map 的时候,初始化一个 hmap 的结构体,同时分配一个足够大的内存空间 A,A 的前半段是 hash 数组,数据元素是 bucket 结构体,bucket 结构体存储一个链表指针;用来在冲突的时候指向溢出桶。后半段是预留给溢出的桶,于是,hamp.buckets 指向哈希数组, 所以创建 map 时一次内存分配既分配了用户预期大小的 hash 数组,又追加了一定量的预留的溢出桶,还做了内存对齐,一举多得。

  • hash 数组+桶+溢出的桶链表,每个桶最多八个 key-value
  • 插入的原理是:key 的哈希数值和桶的数量进行相与,得到 key 所在的 Hash 的桶,高八位再和桶的 tophash[i]进行对比,相同则进一步
  • 并发操作:Go map 不支持并发。插入(更新)、删除、搬迁等操作会置 hashWriting 标志,检测到并发直接 panic;
  • map 的扩容策略有两种,真扩容就是,扩到 hash 桶的数量为原来的两倍,针对元素数量过多,之于假扩容,hash 桶的数量不变,hash 表只增不减。
  • 删除操作:设置删除位,且不能被使用

  • hash 实现的算法其时间复杂度均为 O(1)。

6. 脏读——查询+更新+回滚+没有加锁 以及 脏读解决办法

导致其他查询操作读到没有提交的数据。

加行级锁+版本列 加表锁+版本列

行锁和表锁的区别: 是否是索引节点

乐观锁和悲观锁区别: 加版本号+行级锁

排他锁和共享锁: 排他锁拒绝所有读写,共享锁可以并发读,拒绝写

7. 读到脏数据的隔离级别叫做 RU(Read Uncommited)

Q: 先来个小问题,RU 级别没有任何锁,对吗? A: 错误, RU 级别做 update 等增删改操作时,仍然会默认在事务更新操作中增加排他锁,避免 update 冲突。 切记脏读的发生原因,是查询+更新+回滚时没加锁导致其他查询操作出现失误判断。 即查询这块可能读到没提交的数据,导致错误,而不是更新的并发问题。

8. 当我们的数据库被设置成 RC 级别(Read commited)时, 可以解决脏读, 那么背后是怎么解决的呢?

A: 业界有两种方式

LBCC 基于锁的并发控制(Lock-Based Concurrency Control))

LBCC 就是对所有的 select 操作试图加锁,这样被 update 的排他锁阻塞,避免了脏读。

MVCC 基于多版本的并发控制协议(Multi-Version Concurrency Control)

(同一个数据保留多个版本,进而实现并发控制) 每个连接到数据库的读者,在某一个瞬间看到的数据库的一个快照,连接到数据库的每一个写者的写操作造成的变化是在(数据库事务提交之前)对于其他的读者来者来说是不可以见的。 当一个 MVCC 数据库更新一条记录的时候,不会直接用新的数据覆盖旧的数据,而是将旧的数据标记为过时,在别处增加新版本的数据,这样就会存储多个版本的数据,但是只有一个是最新的数据,在这种情况下,允许读者读取之前已经存在的数据,即便在这个过程中间数据被删除,更新,对其他正在读的用户没有丝毫的影响。 所以 InnoDb 是基于乐观锁的概念,在事务的背后实现了一套乐观锁的机制

  • 查询的时候,只查询当前事务之前的记录,或者回滚版本比当前大的已删记录
  • 插入的时候,加新的版本记录
  • 删除的时候,把老记录标上回滚记录
  • 更新的时候,加新的记录,同时把老记录标记上回滚版本。

MVCC 插入的流程: DB_TRX_ID(数据行的版本号) DB_ROLL_PT(删除版本号)

begin;-- 获取到全局事务ID
insert into `test_zq` (`id`, `test_id`) values('5','68');
insert into `test_zq` (`id`, `test_id`) values('6','78');
commit;-- 提交事务

id test_id DB_TRX_ID DB_ROLL_PT 5 68 1 NULL 6 78 1 NULL

插入的时候,会把全局事务 ID 记录到DB_TRX_ID中去。

MVCC 删除的流程:

begin--获得全局事务ID = 3
delete test_zq where id = 6;
commit;

执行完上述 SQL 之后数据并没有被真正删除,而是对删除版本号做改变,如下所示:

id test_id DB_TRX_ID DB_ROLL_PT 5 68 1 NULL 6 78 1 3

MVCC 逻辑流程-修改

begin;-- 获取全局系统事务ID 假设为 10
update test_zq set test_id = 22 where id = 5;
commit;

执行后表格实际数据应该是:

id test_id DB_TRX_ID DB_ROLL_PT 5 68 1 10 6 78 1 3 5 22 10 NULL

MVCC 逻辑流程-查询:

begin;-- 假设拿到的系统事务ID为 12
select * from test_zq;
commit;

查找数据行版本号早于当前事务版本号的数据行记录

执行结果应该是:

id test_id DB_TRX_ID DB_ROLL_PT 5 22 10 NULL

SELECT t1.* FROM t1 JOIN t2 on t1.id = t2.id

9. SQL 语句及索引的优化

  • 用 join 代替子语句查询,选择 join 的时候,尽量用 inner join ,inner join 而不是 left join (left join 是大表驱动小的表)
  • 用 In 替换 Or
  • 使用 limit 的时候记住上一次查询的 ID
  • 如果对排序没啥要求就少用 order by,在分组的时候会默认排序,那么这个时候就禁止排序。
  • select 只返回需要的列
  • in 和 exists in 用在内表比较小的时候, exists 用于内表比较大的时候。
  • 尽量使用数字型的字段,字符型的字段需要一个一个去比较
  • 用小结果集合去驱动大结果集,先连接数据量比较小的数据表,再去连接数据量比较大的表格,然后同时对被驱动的大的结果集合建立索引
  • 不在索引上做任何操作 where id =1 而不是 where id +1 = 0 计算会使得索引失效
  • 索引是 varchar 但是查询的时候没有加’‘,只有加上才能使得索引生效。 -!= >= <= not in not exists not like is null is not null 都会使得索引失效导致全表扫描。
  • %like%的操作会使得索引失效。最好使用like%
  • or 的语句左右,如果只有一个语句对应的列是索引列,那么无法使用索引.如果不需要 ORDER BY,进⾏ GROUP BY 时加 ORDER BY NULL,MySQL 不会再进⾏⽂件排序。

10. 优化⼦查询的⽅法

  • 如果对排序没啥要求就少用 order by,在分组的时候会默认排序,那么这个时候就禁止排序。
  • 子查询改为关联查询
inner join on
select * from tbl_A where id in (select id from tbl_B)
select A.* from tbl_A inner join (select id from tbl_B)B on B.id=A.id
  • 给字符的前几个字段设置索引(前提是前缀的辨识度比较高)实操的难度:在于前缀截取的⻓度。

11. MySQL 5.6 和 MySQL 5.7 对索引做了哪些优化?

select * from user where a='23' and b like '%eqw%' and c like 'dasd'

如果使⽤了索引下推技术,则 MySQL 会⾸先返回返回条件 a=’23’的数据的索引,然后根据模糊查询的条件来 校验索引⾏数据是否符合条件,如果符合条件,则直接根据 索引来定位对应的数据,如果不符合直接 reject 掉。因此,有了索引下推优化,可以 在有 like 条件的情况下,减少回表的次数。

12. MySQL 有关权限的表有哪⼏个呢?

user 权限表:记录允许连接到服务器的⽤户帐号信息,⾥⾯的权限是全局级的。 db 权限表:记录各个帐号在各个数据库上的操作权限。 columns_priv 权限表:记录数据列级的操作权限。 table_priv 权限表:记录数据表级的操作权限。

13. MySQL 中都有哪些触发器?

Before Insert After Insert Before Update After Update Before Delete After Delete

14. ⼤表怎么优化?分库分表了是怎么做的?分表分库了有什么问题? 有⽤到中间件么?他们的原理知道么?

  • 禁止不带限制范围的查询语句 当 MySQL 单表记录数过⼤时,数据库的 CRUD 性能会明显下降,⼀些常⻅的优化措施如 下:限定数据的范围: 务必禁⽌不带任何限制数据范围条件的查询语句。⽐如:我们当⽤户在查询订单历史的时候,我们可以控制在⼀个⽉的范围内。
  • 读/写分离: 经典的数据库拆分⽅案,主库负责写,从库负责读;
  • 分库分表的⽅式进⾏优化

垂直分区 根据数据库⾥⾯数据表的相关性进⾏拆分。 例如,⽤户表中既有⽤户的登录信息⼜有 ⽤户的基本信息,可以将⽤户表拆分成两个单独的表,甚⾄放到单独的库做分库。 简单来说垂直拆分是指数据表列的拆分,把⼀张列⽐较多的表拆分为多张表。

垂直分表 适用场景,如果一个表的某些列经常使用,另外一些列不经常使用,使得数据行变小,使得一个数据页可以存储更多的数据,查询的时候可以减少 I/O 的次数 但是查询数据比较冗余,所有的操作需要 join 操 水平分表,就是每一行数据分散到不同的表或者库中,达到了分布式的目的,水平拆分可以支持非常大的数据量,但由于表的数据还是在同⼀台机器上,其实对于提升 没有什么意义,所以 ⽔平拆分最好分库 。 水平拆分的时候可以降低在查询时需要读取的数据和索引的页数,但是查询的时候需要多个表名,查询所有数据需要 UNION 的操作,

数据库分⽚的两种常⻅⽅案:

客户端代理和中间件代理,在应用和数据之间增加了一个代理层,分片的逻辑维护在中间件的服务当中,

15. 分库分表后⾯临的问题

事务⽀持 分库分表后,就成了分布式事务了。如果依赖数据库本身的分布式事务管理功能去执⾏事 务,将付出⾼昂的性能代价; 如果由应⽤程序去协助控制,形成程序逻辑上的事务,⼜会 造成编程⽅⾯的负担。

跨库 join 主键 ID 的问题: 一旦一个数据库被切分到多个物理节点,那么不能依赖数据库本身的主键生成机制。UUID 使⽤ UUID 作主键是最简单的⽅案,但是 UUID 非常的长,在建立索引和基于索引进行查询时存在性能问题,分布式自增 Id 算法

16. 数据库表结构的优化:使得数据库结构符合三大范式与 BCNF

使得数据库结构符合三大范式与 BCNF

Tags: golang
Share: X (Twitter) Facebook LinkedIn