hhhzua 发布的文章

Go中map是一个指针,占用8个字节,指向hmap结构

hmap包含若干个结构为bmap的数组,每个bmap底层都是采用链表结构,bmap通常叫做bucket

hmap结构:

type hmap struct{
    count int //元素数量
  flags uint8 //状态标志
  B uint8 //buckets的对数,如果B=5,则buckets的数量=2^5 = 32
  noverflow uint16 //溢出桶的数量
  hash0  uint32 //hash种子
  buckets unsafe.Pointer //指向buckets数组的指针,数组长度为2^B
  oldbuckets  unsafe.Pointer //如果发生扩容,指向老的buckets数组的指针,
  //oldbuckets数组大小为buckets数组长度的一半,未发生扩容时是null
  nevacuate  uintptr //表示扩容速度,小于此地址的都已经扩容完成
  extra *mapextra //存储溢出桶
}

bmap结构:

type bmap struct {
    tophash [bucketCnt]uint8
  //len为8的数组,用于快速定位key是否在这个bmap中
  //一个桶油8个槽,如果key的高8位在tophash中,则表示该key在这个捅中
}

bmap就是我们所说的桶,一个桶最多存放8个key,这些key之所以存放在一个桶,是因为经过hash计算之后,hash结果的低8位相同,在桶内又会根据hash结果的高8位来决定key到底落入那个位置(共有8个位置)。

bmap结构的定义是一个静态结构,在编译过程中,会扩展成下面的结构:

type bmap struct {
    tophash [8]uint8
  keys  [8]keytype
  values [8]elementtype
  overflow uintptr //指向下一个bmap,溢出捅存放在hmap的extra字段中
}

注意key和value是分开放的,这样的形式,当key和value的类型不一样的时候,key和value占用的字节大小不一样,key/value这种形式可能会因为内存对齐而浪费空间,所以分开放更节省内存空间。

mapextra的结构:

type mapextra struct {
    overflow *[]*bmap //包含的是bmap.buckets的overflow的bucket 
    oldoverflow *[]*bmap//包含的扩容时的是bmap.oldbuckets的overflow的bucket 
    nextoverflow *bmap //指向空闲的overflow bucket的指针
}

当map中的key、value都不含指针时,bmap将完全不包含指针,bmap指向溢出桶的字段overflow是uintptr类型,为了防止overflow被gc掉,所以需要mapextra.overflow将它保存起来。

map遍历

map遍历是无序的,原因有2点:

  • map的遍历时,并不是从0号bucket开始遍历,而是从一个随机序号的bucket,再从其中随机的key开始遍历
  • map在扩容后,可能一部分key已经搬迁到了新的内存,此时key就是无序的了

map是非线程安全的,因为Go官方认为,map的典型应用场景是不需要并发访问的,而不是为了小部分情况,导致大部分程序付出加锁的代价。map可以并发读,但不能并发写,否则会panic。

map的扩容

扩容时机

在向map插入新key的时候,会进行2个条件检查,如果符合就会触发扩容

扩容条件

  1. 超过负载:map元素个数 > 6.5 *桶个数
  2. 溢出桶太多

    1. 当桶总数 < 2 ^ 15时,如果溢出桶总数 > 桶总数,则认为溢出桶太多了
    2. 当桶总数 ≥ 2 ^ 15时,如果溢出桶总数 > 2 ^ 15时,则认为溢出桶太多了

扩容机制

  1. 双倍扩容:针对扩容条件1,新建buckets数组,新的buckets大小为原来的2倍,然后旧的buckets数据搬迁到新的buckets,称之为双倍扩容
  2. 等量扩容:针对扩容条件2,并不扩大容量,buckets数量维持不变,将原来的元素搬迁到一起,将松散的键值对重新排列,使得同一个bucket中的key排列更紧密,节省空间,提高bucket利用率

元素搬迁

并不会在扩容之后立马搬迁,而是在插入或者对key的修改和删除操作时,都会尝试进行搬迁,一次最多搬迁2个bucket

唯一索引指的是有唯一性约束的索引,而普通索引则没有约束

两者在使用上的区别有:

  • 在查询场景下,未使用limit时,唯一索引匹配到之后立即返回,普通索引则需要

继续匹配下一条数据,直到不匹配才返回

  • 在更新场景下,如果数据不在buffer pool中,普通索引的更新可以先更新到change buffer中,多次更新可以在change buffer中合并,直到刷盘时才更新到磁盘,而唯一索引无法使用change buffer,因为需要从磁盘加载数据来判断是否符合唯一性

所以,如果是不存在buffer pool中的数据,需要更新的话,非唯一索引的效率更高,所以在写多读少的场景下非唯一索引的性能更高

什么叫goroutine泄漏:

如果goroutine数量是在不断增加的,资源长时间无法释放,最终导致资源耗尽,程序崩溃

泄漏原因:

  • goroutine内运行channel、mutex等读写操作被一直阻塞
  • goroutine内业务逻辑陷入死循环,资源一直无法释放
  • goroutine的业务逻辑进入长时间等待,同时又不断有新增的goroutine进入等待

泄漏场景:

  • nil channel

    
    channel忘记初始化,那么无论是读写都会造成阻塞
    
  • 无缓冲channel只发送不接收,会造成阻塞
  • 无缓冲channel只接收不发送,会造成阻塞
  • 有缓冲channel只发送不接收,缓冲队列满了之后会阻塞
  • 发起http请求时,body未被关闭,goroutine不会退出
  • 互斥锁只加锁未解锁,第一个goroutine加锁成功后不进行解锁,其他goroutine都会等待加锁而阻塞
  • sync.WaitGroup add数量大于done的数量

如何排查:pprof监测goroutine数量

在并发编程中,常见的并发调度模型是多进程、多线程,而Go中则实现了协程的并发调度大大提高了并发性能。

协程与进程、线程:

  • 进程:

    • 进程是系统进行资源分配的最小单位,是程序运行的基本单位
    • 每个进程都有独立的内存空间,不同进程之间不共享也不抢占资源
    • 进程由程序、数据集合、进程控制块(PCB)三部分组成
    • 由于进程占用的资源多,上下文切换时开销大,但是因为独享资源,所以安全稳定
  • 线程

    • 线程是CPU调度的最小单位,是进程的运行实体
    • 多个线程共享一个进程的资源,同时也有属于自己的线程级资源
    • 由线程控制块(TCB)进行控制和管理
    • 线程资源比进程少,上下文切换开销更小
  • 协程

    • 是用户态的调度单位,比线程更轻量级
    • 协程由用户程序调度不涉及用户态与内核态的切换,开销更小

什么是上下文切换?

因为CPU是时间片轮转调度的,所以CPU要知道每个任务从哪里加载,从哪里开始运行,也就是CPU寄存器和程序计数器,这就是CPU 上下文,而每个任务都有对应的CPU上下文,切换不同的任务执行的时候,就需要切换CPU 上下文。

Go中的协程:

goroutine是go中实现的用户级并发调度单元,也被称为轻量级线程

线程、goroutine的对比:

线程协程
内存占用线程栈内存消耗为1MB协程栈大小为2KB,但是可以自动扩容
创建和销毁会导致用户态和内核态的切换,解决办法是线程池只需要在用户态执行,创建和销毁的消耗小
切换需要保存各种寄存器只需保存3个寄存器

goroutine的结构:

type g struct {
    goid    int64 // 唯一的goroutine的ID
  sched   gobuf //goroutine切换时,保存g的上下文
    stack   stack //栈
    gopc //调用goroutine的地址
  startpc //调用goroutine的入口
  ...
}

type gobuf struct{
    sp uintptr //栈指针的位置
  pc uintptr //运行到程序位置
  g  guintptr //指向goroutine
  ret uintptr //保存系统调用的返回值
  ...
}

type stack struct {
    lo uintptr //栈的下界地址
  hi uintptr // 栈的上界地址
}

goroutine的状态:

  1. 空闲中:_Gidle【G刚刚创建,仍未初始化】
  2. 待运行:_Grunable 【就绪状态,G在运行队列中,等待M取出来并运行】
  3. 运行中:_Grunning【M在运行G,此时M拥有一个P】
  4. 系统调用中:_Gsyscall【M正在运行的G发起系统调用,此时M不拥有P】
  5. 等待中:_Gwaiting【G被挂起,在等待某些条件完成,此时G不在运行也不在运行队列中(可能在channel的等待队列中)】
  6. 已终止:_Gdead【G未被使用,也可能执行完毕】
  7. 栈复制中:_Gcopystack【G正在获取一个新的栈空间,并把原来的内容复制过去(用于防止GC扫描)】

goroutine状态流转:

943E311D-9879-49BF-B7AD-25581B61D604.png

创建:通过go关键的创建goroutine会新建自己的栈空间,同时在sched中维护栈地址和程序计数器这些信息,G被创建好之后,会优先放入本地队列中,本地队列满了之后,回放在全局队列中

运行:goroutine只是一个数据结构,让goroutine运行起来的是调度器,也就是GMP模型

阻塞:channel的读写操作、等待锁、等待网络数据、系统调用都可能发生阻塞,通过执行runtime.gopark() 让出CPU时间片,让调度器安排其他等待的任务运行

唤醒:处理_Gwating 的G,在调用runtime.goready()之后,会被唤醒,唤醒之后毁被重新放在M对应的P的本地队列中等待执行

退出:G执行完之后,会调用底层函数runtime.Goexit(),当调用该函数之后,goroutine会设置成dead状态

mysql中有三种日志,分别是:

  1. redo log:InnoDB引擎的重做日志,为了事务的持久性
  2. undo log:InnoDB引擎的回滚日志,为了实现事务的原子性
  3. binlog:Mysql Sever的归档日志,用于数据备份和主从同步

下面具体说明各种日志的原理

Redo Log

mysql的数据都存在磁盘中,当操作数据的时候,都需要从磁盘获取,然后在内存里操作,为了提交效率,Mysql通过Buffer Pool来将索引、数据都缓存起来,而在更新的时候,也会先更新Buffer Pool再将更新过的内存数据刷到磁盘中,而为了避免在刷新到磁盘之前宕机之类的故障导致内存数据丢失,在更新内存的时候,也会采用WAL(Write Ahead Logging)技术将数据先写入到日志中,这个日志就是Redo Log。

什么是Redo Log?

redo log是物理日志,记录的是某个数据页做了什么修改,对某个表空间的某个数据页的某个偏移量做了某个更新,每当执行一个事务就会增加一条redo log。

Undo Log

mysql的事务的原子性,就是保证事务要么成功要么失败,失败是需要回滚的,而undo log就是记录了事务提交前的数据,事务回滚时可以利用undo log进行回滚,回滚的方式则不同的操作有不同的策略:

  • insert

    
    将主键值记录下来,如果要回滚则以主键为条件删除
    
  • delete

    
    将待删除数据记录下来,如果要回滚则将数据插入回去
    
  • update

    
    将待更新数据的旧值记录下来,如果要回滚则将数据更新为旧值
    
    

不同操作下 undo log的格式也不一样,但是每一条undo log都有一个roll_pointer指针和一个trx_id事务ID:

  • 通过trx_id可以看出数据是哪个事务变更的
  • 而roll_pointer则可以将undo log串联成一个链表,这个链表称为版本链

undo log还有一个作用:ReadView + undoLog实现MVCC

undo log也是需要记录redo log的,当修改undo页的时候,会先写入到redo log中,再真正修改Undo 页面

RedoLog和UndoLog的区别:

  1. Redo Log是记录操作完成后的数据,事务提交之后崩溃,需要redo log会做故障恢复
  2. Undo Log是记录操作开始前的数据,事务提交之前崩溃,需要通过undo log回滚数据

Redo Log也不是直接写入到磁盘中的,因为每产生一条redo log就写入一次会导致很高的磁盘IO,所以会先写入到redo log buffer中,redo log的刷盘时机:

  • Mysql正常关闭时
  • 事务提交时
  • redo log buffer写入量大于分配的内存空间的一半时
  • 每隔1秒,定时刷盘

redo log有两个文件,当一个log文件满了后会在另一个log文件上继续写入,写满后再切换回来,而且redo log是一个环形结构,写到末尾后又会从开头写。

故障恢复的时候,先执行redo log,将数据恢复到最新状态,同时也会恢复undo log,再将未提交的事务回滚掉。

BinLog

redo log和undo log都是InnoDB存储引擎产生的,而binlog则是server层产生的。

mysql完成一个更新操作后,server会生成一条binlog日志,当事务提交后,该事务产生的binlog会一起写入到binlog文件。

binlog会记录所有数据表结构的变更以及数据的修改,有3种格式:

  • STATEMENT

    
    每一条操作的sql都会记录到binlog中,主从复制中,slave拿到binlog后需要重放sql来同步数据,当sql中含有动态函数时,同步出来的数据可能会不一致
    
  • ROW

    
    记录了每一条数据变更后的样子,如果是修改表机构或者是批量修改的语句,则会产生大量的变更,导致binlog文件过大
    
  • MIXED

    
    根据情况动态选择使用STATEMENT还是ROW
    
    

BinLog的刷盘时机:

  • 事务提交时,会将写入binlog cache的日志持久化到磁盘
  • 当binlog cache写入量达到binlog_cache_size参数规定的大小后会进行刷盘

事务提交时,binlog cache日志持久化到磁盘的方式有3种,由sync_binlog参数控制:

  • sync_binlog=0(默认)

    
    每次提交事务时,只将binlog cache写入到文件缓存,由系统决定什么时候fsync到磁盘
    
  • sync_binlog=1

    
    每次提交时,都将binlog cache写入到文件缓存并执行fsync,将数据刷到磁盘
    
  • sync_binlog=N(N>1)

    
    每次提交时,只将binlog cache写入到文件缓存,积累到N个事务时,执行fsync,将数据刷到磁盘
    
    

RedoLog和BinLog的区别:

  • RedoLog是覆盖写,将文件写满后会从头进行覆盖,BinLog则是追加写,写满了就创建新的文件,不会覆盖之前的数据
  • RedoLog是物理日志,记录的是对数据页的修改,而BinLog则是有3种不同的格式
  • ReadLog用于故障恢复,BinLog用于备份恢复,主从同步

二阶段提交

从上面的介绍中,可以看出,redo log会影响主库数据,binlog会影响从库数据,如果两者不一致的话,会导致主从数据不一致。

为了避免这个问题,mysql采用了二阶段提交的方式:

  • Prepare(准备阶段):将日志写入到redo log,并设置事务为prepare状态
  • Commit(提交阶段):将日志写入binlog,再设置redolog中事务状态为commit