相比较于其他语言, Go 有什么优势或者特点

  • Go 允许跨平台编译,编译出来的是二进制的可执行文件,直接部署在对应系统上即可运行
  • Go 在语言层面上天生支持并发编程,通过 goroutine 和 channel 实现。channel 的理论依据是 CSP 并发模型, 即所谓的通过通信来共享内存;Go 在 runtime 运行时里实现了属于自己的调度机制:GMP,降低了内核态和用户态的切换成本。
  • Go 是静态类型语言, 代码风格是强制性的统一,如果没有按照规定来,会编译不通过。

Golang 里的 GMP 模型

GMP 模型是 Golang 实现的一个调度模型,它抽象出三个结构:

  • G:即Goroutine,被称为用户态的线程, 存储了Goroutine的执行栈信息、Goroutine状态以及Goroutine的任务函数等(G是可以重用的)
  • P:逻辑Processor 即虚拟的处理器。P的数量决定了系统内最大可并行的G的数据, 每当有 goroutine 要创建时,会被添加到 P 上的本地 goroutine 队列上,如果 P 的本地队列已满,则会维护到全局队列里。
  • M: 系统线程, 真正执行计算的资源。在 M 上有调度函数,它是真正的调度执行者,M要运行goroutine, 必须要与 P 绑定,M会优先从 P 的本地队列获取G, 切换到G的执行栈上并行执行G的函数,调用goexit做清理工作,然后回到M 反复执行;如果本地队列没有,从全局队列获取,如果全局队列也没有,会从其他的 P 上偷取 goroutine。M并不保存G的状态,这是G可以跨M调度的基础。
G被抢占调用调度

操作系统是按时间片调度线程的,Go并没有时间片的概念。如果某个G没有进行系统调用、没有I/O操作、没有阻塞在一个channel上,那么M是怎么让G停下来并调度下一个可运行的G?
这就要说抢占调度了。
上面说了,除非是无限死循环,否则只要G调用函数,Go运行时就有了抢占G的机会。GO程序启动的时候,运行时会启动一个名为sysmon的M(你可以简单理解为监控器或监控协程),该M特殊之处就是其无需绑定P即可运行(以g0的形式),该M在整个Go程序的运行过程中非常重要。
sysmon主要工作:

  • 释放闲置超过5分钟的span物理内存;
  • 如果超过2分钟没有垃圾回收,强制执行;
  • 将长时间未处理的netpoll结果添加到任务队列;
  • 向长时间运行的G任务发出抢占调度;
  • 收回因syscall长时间阻塞的P;
channel阻塞或网络I/O下的调度

如果G被阻塞在某个channel操作或者网络I/O操作上的时候,G会被放入到某个等待队列中,而M会尝试运行P的下一个可运行的G;如果此时P没有可运行的G给M运行,那么M将解绑P,并进入挂起状态。当I/O或者channel操作完成,在等待队列中的G会被唤醒,标记为可运行,并被放入到某个P队列中,绑定一个M后继续运行。

系统调用阻塞情况下,如何调度

如果G被阻塞在某个系统调用上,那么不仅仅G会阻塞,执行G的M也会解绑P,与G一起进入挂起状态。如果此时有空闲的M,则P和与其绑定并继续执行其他的G;如果没有空闲的M,但还是有其他G去执行,那么会创建一个新M。当系统调用返回后,阻塞在该系统调用上的G会尝试获取一个可用的P,如果没有可用的P,那么这个G会被标记为runnable,之前的那个挂起的M将再次进入挂起状态。

goroutine 有什么特点,和线程相比

  • 内存占用
    goroutine 非常轻量,创建时初始内存分配只有 2KB,运行过程中当栈空间不够用时,会自动扩容。同时,自身存储了执行 stack 信息,用于在调度时能恢复上下文信息。而线程比较重,一般初始大小有几 MB(不同系统分配不同, 一般1-8MB)。
  • 创建与销毁成本
    线程是操作系统的调度基本单位, 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级
  • 切换调度
    线程切换会保存上下文信息(寄存器), 线程状态等, 保存成本高, 线程切换要消耗 1000-1500 纳秒 (一个纳秒平均可以执行 12-18 条指令, 执行指令的条数会减少 12000-18000), 性能开销较大, 而 goroutine 是用户态线程, 由 Go runtime 管理, 并不需要进入内核, 在用户态进行上下文切换, goroutine 的切换约为 200 纳秒(寄存器), 相当于 2400-3600 条指令, 因此 goroutine 切换成本要比 threads 小的多

Go 的垃圾回收机制

堆内存上分配的数据对象,不再使用时,不会自动释放内存,就变成垃圾,在程序的运行过程中,如果不能及时清理,会导致越来越多的内存空间被浪费,导致系统性能下降。
内存回收

  • 手动释放占用的内存空间
    程序代码中也可以使用runtime.GC()来手动触发GC
  • 自动内存回收
    内存分配量达到阀值触发GC
    定期触发GC, 默认情况下,最长2分钟触发一次GC

三色标记最大的好处是可以异步执行,以中断时间极少的代价或者完全没有中断来进行整个 GC。
Go 采用的是三色标记法,将内存里的对象分为了三种:

  • 白色对象:未被使用的对象;
  • 灰色对象:当前对象有引用对象,但是还没有对引用对象继续扫描过;
  • 黑色对象,对灰色对象的引用对象已经全部扫描过了,下次不用再扫描它了。

只要是新创建的对象, 默认都会标记为白色, 当垃圾回收开始时,Go 会把根对象标记为灰色,其他对象标记为白色,然后从根对象遍历搜索,按照上面的定义去不断的对灰色对象进行扫描标记。当没有灰色对象时,表示所有对象已扫描过,然后就可以开始清除白色对象了。
go 1.3 之前采用标记清除法,需要STW(stop the world)需要暂停用户所有操作
go 1.5 采用三色标记法,插入写屏障机制(只在堆内存中生效),最后仍需对栈内存进行STW
go 1.8 采用混合写屏障机制,屏障限制只在堆内存中生效。避免了最后节点对栈进行STW的问题,提升了GC效率

channel 的内部实现是怎么样的

底层 hchan结构体的主要组成部分

  • 用来保存goroutine之间传递数据的循环链表-------->buf
  • 用来记录此循环链表当前发送或接收数据的下标值---------->sendx和recvx
  • 用于保存向该chan发送和从该chan接收数据的goroutine队列---------->sendq和recvq
  • 保证chan写入和读取数据时的线程安全的锁----------->lock

channel 内部通过队列实现, 有一个唤醒队列队列作为缓冲区,队列的长度是创建chan时指定的。维护了两个 goroutine 等待队列,一个是待发送数据的 goroutine 队列,另一个是待读取数据的 goroutine 队列。
从channel中读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞;向channel中写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞。被阻塞的goroutine将会被挂在channel的等待队列中:

  • 因读阻塞的goroutine会被向channel写入数据的goroutine唤醒
  • 因写阻塞的goroutine会被从channel读数据的goroutine唤醒

直到有其他 goroutine 执行了与之相反的读写操作,将它重新唤起。

并且内部维护了一个互斥锁, 来保证线程安全, 即在对buf中的数据进行入队和出队操作时, 为当前channel使用了互斥锁, 防止多个线程并发修改数据

向channel写数据

  1. 如果recvq队列不为空,说明缓冲区没有数据或无缓冲区,且有等待取值的goroutine在排队, 此时直接从recvq等待队列中取出一个G,并把数据写入,最后把该G唤醒,结束发送过程;
  2. 如果缓冲区有空余位置,则把数据写入缓冲区中,结束发送过程;
  3. 如果缓冲区没有空余位置,将当前G加入sendq队列,进入休眠,等待被读goroutine唤醒;

从channel读数据

  1. 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq队列中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
  2. 如果等待发送队列sendq不为空,说明缓冲区已满,从缓冲队列中首部读取数据,从sendq等待发送队列中取出G,把G中的数据写入缓冲区尾部,结束读取过程;
  3. 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;

对已经关闭的 channel 进行读写,会怎么样

  • 当 channel 被关闭后,如果继续往里面写数据,程序会直接 panic 退出
  • 关闭已经关闭的channel会发生Panic
  • 关闭值为 nil 的channel会发生Panic

如果是读取关闭后的 channel,不会产生 pannic,还可以读到数据。但关闭后的 channel 没有数据可读取时,将得到零值,即对应类型的默认值。

// 判断当前 channel 是否被关闭
if v, ok := <-ch; !ok {fmt.Println("channel 已关闭,读取不到数据")}

还可以使用下面的写法不断的获取 channel 里的数据:

// range迭代从channel中读数据, 只有当channel关闭后才能退出循环, 否则没有数据了也会一直阻塞for data := range ch {// get data dosomething}

使用for-range读取channel, 这样既安全又便利, 当channel关闭时, 循环会自动退出, 无需主动检测channel是否关闭, 可以防止读取已经关闭的channel, 造成读取数据为通道所存储类型的零值

map 为什么是不安全的

Go 官方认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问),导致大部分程序付出加锁代价(性能),所以决定了不支持并发安全。
map 在并发编程中, 读是线程安全的, 写不是
map 在扩缩容时,需要进行数据迁移,迁移的过程并没有采用锁机制防止并发操作,而是会对某个标识位标记为 1,表示此时正在迁移数据。如果有其他 goroutine 对 map 也进行写操作,当它检测到标识位为 1 时,将会直接 panic。

如果想实现map线程安全

  1. 使用 map + 读写锁 sync.RWMutex
  2. 使用 sync.map
    sync.map是通过读写分离实现的,拿空间换时间, 通过冗余两个数据结构(read、dirty), 减少加锁对性能的影响, 可以无锁访问 read map, 而且会优先操作read map(不需要锁),倘若只操作read map就可以满足要求(增删改查遍历),那就不用去操作dirty map(它的读写都要加锁),所以在某些特定场景中它发生锁竞争的频率会远远小于方式1。

sync.Map 适合读多写少的场景, 且性能比较好,否则并发性能很差, 因为会动态调整,miss次数多了之后,将dirty数据提升为read

concurrent-map 提供了一种高性能的解决方案:通过对内部 map 进行分片,降低锁粒度,从而达到最少的锁等待时间(锁冲突)。, double-checking, 延迟删除。 删除一个键值只是打标记,只有在提升dirty的时候才清理删除的数据

map 的 key 为什么得是可比较类型的

map 的 key、value 是存在 buckets 数组里的,每个 bucket 又可以容纳 8 个 key-value 键值对。当要插入一个新的 key - value 时,会对 key 进行哈希计算得到一个 hash 值,然后根据 hash 值的低几位(取几位取决于桶的数量)来决定命中哪个 bucket。

在命中某个 bucket 后,又会根据 hash 值的高 8 位来决定是 8 个 key 里的哪个位置。若发生了 hash 冲突,即该位置上已经有其他 key 存在了,则会去其他空位置寻找插入。如果全都满了,则使用 overflow 指针指向一个新的 bucket,重复刚刚的寻找步骤。

从上面的流程可以看出,在判断 hash 冲突,即该位置是否已有其他 key 时,肯定是要进行比较的,所以 key 必须得是可比较类型的。像 slice、map、function 就不能作为 key。

遍历时, map 的 key 为什么是无序的

  • 首先, map 在扩容后,会发生 key 的迁移,原来落在同一个 bucket 中的 key,可能迁移到别的 bucket 中。即使按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。由于扩容导致 key 的位置发生变化,遍历 map 也可能不按原来的顺序了
  • 再者, 当遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了

Go 的逃逸行为

在 Go 里变量的内存分配方式则是由编译器来决定的。编译器在静态编译时,会做逃逸分析, 分析对象的生命周期及引用情况来决定对象内存分配到堆上还是栈上,如果变量在作用域(比如函数范围)之外,还会被引用的话,那么称之为发生了逃逸行为,此时将会把对象放到堆上, 如果没有发生逃逸行为,则优先会被分配到栈上。
栈上分配的内存,在函数执行结束后可自动将内存回收, 堆上分配的内存, 则函数执行结束后可交给GC进行处理

go build -gcflags '-m -l' *.go
  • 指针逃逸
  • 栈空间不足逃逸
  • 动态类型逃逸
  • 闭包引用对象逃逸

context 使用场景及注意事项

Go 里的 context 有 cancelCtx 、timerCtx、valueCtx。它们分别是用来通知取消、通知超时、存储 key - value 值

  • context 的 Done() 方法往往需要配合 select {} 使用,以监听退出。
  • 尽量通过函数参数来暴露 context,不要在自定义结构体里包含它。
  • WithValue 类型的 context 应该尽量存储一些全局的 data,而不要存储一些可有可无的局部 data。
  • context 是并发安全的。
  • 一旦 context 执行取消动作,所有派生的 context 都会触发取消。

context 是如何一层一层通知子 context

当 ctx, cancel := context.WithCancel(父Context)时,会将当前的 ctx 挂到父 context 下,然后开个 goroutine 协程去监控父 context 的 channel 事件,一旦有 channel 通知,则自身也会触发自己的 channel 去通知它的子 context, 关键代码如下

go func() {select {case <-parent.Done():child.cancel(false, parent.Err())case <-child.Done():}
}()

乐观锁和悲观锁

乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题

  • 乐观锁
    乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作或重试,否则执行操作
    == Golang中有一个 atomic 包,可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作,这个包应用的便是乐观锁的原理。==
    不过这个包只支持int32/int64/uint32/uint64/uintptr这几种数据类型的一些基础操作(增减、交换、载入、存储等)
  • 悲观锁
    悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据
    Golang中的sync包,提供了各种锁,如果使用了这个包,基本上就以悲观锁的工作模式了

乐观锁没有加锁和解除锁的步骤,直觉上会快一些;但是乐观锁这么做的前提是总认为不会发生并发,如果并发发生的概率很大,重试的次数会增加,这种情况下乐观锁的性能就差很多了。

悲观锁有加锁和解除锁的步骤,直觉上会慢一些;但是当有很多进程或者线程对同一个数值进行修改时,能避免大量的重试过程,这种情况下悲观锁的性能相对就很高了。

Go语言读写锁 RWMutex 详解

Go语言 中的 Mutex,它是一把互斥锁,每次只允许一个 goroutine 进入临界区,这种可以保证临界区资源的状态正确性。但是有的情况下,并不是所有 goroutine 都会修改临界区状态,可能只是读取临界区的数据,如果此时还是需要每个 goroutine 拿到锁依次进入的话,效率就有些低下了
RWMutex 是一个读/写互斥锁,在某一时刻只能由任意数量的 reader 持有 或者 一个 writer 持有。也就是说,要么放行任意数量的 reader,多个 reader 可以并行读;要么放行一个 writer,多个 writer 需要串行写。
RWMutex 对外暴露的方法有五个:

  • RLock():读操作获取锁,如果锁已经被 writer 占用,会一直阻塞直到 writer 释放锁;否则直接获得锁;
  • RUnlock():读操作完毕之后释放锁;
  • Lock():写操作获取锁,如果锁已经被 reader 或者 writer 占用,会一直阻塞直到获取到锁;否则直接获得锁;
  • Unlock():写操作完毕之后释放锁;
  • RLocker():返回读操作的 Locker 对象,该对象的 Lock() 方法对应 RWMutex 的 RLock(),Unlock() 方法对应 RWMutex 的 RUnlock() 方法。

可以想象 RWMutex 有两个队列,一个是包含 所有reader 和你获得准入权writer 的 队列A,一个是还没有获得准入权 writer 的 队列B。

  1. 队列 A 最多只允许有 一个writer,如果有其他 writer,需要在 队列B 等待;
  2. 当一个 writer 到了 队列A 后,只允许它 之前的reader 执行读操作,新来的 reader 需要在 队列A 后面排队;
  3. 当前面的 reader 执行完读操作之后,writer 执行写操作;
  4. writer 执行完写操作后,让 后面的reader 执行读操作,再唤醒队列B 的一个 writer 到 队列A 后面排队。

初始时刻 队列A 中 writer W1 前面有三个 reader,后面有两个 reader,队列B中有两个 writer

并发读 多个 reader 可以同时获取到读锁,进入临界区进行读操作;writer W1 在 队列A 中等待,同时又来了两个 reader,直接在 队列A 后面排队

写操作 W1 前面所有的 reader 完成后,W1 获得锁,进入临界区操作

获得准入权 W1 完成写操作退出,先让后面排队的 reader 进行读操作,然后从 队列B 中唤醒 W2 到 队列A 排队。W2 从 队列B 到 队列A 的过程中,R8 先到了 队列A,因此 R8 可以执行读操作。R9、R10、R11 在 W2 之后到的,所以在后面排队;新来的 W4 直接在队列B 排队。

RWMutex 可以看作是没有优先级,按照先来先到的顺序去执行,只不过是 多个reader 可以 并行去执行罢了

sync.WaitGroup原理

waitgroup 内部维护了一个计数器,当调用 wg.Add(1) 方法时,就会增加对应的数量;当调用 wg.Done() 时,计数器就会减一。直到计数器的数量减到 0 时,就会唤起之前因为 wg.Wait() 而阻塞住的 goroutine。

sync.Once 原理

内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了

type Once struct {done uint32m    Mutex
}func (o *Once) Do(f func()) {// 原子加载标识值,判断是否已被执行过if atomic.LoadUint32(&o.done) == 0 {o.doSlow(f)}
}func (o *Once) doSlow(f func()) { // 还没执行过函数o.m.Lock()defer o.m.Unlock()if o.done == 0 { // 再次判断下是否已被执行过函数defer atomic.StoreUint32(&o.done, 1) // 原子操作:修改标识值f() // 执行函数}
}

atomic 原子操作

  • 原子操作
    在进行过程中不能被中断的操作, 即针对某个值的原子操作在被进行的过程中, CPU绝不会再去进行其他的针对该值的操作, 无论这些其他的操作是否为原子操作都会是这样, 为了实现这样的严谨性, 原子操作仅会由一个独立的CPU指令代表和完成, 只有这样才能在并发环境下保证原子操作的绝对安全, sync/atomic

  • atomic包提供了底层的原子级内存操作, 对于同步算法的实现很有用, 这些函数必须谨慎的保证正确使用, 除了某些特殊的底层应用, 使用通道或者sync包的函数/类型实现同步更好

  • 多个goroutine同时操作共享资源, 可以用锁或者原子操作去实现并发安全, 原子操作内部也是实现了锁

sync.Pool 对象缓存

  • 私有对象
    协程安全
  • 共享池
    协程不安全
  • 对象获取
    尝试从私有对象获取, 私有对象不存在, 尝试从当前 Processor 的共享池获取, 如果当前 Processor 共享池也是空的, 那么就尝试去其他 Processor 的共享池获取, 如果所有池子都是空的, 最后就用用户指定的 New 函数产生一个新的对象返回
  • 对象放回
    如果私有对象不存在则保存为私有对象, 如果私有对象存在, 放入当前 Processor 的共享池中
  • 生命周期
    GC 会清除 sync.Pool 缓存的对象, 对象的缓存有效期为下一次 GC 前

临时对象池, 可以减轻程序频繁创建对象的消耗,以减轻垃圾回收的压力, Pool中的数据在每次GC的时候都会清掉,所以不能用在一些需要保持连接的场景下, 不适合做连接池等, 需自己管理生命周期的资源的池化

mq := &sync.Pool{New: func() interface{} {return 0},
}
a := mq.Get().(int)
mq.Put(10)

协程池

绝大部分应用场景, go是不需要协程池的, goroutine的创建很轻量

  1. 可以限制goroutine数量, 避免无限制的增长占用系统资源, 减轻runtime调度压力
  2. 减少栈扩容的次数
  3. 频繁创建goroutine的场景下, 资源复用, 节省资源 (一般场景效果不明显, 需要一定规模)

go对goroutine有一定的复用能力, 所以要根据场景选择是否使用协程池, 不恰当的场景不仅得不到收益, 反而增加系统复杂性

ants是一个高性能的 goroutine 池,实现了对大规模 goroutine 的调度管理,允许使用者在开发并发程序的时候限制 goroutine 数量,复用资源,达到更高效执行任务的效果

// Pool 协程池实现(创建指定数量协程后, 任务执行完不会销毁, 等待继续执行), 协程复用, 避免协程频繁创建
type Pool struct {work chan func()   // 用于接收 task 任务sem  chan struct{} // 用于设置协程池大小, 即可同时执行的协程数量
}// New 用于创建一个协程池对象
func New(size int) *Pool {return &Pool{work: make(chan func()),         // 无缓冲区通道sem:  make(chan struct{}, size), // 缓冲通道, size 大小即为协程池大小}
}// NewTask 往协程池中添加任务
func (p *Pool) NewTask(task func()) {// 当第一次调用添加任务时, 由于 work 是无缓冲通道, 所以一定会走第二个 case 的分支: 使用 go worker 开启一个协程select {case p.work <- task: // 这里对应case p.sem <- struct{}{}:go p.worker(task)}
}// 用于执行任务
func (p *Pool) worker(task func()) {// 为了能够实现协程的复用, 这个使用了 for 无限循环, 使这个协程在执行完任务后, 也不退出, 而是一直在接收新的任务defer func() { <-p.sem }()for {task()task = <-p.work // 这里对应}
}

定时器原理

一开始,timer 会被分配到一个全局的 timersBucket 时间桶。每当有 timer 被创建出来时,就会被分配到对应的时间桶里了。

为了不让所有的 timer 都集中到一个时间桶里,Go 会创建 64 个这样的时间桶,然后根据 当前 timer 所在的 Goroutine 的 P 的 id 去哈希到某个桶上:

// assignBucket 将创建好的 timer 关联到某个桶上
func (t *timer) assignBucket() *timersBucket {id := uint8(getg().m.p.ptr().id) % timersLent.tb = &timers[id].timersBucketreturn t.tb
}

接着 timersBucket 时间桶将会对这些 timer 进行一个最小堆的维护,每次会挑选出时间最快要达到的 timer。如果挑选出来的 timer 时间还没到,那就会进行 sleep 休眠;如果 timer 的时间到了,则执行 timer 上的函数,并且往 timer 的 channel 字段发送数据,以此来通知 timer 所在的 goroutine。

Gorouinte 泄漏

Go的并发是以goroutine和channel的形式实现的。协程泄露是指goroutine创建后,长时间得不到释放,并且还在不断地创建新的goroutine协程,最终导致内存耗尽,程序崩溃。
gorouinte 里有关于 channel 的操作,如果没有正确处理 channel 的读取,会导致 channel 一直阻塞住, goroutine 不能正常结束

  • 缺少接收器,导致发送阻塞
  • 死锁(dead lock)
    同一个goroutine中,使用同一个无缓冲区channel读写
    channel 和 读写锁、互斥锁混用

多 goroutine 任务同时取消

  • 全局标志位 (不是非法安全)
    共享内存做法, 即定义一个变量, 然后判断这个变量是 true 或 false 来取消任务
  • CSP 模式 (channel 并发安全, 取消所有通道需提前知道要关闭的数量)
    通过通信来共享内存而不是通过共享内存来通信, 即定义一个信号通道, 在每一个协程中去判断当前通道是否有值, 有值则取消任务, 但是通道取值是一次性的, 取完就没了, 所以写入值后只能对某一个协程任务有效, 若有多个协程任务时, 取消时并不能影响所有的任务协程, 也可以写入指定个数的信号值取消所有, 但是这样就必须提前知道有多个任务协程在运行, 耦合性太高, 代码不够灵活
  • close(ch) 广播机制 (channel 并发安全, 可以取消所有通道)
    利用 close() 关闭通道的广播机制, 通道关闭后会给所有的 goroutine 发送信号, 使所有阻塞状态的通道接收值然后被唤醒, 然后继续执行下去
  • context (并发安全)
    推荐使用内置包 context 的 context.WithCancel() 方法

Slice 注意点

  • Slice 的扩容机制
    如果 Slice 要扩容的容量大于 2 倍当前的容量,则直接按想要扩容的容量来 new 一个新的 Slice,否则继续判断当前的长度 len,如果 len 小于 1024,则直接按 2 倍容量来扩容,否则一直循环新增 1/4,直到大于想要扩容的容量。除此之外,还会根据 slice 的类型做一些内存对齐的调整,以确定最终要扩容的容量大小。
  • Slice 的一些注意写法
// =========== 第一种
a := make([]string, 5)
fmt.Println(len(a), cap(a))   //  输出5   5
a = append(a, "aaa")
fmt.Println(len(a), cap(a))   // 输出6  10
// 总结: 由于make([]string, 5) 则默认会初始化5个 空的"", 因此后面 append 时,则需要2倍了
// =========== 第二种
a:=[]string{}
fmt.Println(len(a), cap(a))   //  输出0   0
a = append(a, "aaa")
fmt.Println(len(a), cap(a))   // 输出1  1
// 总结:由于[]string{}, 没有其他元素, 所以append 按 需要扩容的 cap 来
// =========== 第三种
a := make([]string, 0, 5)
fmt.Println(len(a), cap(a))   //  输出0   5
a = append(a, "aaa")
fmt.Println(len(a), cap(a))   // 输出1  5
// 总结:注意和第一种的区别,这里不会默认初始化5个,所以后面的append容量是够的,不用扩容
// =========== 第四种
b := make([]int, 1, 3)
a := []int{1, 2, 3}
copy(b, a)
fmt.Println(len(b))  // 输出1
// 总结:copy 取决于较短 slice 的 len, 一旦最小的len结束了,也就不再复制了
  • range slice
    以下代码的执行是不会一直循环下去的,原因在于 range 的时候会 copy 这个 slice 上的 len 属性到一个新的变量上,然后根据这个 copy 值去遍历 slice,因此遍历期间即使 slice 添加了元素,也不会改变这个变量的值了
v := []int{1, 2, 3}
for i := range v {v = append(v, i)
}

使用range遍历切片时会先拷贝一份,然后再遍历拷贝数据,只声明一个变量v, 然后在遍历时把切片元素赋值给v, 如果 slice 里存储的是指针集合,那在遍历里修改v是有效的,如果 slice 存储的是值类型的集合,期间的修改v也只是在修改这个副本v,跟原来的 slice 里的元素是没有关系的

s := []int{1, 2}
for k, v := range s {}
会被编译器认为是
for_temp := s
len_temp := len(for_temp)
for index_temp := 0; index_temp < len_temp; index_temp++ {value_temp := for_temp[index_temp]_ = index_tempvalue := value_temp}
  • slice 入参注意点
    如果 slice 作为函数的入参,通常希望对 slice 的操作可以影响到底层数据,但是如果在函数内部 append 数据超过了 cap,导致重新分配底层数组,这时修改的 slice 将不再是原来入参的那个 slice 了。因此通常不建议在函数内部对 slice 有 append 操作,若有需要则显示的 return 这个 slice。

参数传递切片和切片指针有什么区别

切片底层就是一个结构体,里面有三个元素, 分别表示切片底层数据的地址,切片长度,切片容量

  • 当切片作为参数传递时,其实就是一个结构体的传递,因为Go语言参数传递只有值传递,传递一个切片就会浅拷贝原切片,但因为底层数据的地址没有变,所以在函数内对切片的修改,也将会影响到函数外的切片, 但如果指向底层数组的指针被覆盖或者修改(copy、重分配、append触发扩容),此时函数内部对数据的修改将不再影响到外部的切片,代表长度的len和容量cap也均不会被修改。
  • 参数传递切片指针就很容易理解了,如果你想修改切片中元素的值,并且更改切片的容量和底层数组,则应该按指针传递。

make 和 new 的区别

new
  • 分配内存。将会申请某个类型的内存, 内存里存的值是对应类型的零值
  • 只有一个参数。参数是分配的内存空间所存储的变量类型,Go语言里的任何类型都可以是new的参数,比如int, 数组,结构体,甚至函数类型都可以
  • 返回的是某类型的指针
make
  • 分配和初始化内存
  • 只能用于slice, map和chan这3个类型,不能用于其它类型。如果是用于slice类型,make函数的第2个参数表示slice的长度,这个参数必须给值
  • 返回的是原始类型,也就是slice, map和chan,不是返回指向slice, map和chan的指针

进程, 线程,协程

  • 程序
    编译成功后得到的二进制文件, 占用磁盘空间, 一个程序可以启动多个进程
  • 进程
    运行起来的程序, 占用系统资源(内存), 是系统资源分配的最小单位, 每一个进程都有自己独立的内存空间, 是应用程序运行的载体
  • 线程
    也称轻量级进程 (Lightweight Process), 是最小的执行单位, 一个进程可以有多个线程,每个线程会共享父进程的资源, CPU分配时间轮片的对象, 线程共享进程地址空间
  • 协程
    Coroutine, 轻量级线程, 独立的栈空间, 共享堆空间, 完全由用户自己的程序进行调度, 属于用户态线程, 轻量且开销较小, 对内核透明, 也就是系统并不知道有协程的存在

defer、panic、recover 三者的用法

defer 函数调用的顺序是先进后出,当产生 panic 的时候,会先执行 panic 前面的 defer 函数后才真的抛出异常。一般的,recover 会在 defer 函数里执行并捕获异常,防止程序崩溃。

package mainimport "fmt"func main() {defer func(){fmt.Println("b")}()defer func() {if err := recover(); err != nil {fmt.Println("捕获异常:", err)}}()panic("a")
}
// 输出
// 捕获异常: a
// b

return 与 defer

return 并不是原子操作, 底层是两个步骤

  1. 返回值赋值, 返回值有匿名返回值, 具名返回值
  2. 执行defer
  3. 执行RET指令, 函数携带当前返回值退出

若为匿名返回值, 赋值后, defer中的操作对返回值没有影响, 若为具名返回值, 赋值后, defer中对返回值的操作, 影响最后的返回值

slice 和 array 的区别

数组array是固定长度的,不能动态扩容, 在编译时就确定大小, 并且是值类型的,也就是说是拷贝复制的, 由于长度固定, 在某些场景下使用不方便, 不灵活
切片slice 是对数组的抽象, 动态数组, 长度不固定支持动态扩容, 引用类型,指向了一个动态数组的指针

拷贝大切片一定比拷贝小切片代价大吗

Go语言中只有值传递
切片本质内部结构是一个struct, 包含 Data uintptr, Len int, Cap int, 第一个字段是指向切片底层数组的指针,这是切片的存储空间,第二个字段是切片的长度,第三个字段是容量。将一个切片变量分配给另一个变量只会复制三个机器字,大切片跟小切片的区别无非就是 Len 和 Cap的值比小切片的这两个值大一些,如果发生拷贝,本质上就是拷贝上面的三个字段。所以在值拷贝时, 大切片并不需要更昂贵的操作

切片的深浅拷贝

深浅拷贝的本质,就是看拷贝内容是数据还是数据的地址, 即复制出来的新对象与原来的对象在它们发生改变时,是否会相互影响

  • 使用=操作符拷贝切片,这种就是浅拷贝
  • 使用[:]下标的方式复制切片,这种也是浅拷贝
  • 使用Go语言的内置函数copy()进行切片拷贝,开辟一个新的内存空间, 这种就是深拷贝

零切片、空切片、nil切片是什么

  • 零切片
    我们把切片内部数组的元素都是零值或者底层数组的内容就全是 nil的切片叫做零切片,使用make创建的、长度、容量都不为0的切片就是零值切片:
slice := make([]int, 5) // 0 0 0 0 0
slice := make([]*int, 5) // nil nil nil nil nil
  • nil切片
    nil切片的长度和容量都为0,并且和nil比较的结果为true,采用直接创建切片的方式、new创建切片的方式都可以创建nil切片
var slice []int
var slice = *new([]int)
  • 空切片
    空切片的长度和容量也都为0,但是和nil的比较结果为false,因为所有的空切片的数据指针都指向同一个地址;使用字面量、make可以创建空切片
var slice = []int{}
var slice = make([]int, 0)

redis 是单线程还是多线程, 为什么快

单线程

Redis无论什么版本, 都是工作单线程, 指令串行执行, 内部保证线程安全, 外部使用时业务上要自行保证(互斥锁)
而6.x 版本为 IO 多线程, 提高吞吐量, 更好的压榨系统和硬件资源, 单机可支持约 55000-75000 的 QPS

原子操作:
  1. 单指令
  2. pipeline管道 (一个客户端的指令集合, 客户端先攒后发再执行)
  3. lua脚本的方式
  4. 事务的执行期间 (事务有queue的概念, 事务是先发送, 在服务端攒着, 再执行, 而且事务若有指令失败, 失败就是失败, 其他继续执行, 没有回滚, 所以少使用事务且事务里指令尽量少和快, 避免由于串行影响其他客户端的执行和响应)
为什么快
  • 纯内存操作, 读写不涉及磁盘IO
  • 在底层上, Redis 使用epoll多路复用 (IO管理, 不负责数据的读写, 只监听读写的事件, Redis服务端程序一直在循环监听, 有事件时再去内核读取数据) 的网络IO模型,能较好的保障吞吐量。(Redis-client 在操作时会产生具有不同事件类型的 Socket, 在服务端, 有一段IO多路复用程序, 将其置入队列之中, 事件分派器依次去队列中取, 转发到不同的事件处理器中)
  • redis 采用了单线程处理请求,串行执行指令, 避免了线程切换和锁竞争而带来额外的资源消耗。
顺序性

对于服务端来说, 多个客户端被读取的顺序不能被保障, 但是在一个连接 (socket) 里的指令是有顺序的

Redis 数据类型及使用场景

Redis 最常用的场景是做数据缓存, 一般不作为存储

string

支持对字符串, 位, 数的操作

  • 计数功能,比如点赞数、粉丝数的操作, 计数器
  • 记录 session, token, 为服务无状态
hash

主要是用来存储对象 (整个对象进行存储,里面包含了多个字段)

  • 用户信息
    用户信息序列化后的数据
  • 购物车
    利用hash结构, 用户ID为 key, 商品ID 为 field, 商品数量为 value, 添加商品 hset cart:1001 10088 1, 增加数量 hincrby cart:1001 10088, 1. 优点: 同类数据归类整合存储, 方便数据管理 2. 相比 string 操作消耗CPU和内存更小, 性能更高且存储更节省空间, 缺点: 1. 过期功能不能使用在field上, 只能用在 key 上 2. Redis集群架构下不适合大规模使用(若值很大, 但key固定, 则只会存储到同某一个节点上, 没办法根据key做分片存储, 导致数据过于集中)
list

字符串列表(双向列表),允许从两端进行 push,pop 操作,还支持一定范围的列表元素

  • 消息队列
    利用 list, Stack 栈 (先进后出 FILO) = LPUSH + LPOP, Queue 队列 (先进先出) = LPUSH + RPOP, Blocking MQ 阻塞消息队列 = LPUSH + BRPOP, 获取已关注的微博消息和微信公众号消息, LPUSH msg:uid msgId, LRANGE msg:uid 0 4
sorted set

有序集合,在集合的基础上提供了排序功能,通过一个 score 属性来进行排序。

  • 排行榜/新闻热搜榜
    有序集合(sorted set)每次写入都会进行排序,而且不含重复值,所以我们可以将用户的唯一标识,比如 userId 作为 key,分数作为 score,然后就可以进行 ZADD 操作,以得到排行榜。
set

一个不重复值的组合,提供了交集、并集、差集等操作

  • 参与并抽奖
  • 点赞与收藏
  • 关注模型
  • 电商标签筛选
GEO
  • 附近的人
    Redis GEO 主要用于存储地理位置信息,并对存储的信息进行操作, geoadd:添加地理位置的坐标。georadius:根据用户给定的经纬度坐标来获取指定范围内的地理位置集合

Redis 通信协议 是怎么样的

edis 采用文本序列化协议 (通过一些规范格式去解析文本),和 http 协议一样,一个请求一个响应,客户端接到响应后再继续请求。也可以发起多次请求,然后一次响应回所有执行结果,即所谓的 pipeline 管道技术。

Redis 淘汰策略

内存空间不足的情况下会发生淘汰, 可以设置数据达到多大后执行淘汰策略, 在 Redis 的配置文件 redis.conf 里我们可以进行淘汰策略的设置

  • 可以设置不允许淘汰
  • LRU :最近最少使用的淘汰掉
  • TTL :越早过期的越先淘汰掉。
  • RANDOM:采用随机算法淘汰掉。

Redis 的过期key删除策略

回收即表示该key无用, 尽量将内存中无用的空间回收
Redis 采用的是定期删除 + 惰性删除策略

  • 定期删除
    Redis 默认每隔100ms检查是否有过期的 Key, 有过期的 Key 则删除, 但是 Redis 不是将所有的 Key 都检查一遍, 而是随机抽取进行检查 (若每隔100ms对全部 Key 进行检查, Redis 岂不是卡死), 因此, 如果只采用定期删除策略, 会导致很多 Key 到时间没有删除, 于是惰性删除派上用场
  • 惰性删除
    请求获取某个 Key 的时候, Redis 会检查一下, 这个 Key 如果设置了过期时间, 是否过期了, 若过期则此时就会删除

但是采用定期删除 + 惰性删除 也会有问题, 如果定期删除时没有删除的 Key, 然后也没及时去请求 Key, 也就是说惰性删除也没生效, 这样 Redis 内存会越来越高, 那么就会再加上内存淘汰机制

redis 的持久化机制有哪些

  • RDB
    该机制可以在指定的时间间隔内生成数据集的时间点快照, 即在指定的时间间隔将 Redis 内存里的数据镜像下来,保存到文件里。
    它会先 fork 一个子进程,将数据的写入交给子进程,而父进程不会涉及到磁盘的 IO 操作,所以 RDB 的性能非常好。
    由于 RDB 文件只存储了某个时刻的内存数据,并没有什么逻辑命令,所以在进行重启恢复时,能很快的加载进来。
    虽然 RDB 的 fork 能使得 Redis 的持久化独立进行,但是一旦数据量比较大的,就会一直占用 CPU,可能会影响到父进程的进行。
  • AOF
    记录服务器执行的所有写操作命令,即将对数据的写操作追加到文件里, 并在服务器启动时,通过重新执行这些命令来还原数据集。
    AOF允许我们以每秒的速度进行持久化,这样可以很大程度减少数据丢失, 同时它采用追加的方式进行写文件,这样即使持久化失败,影响较少。不过日志可能会越来越大,需要靠重写来减少对磁盘的占用。
  • RDB + AOF
    将 RDB 和 AOF 结合起来,组合它们各自的优点。其文件时前半部分是 RDB 格式,后半部分是 AOF 格式, 当Redis重启时,它会优先使用AOF文件来还原数据集,因为AOF文件保存的数据集通常比RDB文件所保存的数据集更完整

Redis 事务

事务是先发送指令, 在服务端攒着, 再执行, 而且事务若有指令失败, 失败就是失败, 其他继续执行, 没有回滚

  • Multi: 开始事务
  • Exec: 执行事务
  • Discard: 回滚事务

如何实现分布式锁

保证多机器, 多进程, 多线程访问资源的同步问题和数据一致性问题

类cas自旋式分布式锁, 询问的方式, 尝试加锁 (MySQL, Redis)

Redis: 工作单线程, 业务处理为串行方式

  • Redis
  1. 加锁: SETNX key value , 原子操作, 当key不存在时, 完成创建并返回成功, 否则返回失败, 获取锁成功后执行后续逻辑, 只加锁但未释放锁会出现死锁
  2. 释放锁: DEL key, 通过删除键值对来释放锁, 以便其他线程通过SETNX命令获得锁, 加锁后, 程序还没执行释放锁, 程序挂了, 会出现死锁
  3. 为锁设置过期时间: EXPIRE key timeout, 设置key的超时时间, 保证锁在没有被显式释放时, 也能在一定时间后自动释放, 避免资源被永久锁住, 当过期时间到达, Redis会自动删除对应的key-value, SETNX和EXPIRE非原子性, Redis支持nx和ex操作是同一原子操作 set key value [expiration EX seconds|PX milliseconds] [NX|XX]
  4. 锁误删除: 若线程A成功获得锁并设置过期时间30秒, 但线程A执行时间超过30秒, 锁过期自动释放, 此时线程B获得锁并设置过期时间, 随后线程A执行完成并通过DEL命令来释放锁, 但此时线程B加的锁还没有执行完成, 线程A实际释放了线程B的锁, 即避免超时时间设置不合理时, 自己的锁被其他线程释放掉, 导致锁一直失效, 通过在value中设置当前线程加锁的标识, 在删除之前验证key对应的value, 判断是否当前线程持有, 可生成一个UUID标识当前线程
  5. 超时解锁导致并发: 若线程A成功获得锁并设置过期时间30秒, 但线程A执行时间超过30秒, 锁过期自动释放, 此时线程B获得锁, 线程A和线程B并发执行, 使用Redission, 通过WatchDog机制为将要过期但未释放的锁增加有效时间
  6. Redis主从复制: 客户端A在Redis的master节点上拿到了锁, 但是这个加锁的key还没有同步到slave节点, master故障, 发生故障转移, 一个slave节点升级为master节点, 客户端B也可以获得同个key的锁, 这就导致多个客户端都拿到锁, 使用RedLock(不是Redis实现, 是client实现的算法), 利用多个Redis集群, 用多数的集群加锁成功, 减少Redis某个集群出故障造成分布式锁出现问题的概率(只要过半就可以获得锁)
  • MySQL
    基于MySQL数据库的主键或唯一索引的唯一性, 同一个key在表中只能插入一次
event事件通知我后续锁的变化, 轮询向外的过程 (Zookeeper, Etcd)
  • Zookeeper
    zk本身可以存储数据, 其数据是存在内存中的, 但是提供了持久化机制, 分布式协调(可以利用其内部机制实现分布式锁, 分布式ID, 分布式配置, 分布式注册发现, 分布式高可用等), zk集群中有leader和follower节点, leader主要负责外界的写操作(增, 删, 改), 而follower只负责读操作, leader与follower同步数据采用两阶段提交和过半通过, 即当有数据写入时, leader收到后先将数据写入自己的本地文件, 返回自己ack, 然后将数据发送给所有的follower, follower节点将数据写到自己的文件中, 然后返回ack给leader, leader收到半数以上(集群所有节点数的半数)的ack时, 再向自己和所有的follower发送commit, leader和follower收到commit后把文件中的数据写到内存中, 返回客户端写入成功
  • 选举
    若leader挂掉, zk会在follower中选举出一个新leader, 选举时间在200ms, 选举采用推让制, 节点间互相发消息, 带着自己的数据事务ID(每个数据操作都会递增), 即zid与serverid, 先看谁的数据最新, 再看自己节点标识
  • sesion和watch
    若客户端连接了集群中的某一个节点, 会产生一个session, 用于标识该客户端的连接, session有超时时间的, 需要在时间内去续命, session也是数据, 也会同步到其他节点, 即使某节点挂了, 在session有效期间也可以被识别出用于连接恢复
    zk中数据保存在znode上, 多个znode构成一个类似文件系统的的树型结构, 每个znode通过其路径可以唯一标识, 其中znode有持久节点, 临时节点, 持久时序节点, 临时时序节点, 当某一个客户端创建了一个临时节点/lock时, 该临时节点会与session绑定, 若session还在该节点就不会被删除, 此时其他客户端再创建临时节点/lock就会失败, 但每个客户端都可以使用zk提供的watch机制, 在尝试创建后实现一个回调函数, 用于监控该节点的事件(增删改), 当有事件发生就会反馈到客户端的回调, 通过基于事件回调的watch机制可以避免客户端一直去轮询

利用 zookeeper 临时顺序节点 (临时节点在客户端与集群断开连接后被自动删除, 顺序节点按照前后创建的顺序维护一个递增值, 并以该值作为节点名称的一部分)

  1. 创建一个临时顺序节点 /业务ID/lock-, 节点的数据是 write, 表示是写锁
  2. 获取 zk 中 /业务ID 下所有的子节点
  3. 判断自己是否是最小的节点, 如果是则表示该客户端获得了锁, 即上锁成功, 如果不是, 说明前面还有其他的临时节点在使用中, 没被删除, 即有别的客户端还在获得锁中, 当前客户端则上锁失败, 然后通过watch机制来监听节点的变化, 如果有变化, 则回到第二步

Redis作为限流器或计数器

在指定的时间周期内, 限制累积的次数, 下一周期则会清零

  • 计数器算法: 使用Redis的 incr 原子自增性, 再结合key的过期时间, 但会有一个临界问题 (在上一个周期的后半段与下一个周期的前半段所在的时间段内, 可能会出现超于限制数的情况)
  • 滑动时间窗口算法: 解决计数器算法的临界问题, 将实际周期切分为多个小的时间窗口, 分别在每个小的时间窗口中计数, 然后根据时间将窗口向前滑动, 并删除过期的小时间窗口, 最终只需要统计滑动窗口范围内的小时间窗口的总数

Redis 集群方案

主从复制

高可用, 解决单点故障问题, 在不同的机器上部署着同一 Redis 程序。在这多台机器里,我们会选择一个节点作为主节点,它负责数据的写入。其他节点作为从节点,从节点负责读取, 且定时的和主节点同步数据。一旦主节点不能使用了,那么就可以在从节点中挑选一个作为主节点,重新上岗服务。

可以人工进行故障节点切换, 也可以利用哨兵监控来自动切换从为主, 即哨兵会不断的检测主从节点是否能正常工作, 当某个 master 不能正常工作时,Sentinel 会启动一个故障转移过程,将其中的一个副本提升为 master,并通知其他从节点对应新的 master 相关信息, 还会告知已连接过来的客户端程序关于主节点新的地址, 客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址

  • 数据冗余
    主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
  • 故障恢复
    当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。
  • 负载均衡
    在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
  • 读写分离
    可以用于实现读写分离,主库写、从库读,读写分离不仅可以提高服务器的负载能力,同时可根据需求的变化,改变从库的数量;

从节点开启主从复制, 配置文件中

replicaof <masterip> <masterport>
masterauth <master-password>
  • 第一阶段是建立链接、协商同步;
  • 第二阶段是主服务器同步数据给从服务器;
  • 第三阶段是主服务器发送新写操作命令给从服务器

主从复制共有三种模式:全量复制、基于长连接的命令传播、增量复制

  • 主从服务器第一次同步的时候,就是采用全量复制,此时主服务器会两个耗时的地方,分别是生成 RDB 文件和传输 RDB 文件
  • 第一次同步完成后,主从服务器都会维护着一个长连接,主服务器在接收到写操作命令后,就会通过这个连接将写命令传播给从服务器,来保证主从服务器的数据一致性。
  • 如果遇到网络断开,增量复制就可以上场了

主从数据同步, 节点间数据是全量的, AP 特性(CAP理论, P 分区容错性, A 可用性, C 一致性, 三者不可兼得), Redis 默认是弱一致性, 异步同步, 即 同步不精准, 一致性不够, 锁不能用主从, 可以使用 (单实例/分片集群/redlock) redisson

cluster 模式

分治, 分片, 解决容量, 压力, 瓶颈的问题, 采用了哈希槽的概念,总共会有 16384 个哈希槽。这些哈希槽会被分配到各个节点上,比如:
节点 1 分配了 0 至 5500 的哈希槽。
节点 2 分配了 5501 至 11000 的哈希槽。
节点 3 分配了 11001 至 16384 的哈希槽。
当有 key 过来时,Redis 会对其进行 CRC16(key) % 16384 的运算,看当前的 key 要分散到哪个哈希槽上,再根据当前的哈希槽定位到对应的节点上。这样就完成了一次 key-value 的存储了。
读取也是按这规则来,不同的是,如果运算结果所对应的节点不在当前节点上,则会转发给对应的节点去处理。
当有节点进行新增或删除时,会重新划分这些哈希槽,当然,影响的只会是周围节点,不会造成整个集群不可用。
在这些节点背后还有属于它们的从节点,一旦主节点不可用,那么这些从节点就会被启用,以保证系统的正常运行。

每一个节点存储的是一部分数据

缓存雪崩, 缓存穿透, 缓存击穿

数据库是架构的瓶颈, 只能有效的请求可以到达数据库, 过滤掉无效的请求, 即便放大前置环节的复杂度和成本

雪崩

缓存不存在, 数据库存在, 大量 key
或者从没有被缓存, 系统刚上线或刚恢复未对缓存预热,大量并发, 请求到数据库,数据库压力增大甚至崩溃,这就是缓存雪崩。

  • 同一时间大批缓存过期, 大量并发
    给缓存的失效时间再加上一个随机值, 避免同一时间有大量的key集体失效
  • 从未被缓存, 大量并发
    使用互斥锁, 缓存失效去抢锁, 拿到锁请求数据库, 更新缓存后释放锁, 未抢到锁睡眠一段时间后再试, 由于key数量较多, 每一个key的锁都是隔离的, 此时可以使用redis的集群模式(分片), 分治, 不同的key打到不同的机器上
穿透

缓存不存在, 数据库不存在, 少量 key

  • key null
    将此key对应的value设置为一个默认的值,比如设置为空(NULL),并设置一个缓存的失效时间,这时在缓存失效之前,所有通过此key的访问都被缓存挡住了。后面如果此key对应的数据在DB中存在时,缓存失效之后,通过此key再去访问数据,就能拿到新的value了
  • 布隆过滤器
    提供一个能迅速判断请求是否有效的拦截机制 , 内部维护一系列合法有效的 Key, 迅速判断出请求所携带的 Key 是否合法有效, 不合法则直接返回
    创建指定容量的二进制数组, 对数据进行Hash计算, 得到的值再%容量, 可以知道该值分配到位数组的位置, 对应位置的值置1, 判断key是否存在时, 也是先对key进行Hash, 然后取余, 得到索引, 再判断该索引位置上是否为1, 二进制数组中数据存在, 实际数据不一定存在, 二进制数组中数据不存在, 则实际数据一定不存在
  • 互斥锁
    由于Redis是串行的, 当多个客户端并发去请求, 已到达的会在串行里, 即这些串行里的还是都会去请求数据库, 可能造成数据库异常, 只有当缓存有值时, 后续连接的请求才会直接返回, 所以需要加锁 (必须由一个redis提供锁, 避免主从模式同步不及时导致数据不一致而产生锁错误)
  1. 请求redis, 根据key取值
  2. 取不到就去抢锁
  3. 抢到锁之后再请求数据库, 更新redis
  4. 没得到锁则休眠一段时间重试, 回到1执行

加锁保证一次DB请求, 避免n次无效或者重复的DB请求, sleep 睡眠时为阻塞状态, 不会抢占CPU和内核调度

击穿

缓存不存在, 数据库存在, 少量 key
热点key过期或者从未被缓存, 大量并发

  • 互斥锁
    为数据库挡住大量无效的请求或重复的请求, 保障DB的有效请求, 必须由一个redis提供锁, 避免主从模式同步不及时导致数据不一致而产生锁错误

Redis 和数据库数据一致性问题

先读缓存, 缓存没有, 再读取数据库
一份数据同时保存在数据库和 Redis 里面, 当数据发生变化时, 需要同时去更新 Redis 和 Mysql, 由于更新操作有先后顺序, 但又没有像 MySQL 中多表事务的操作, 满足 ACID 特性, 所以会出现数据不一致性的问题

先更新数据库再更新缓存

数据有时差, 数据短期不一致

  • 线程安全角度
    同时有请求A和请求B进行更新操作, 那么可能会出现 1. 线程A更新了数据库 2. 线程B更新了数据库 3. 线程B更新了缓存 4. 线程A更新了缓存, 请求A更新缓存应该比请求B更新缓存早才对, 但是因为网络等原因, B却比A更早更新了缓存, 这就导致了脏数据
  • 业务场景角度
    若写多读少的业务场景, 就会导致数据还没被读到, 缓存就频繁的更新, 浪费性能, 或者写入数据库的值并不是直接写入缓存, 而是要经过一系列复杂的计算后再写入, 写入数据库后再次计算后写入缓存, 无疑是浪费资源性能的, 删除缓存更适合
先删除缓存再更新数据库

若同时有一个请求A进行更新操作, 另外一个请求B进行查询操作, 可能会出现 1. 请求A进行写操作, 删除缓存 2. 请求B查询发现缓存不存在 3. 请求B去数据库查询得到旧值 4. 请求B将旧值写入缓存 5. 请求A将新值写入数据库, 出现数据不一致, 而且如果不采用给缓存时间设置过期时间策略, 该数据永远都是脏数据

  • 解决
    延迟双删 (先淘汰缓存, 再写数据库, 休眠1秒再次淘汰缓存, 但第二次删除的时间无法很好控制, 适合小项目
先更新数据库再删缓存

假设有两个请求, 一个请求A做查询操作, 一个请求B做更新操作 1. 缓存刚好失效 2. 请求A查询数据库得到一个旧值 3. 请求B将新值写入数据库 4. 请求B删除缓存 5. 请求A将查到的旧值写入缓存, 还是可能会出现脏数据

  • 解决
    给缓存设置有效时间或延时双删

一致性问题可以分为最终一致性和强一致性, Redis是缓存, 更倾向于数据有短期不一致, 如果对数据有强一致性要求, 就不能放缓存, 我们所做的一切只能保证最终一致性

  • 基于MQ中间件, 即先更新数据库, 再更新 Redis, 若更新失败, 则将失败的请求写入 MQ 事务消息, 异步再重试, 确保成功
  • 比较可靠的就是使用 Canal 组件监控 MySQL 中 binlog 的日志, 把更新后的数据同步到 Redis

redis 如何实现延迟队列

利用有序集合的 score 属性,将时间戳设置到该属性上,然后定时的对其排序,查看最近要执行的记录,如果时间到了,则取出来消费后删除,即可达到延迟队列的目的。

RPC和HTTP访问的区别在哪

首先底层都是基于socket, 都可以实现远程调用, 都可以使用服务调用服务

  • 速度来看, RPC要比HTTP更快, 虽然底层都是TCP, 但是HTTP协议的信息往往比较臃肿
  • 难度来看, RPC实现较为复杂, HTTP相对比较简单
  • 若对效率要求更高用RPC, 灵活性通用性要求高用HTTP
  • RPC是长连接, HTTP是短连接
  • RPC可以压缩消息, 实现更极致的流量优化

Websocket和HTTP

  • IP:网络层协议
  • TCP和UDP:传输层协议
  • HTTP:应用层协议;HTTP(超文本传输协议)是建立在TCP协议之上的一种应用。HTTP连接最显著的特点是客户端发送的每次请求都需要服务器回送响应,在请求结束后,会主动释放连接。从建立连接到关闭连接的过程称为“一次连接”。
  • SOCKET:套接字,TCP/IP网络的API。Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。socket是在应用层和传输层之间的一个抽象层,它把TCP/IP层复杂的操作抽象为几个简单的接口供应用层调用已实现进程在网络中通信。
  • Websocket:同HTTP一样也是应用层的协议,但是它是一种双向通信协议,是建立在TCP之上的,解决了服务器与客户端全双工通信的问题,包含两部分:一部分是“握手”,一部分是“数据传输”。握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了

每个WebSocket连接都始于一个HTTP请求,第一次握手连接时,通过HTTP协议传送WebSocket支持的版本号,原始地址,主机地址等一些列字段给服务器端, 将其升级到Web Socket协议,其底层仍是TCP/IP连接

  • 相同点:
    (1)都是建立在TCP之上,通过TCP协议来传输数据。
    (2)都是可靠性传输协议。
    (3)都是应用层协议。

  • 不同点:
    (1)WebSocket支持持久连接,HTTP不支持持久连接。
    (2)WebSocket是双向通信协议,HTTP是单向协议,只能由客户端发起,做不到服务器主动向客户端推送信息。

TCP/IP协议栈

互联网的核心是一系列协议, 总称互联网协议, 正是这一些协议规定了电脑如何连接和组网
常见的互联网分层模型为TCP/IP4层模型: 应用层, 传输层, 网络层, 链路层
TCP是面向连接的, 可靠的传输层协议, 指定端口号, 标识主机上的一个进程

  • 三次握手
    首先Client端发送连接请求SYN报文,Server端接受连接后回复ACK报文并同时发送SYN报文,并为这次连接分配资源。Client端接收到SYN报文后也向Server段发送ACK报文,并分配资源,Server端接收到ACK报文后, TCP连接就建立了, 最主要的目的就是双方确认自己与对方的发送与接收都是正常的
  • 四次挥手
    中断连接端可以是Client端,也可以是Server端
    假设Client端发起中断连接请求,发送FIN报文。Server端接到FIN报文后,并不会着急关闭Socket, 而是先回复ACK报文, 若有未发送完成的数据, 则继续发送, 而Client端接收到ACK后就进入FIN_WAIT状态,继续等待Server端的FIN报文。当Server端确定数据已发送完成,则向Client端发送FIN报文, Client端收到FIN报文后,然后回复ACK后进入TIME_WAIT状态,如果Server端没有收到ACK则可以重传。Server端收到ACK后断开连接。Client端等待后仍未收到回复,则证明Server端已正常关闭,Client端也关闭连接。

为什么连接的时候是三次握手,关闭的时候却是四次握手

因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手。

TCP与UDP

TCP UDP
面向连接 面向无连接
系统资源消耗较多 系统资源消耗少
程序结构较复杂 程序结构简单
流式数据包传输 报文传递
通过回执保证数据准确性 不保证数据准确性
保证数据顺序 不保证数据顺序
通讯速度较慢 通讯速度较快
  • 优缺点
    TCP 稳定, 安全, 有序, 但效率低, 开销大, 开发复杂度高
    UDP 效率高, 开销小, 开发复杂度小, 但稳定性差, 安全性低, 无序

  • 场景
    TCP 适合对数据传输安全性, 稳定性要求较高的场景, (网络文件储传输, 上传/下载等)
    UDP 适合对数据实时传输要求较高的场景, (视频直播, 在线电话会议等)

哪些问题会引起接口性能问题

  • 数据库慢查询
  1. 深度分页问题, select name,code from student limit 100,20 MySQL会把前120条数据都查出来, 抛弃前100条, 返回20条, 随着分页深度的增大可能会变成 1000000,20 如此大的数据量, 速度一定快不起来, 最好的方式是增加一个条件 select name,code from student where id > 1000000 limit 20, 这样会走主键索引, 直接链接到1000000处然后查出20条, 这个方式需要把上次查询出来的最大id以参数的方式传过来
  2. 未加索引, 加索引之前确保该字段区分度较高, 离散度好, 另外加索引的 alter 操作, 可能引起表锁, 执行sql时要在低峰期
  3. 索引失效, (添加索引的字段区分性很差, 索引语句在OR中, LIKE的时候出现类似’%xxx’的语句. 索引发生了隐式变换, 不满足最左前缀规则, where条件里索字段有计算, where条件里索引字段使用了函数)
  4. join 过多, join关联的表不宜过多, 一般来说2-3张比较合适, 建议从代码层面进行拆分, 在业务层先查询一张表的数据, 然后以关联字段作为条件查询关联表, 然后在业务层进行数据的拼装, 建立正确的索引会比join快, 毕竟内存里拼接数据比网络传输和磁盘IO快很多
  5. 子查询过多, 一般不建议使用子查询, 可以把子查询改成join来优化
  6. in 中的值太多, 可以把元素分个组, 每组查一次
  7. 单纯的数据量过大, 分表或分库+分表
  • 业务逻辑复杂
  1. 循环调用, 这种情况一般都循环调用同一段代码, 每次循环的逻辑一致, 前后不关联, 可以使用多线程或多协程的方式并发或并行进行
  2. 顺序调用, 一次性的顺序调用, 而且调用之间没有结果上的依赖, 可以使用多线程或多协程的方式并发或并行进行
  • 线程池设计不合理
  • 锁设计不合理
  1. 锁类型使用不合理(读写锁: 读是可以共享的, 但是读的时候不能对共享变量写, 而在写的时候, 读写都不能进行), 在可以加读写锁的时候, 如果加成了互斥锁, 那么在读远远多于写的场景下, 效率会极大降低
  2. 锁过粗, 把锁包裹的范围过大, 则加锁的时间会过长
  • 机器问题 (fullGC, 机器重启, 线程打满)

MySQL事务

ACID 特性
  • 原子性(Atomicity):事务是一个不可分割的单位,一个事务里的所有操作属于一个整体, 要么全部生效,要么全部不生效。
  • 一致性(Consistency):事务中操作的数据改变是一致的, 事务开始前和结束后, 数据库的完整性没有被破坏
  • 隔离性(Isolation):事务并发执行时,各个事务之间相互影响的程度
  • 持久化(Durability):事务操作的结果是不会丢失的, 永久保存
隔离级别
  • 未提交读: 即所谓的脏读,事务读取的数据可能是另一个事务已修改但还没提交的,这部分数据有可能产生回滚。导致后续的操作依赖了无效的数据。
  • 已提交读: 如果想防止脏读,就需要等待其他事务提交后再进行读取操作。
  • 可重复读: 已提交读的隔离级别考虑到了数据回滚的无效性,却无法阻止事务的多次提交。比如事务 A 不断的对表进行修改提交,那么事务 B 就会在不同的时间点读取到不同的数据。为了让事务 B 在执行期间读取的数据都是一致的,就有了可重复读的隔离级别,即事务 B 在执行期间,其他事务不得进行修改操作。
  • 可串行化: 上面的可重复读隔离级别保证了事务执行期间读取的一致性。然而这里并不包括插入、删除操作。即会出现读多读少数据的情况,这种现象叫做幻读。为了解决幻读,只得进行串行化执行事务,才能互不影响。而此时的事务并发性是最低的

索引

Mysql 的索引分类
  • 从数据结构划分: B+ 树、hash 索引、全文索引
  • 从物理结构划分: 聚集索引、非聚集索引
  • 从逻辑用户划分: 主键、唯一索引、复合索引、普通单列索引
聚集索引、非聚集索引、主建的区别
  • 聚集索引
    就是基于主键创建的索引, 在InnoDB引擎里面, 一张表的数据对应的物理文件本身就是按照B+树来组织的一种索引结构, 而聚集索引就是按照每张表的主键来构建一颗B+树, 叶子节点存储了这个表的每一行数据记录, 使用的是物理排序, 聚集索引并不仅仅是一种索引类型, 还代表着一种数据存储方式, InnoDB里面一张表里只能存在一个聚集索引, 如果存在多个聚集索引, 那么意味着这个表里的数据存在多个副本, 造成磁盘空间的浪费, 以及数据维护的困难
  • 主建
    唯一标识某行记录,不允许有 null 的数据,要求数据必须唯一。在设置某个字段为主建时,数据库一般会自动在这个主建上建立一个唯一索引,并且如果之前表没有创建过聚集索引,还会在这个主建上建立一个聚集索引。每个表里面必须要有一个主键, 如果没有主键, InnoDB会默认选择或添加一个隐藏列作为主键索引来存储这个表的数据行, 一般情况建议使用自增id作为主键, 因为id本身具有连续性使得对应的数据也会按照顺序存储在磁盘上, 写入性能和检索性能都很高, 若使用uuid这种随机id, 那么在频繁插入数据时, 就会导致随机磁盘IO, 从而导致性能较低
  • 非聚集索引
    除了主键索引以外的其他索引, 称为非聚集索引, 也叫二级索引, 索引的叶子节点存的是数据域的记录指针,若基于非聚集索引来查询一条完整的记录, 最终还是需要访问主键索引来检索, 即需要跳转查找。排序规则是逻辑排序,因此可以有多个非聚集索引存在。

有哪些情况会让索引失效

在 where 字段 上使用了函数或其他隐式转换
Like 模糊查询,开头使用了 “%”,例如 like ‘%hello%’
where 条件里使用了 or
建立了复合索引,但 where 条件里使用的是第二个字段的搜索

最左匹配原则是指

mysql 建立联合索引后,是按最左匹配原则来筛选记录的,即检索数据是从联合索引的第一个字段来筛选的。如果 where 里的条件只有第二个字段,那么将无法应用到索引。

索引的底层数据结构 B+ 树是怎么样的

B+ 树是二叉搜索树的一个扩充,是多路搜索树。它只在叶子节点存储具体的数据或者数据的指向指针,而非叶子节点存放索引数据。这样可以降低磁盘 IO,还能充分利用磁盘的预读功能,批量的加载索引数据。
b 树就是 b-树, 国外叫 b-tree, 也就是 b 树。
b-tree 是在非叶子节点存放了数据,在查询索引时,只要找到索引值也就可以找到数据了,这样可以提前终止搜索。但每个节点就得存储索引值+数据值,占用的页空间会比较大,需要的磁盘 IO 次数也会变多,即使是不需要关心的数据也会被预加载出来,浪费性能。
b+树是将索引值存在非叶子节点,数据值存在叶子节点,这样可以压缩树的高度,减少磁盘 IO次数, 并且做一次磁盘IO时, 页空间可获得更多有效的索引值, 查询更快。

为什么不能在重复率高,例如性别字段上建立索引

对于性别这种索引, 由于重复率高,离散型太差, 对于 B+树(多路搜索树)来讲,得遍历多条路径,搜索代价大。还不如全表扫描,这样不需要维护索引,降低开销。

Mysql 的 hash 索引是怎么样,有什么优缺点

hash 索引将列通过 hash 运算得到 hash code,然后将 hash code 跟数据行的指针地址关联在一起,下次查找时只需查找对应 hash code 的数据行地址即可。
hash 索引非常的紧凑,查找速度很快,适用于内存存储引擎的应用。不过它只能精确查询,不支持范围查找,也不能直接进行排序。

日志类别

  • binlog: 二进制日志,记录了数据库对数据的修改记录,例如表的创建,数据更新等。但并不包括 select 这些查询语句。binlog 日志是属于逻辑语句的记录,可用于主从数据库的同步。
  • relay log: 中继日志,用于主从备份恢复使用的。有主服务器的 binlog 逻辑操作语句,以及当前的恢复位置。
  • 慢查询日志: 记录在 mysql 里执行时间超过预期值的耗时语句
  • redo log: redo log 是对加载到内存数据页的修改结果的记录,和 binlog 不同的是,binlog 记录的是逻辑操作语句,偏向于过程记录。而 redo log 是一个数据页的修改日志,偏向于结果的记录。redo log 在写 binlog 日志前会先记录 redo log,记录完后标记为 prepare 状态。当 binlog 也写入完成后,才将 redo log 标记为 commit 状态。只有当 redo log 是 commit 状态时,事务才能真正的 commit。这样能防止主从节点根据 binlog 同步有可能事务不一致的情况。
  • undo log: 回滚日志主要用于回滚数据,和 redo log 不一样的是,undo log 是逻辑日志,是一种相反操作的记录,比如在回滚时,如果是 insert 操作时,则会逆向为 delete,delete 操作时,逆向为 insert 操作,更新则恢复到当时的版本数据。

Mysql 里的锁

  • 乐观锁
    在读取数据时会假设各个事务互不影响,它们会处理好属于自己的那部分数据。如果在更新数据时,发现有其他事务修改了属于自己的数据,则会回滚之前的一切操作。
  • 悲观锁
    采取了先获取锁再访问的保守策略,如果已经有其他事务获取了锁,则必须等待锁释放才能继续。
  • 共享锁
    又称读锁,当前事务在读取时,允许其他事务并发读取,但不允许其他事务上排它锁,必须等自己释放了才能继续。
  • 排它锁
    又称写锁,在写锁占有时,如果其他事务想上读写锁,则得排队等待。
  • 表锁
    在操作数据时,直接将整张表锁住,操作粒度很大,很容易让其他事务在等待,但不会产生死锁。
  • 行锁
    针对的是行记录的并发控制,锁粒度很细,能支持高并发,但是不排除会有死锁情况产生。在 mysql 里行锁依赖索引实现,如果没有索引存在,则会直接进行表锁!
  1. 记录锁:只锁住某一条记录。当对唯一索引(包括主键)进行精确查询时,会使用记录锁。
  2. 间隙锁:当使用范围查询时,会对符合条件的区间数据上锁。在涉及到普通索引(即不是唯一索引)的查询时,都会使用间隙锁。
  3. Next-key 锁:临建锁,可以理解为 记录锁 + 间隙锁。当对唯一索引进行范围查找或对唯一索引进行查找但结果不存在时(可以理解为锁住不存在的记录),会使用临建锁。

上面的间隙锁、临建锁有效的防止了事务幻读情况产生,避免了在查找期间有数据新增或删除。

  • 意向锁
    表锁的一种,它仅仅表示一种操作意向。当我们使用粒度比较小的行锁时,在检测是否有锁时,需要一行一行的检查,效率较低。有了意向锁之后,则不需一行一行的排查,只需检测对应的意向锁即可。

事务里锁的应用是怎么样的

  • 可重复读
    可重复读使用的是 MVCC 快照,所以在读取数据时大多数时候不需要使用锁。

但使用了 UPDATE, DELETE,或 SELECT with FOR UPDATE(排它锁) 或 FOR SHARE(共享锁),则会根据下面的情况来使用锁:

在唯一索引上精确查找某条记录时,使用记录锁
对于其他的搜索,InnoDB 将会锁定扫描到的索引范围,使用间隙锁或临建锁来防止幻读的产生

  • 读提交
    也是使用 MVCC 机制来读取数据,不过在使用 UPDATE, DELETE,或 SELECT with FOR UPDATE(排它锁) 或 FOR SHARE(共享锁)时和上面的机制不一样,当存储引擎将筛选到的记录交给 mysql server 层后,会对不相干的数据进行解锁,所以不会涉及间隙锁或临建锁。它们只会在做外键约束检查和重复键检查时使用到。由于间隙锁的禁用,可能会出现幻读现象。

  • 未提交读
    在 mysql 的 innodb 存储引擎里做 SELECT操作不会做任何锁动作,如果是 myisam 存储引擎,则会上共享锁。
    如果使用UPDATE, DELETE,或 SELECT with FOR UPDATE(排它锁) 或 FOR SHARE(共享锁)则和读提交一样的原则。

  • 序列化
    可序列化读在使用 select 时,一般会自动的转化为 SELECT … FOR SHARE(共享锁),以保证读写序列化。

lock in share mode 和 for update 里间隙锁什么时候会应用

lock in share mode, for update 如果 where 条件是非索引类的,则不会加间隙锁;
lock in share mode, for update 如果 where 条件是主键类的,并且找不到记录时会加间隙锁;如果找到记录了则会将间隙锁给释放了。比如 where 主键 = 3 能找到记录时则不会加间隙锁,找不到时会在该数据的前后叶子节点间加间隙锁;此时假如记录里只有 1,8,9,则会在 1, 8 之间加间隙锁
lock in share mode, for update 如果 where 条件是非聚集索引类的,会加间隙锁,即使找不到记录。

MVCC 是指什么

MVCC 即多版本并发控制,它利用了 undo log 会在数据修改时保留上一个修改记录指针的特点,使得每个事务对数据的修改能有自己的历史版本追溯,就像镜像备份一样。当进行读操作时,如果有其他写操作的事务并发进行,那么此时可以根据事务的隔离级别选择读取最新版本亦或自己之前版本的数据。MVCC 不需要加锁的,它能提高事务的并发处理能力。

mysql 的复制技术

整体上来说,复制有3个步骤:

  1. master将改变记录到二进制日志(bin log)中
  2. slave将master的bin log events拷贝到它的中继日志(relay log);
  3. slave重做中继日志中的事件,将改变反映它自己的数据。
  • 全同步复制
    当向主库写数据时, 只有等所有的 slave 节点将同步的bin log日志写入 relay log,并且响应 ack 确认后,此次的事务才会提交, 然后返回客户端。数据完整性高,但性能低
  • 半同步复制
    当向主库写数据时, 只要有一个 salve 节点响应 ack 后就可以认为同步成功,但细分为了两种,一种是 AFTER_COMMIT:先在主库提交事务, 然后同步从库, 等待从库的 ack 确认才告诉客户端是否 Ok。另一种是 AFTER_SYNC:主库先不提交事务, 只有从库有 replay log ,回复了 ack 后才进行提交事务。后面一种数据一致性较高
  • 异步复制
    当向主库写数据时, 立刻返回客户端, 即一旦有需要复制的就通知 slave, 但不会等待确认成功才进行后续操作。

数据库的读写分离

读写分离的基本原理就是让主数据库处理事务性增, 改, 删操作(INSERT, UPDATE, DELETE), 而从数据库处理SELECT查询操作, 数据库复制被用来把事务性操作导致的变更同步到其他从数据库, 主库负责写数据和读数据, 从库仅负责读数据

  1. 实现负载均衡, 将读操作和写操作分离到不同的数据库上, 避免主服务器出现性能瓶颈
  2. 主服务器进行写操作时, 不影响查询应用服务器的查询性能, 降低阻塞, 提高并发
  3. 数据拥有多个备份, 提高数据安全性, 同时当主服务器故障时, 可立即切换到其他服务器, 提高系统可用性

存储引擎

InnoDB

它是 mysql 的默认存储引擎,能够实现 ACID 特性的事务,并且能提交、回滚、恢复数据,能很好的保障用户数据。同时支持了行级锁、聚集索引以及外键约束,是一个完善的存储引擎。

  • 支持事务,行锁,外键,聚集索引和非聚集索引
  • 不支持全文索引
MyISAM

是 mysql 最开始的存储引擎,占用空间小,能快速存储,但不支持事务,提供了基于表级别的锁粒度,适用于配置或只读功能的应用程序。

  • 支持全文索引,支持非聚集索引
  • 不支持事务,行锁,外键

Mysql 的三层架构

连接层: 主要负责连接池、通信协议、认证授权等;
SQL 层: 这一层是 mysql 的大脑,通过一系列组件得到数据操作的最优解。
存储层: 负责数据的存储、检索。

执行计划是什么?怎么看

执行计划是 mysql 根据我们的查询语句进行一系列的分析后得到的优化方案。我们可以通过执行计划来获取执行过程。
explain select 语句

SQL 注入的现象是

在拼接 SQL 语句时,直接使用客户端传递过来的值拼接,如果客户端传来包含 or 1=1 类似的语句,那么就会筛选到非预期的结果,进而达到欺骗服务器的效果。

解决方案是使用现在数据库提供的预编译(prepare)和查询参数绑定功能,例如使用占位符 ?,然后将带有占位符的 SQL 语句交给数据库编译,这样数据库就能知道要执行的是哪些语句,条件值又是哪些,而不会混杂在一起。

UNION 和 UNION ALL 的区别

UNION ALL:将所有的数据联合起来,即使有重复数据
UNION:会合并重复数据

为什么尽量使用自增 ID,而不是 UUID

首先, UUID相较于自增ID, 会占用更多磁盘空间去存储
再者, UUID之间比较要慢于自增ID, 影响性能
最后, 自增 ID 是有序的,而 UUID 是无序的,如果该字段作为索引,那么就会很容易打破 B+ 树的平衡,进而不断对磁盘数据页进行调整,导致性能下降

但在出现数据拆分、合并存储的时候,UUID能达到全局的唯一性
所以可以把它作为逻辑主键,物理主键依然使用自增ID。为了全局的唯一性,应该用uuid做索引关联其他表

分库分表有哪些?有什么优缺点

分库:从业务角度进行切分
分表:将数据根据一定的规则落在多张表上。比如按时间范围来切分,或者通过对 ID 进行 Hash 来路由到对应的表上。
分库分表后使得数据不再集中到一张表上,但也带来了维护以及其他处理问题。比如原来的事务变为分布式事务;原来的 join 操作将要变为在应用程序做过滤;还有数据的后续迁移、扩容规划等。

内连接、外连接区别

内连接:只有符合条件的记录才会出现在结果集里
外连接:其结果集中不仅包含符合连接条件的行,还会包括左表、右表或两个表中的所有数据行,这三种情况依次称之为左外连接,右外连接,和全外连接

常见的数据库优化

  • 深度分页
    limit 1000000, 10, 一般会再用另外的条件限制 where id > *** limit 1000000, 10
  • 未建索引
    创建合适的索引, 让查询尽可能走索引树, 避免全表扫描
  • 索引失效
  1. 对经常出现在 where 条件里,并且数据重复率不高的字段建立索引
  2. 能使用 in 就不使用 or,前者能命中索引,后者会让索引失效
  3. 避免在 where 字段上计算,例如 where a / 3 = 1,这样会让索引失效
  4. 使用 like 匹配时, %写到最右
  5. 避免在 where 字段上使用 NULL 值的判断
  • 子查询过多
    用JOIN代替子查询, 但JOIN也不宜关联太多表
  • 打开慢查询日志配置,有针对性的分析响应缓慢的语句

分布式事务

两阶段提交 (2PC)
  • 概念
    参与者将操作成败通知协调者, 再由协调者根据所有参与者的反馈情况, 决定各参与者是否要提交操作或中止操作
  1. 准备阶段
    事务协调者给每个参与者发送Prepare消息, 每个参与者要么直接返回失败, 要么在本地执行事务, 写本地的undo和redo日志, 但不提交
  2. 执行阶段
    如果协调者收到了参与者的失败消息或者超时, 直接给每个参与者发送回滚(Rollback)消息, 否则发送提交(Commit)消息, 然后再释放资源
  • 缺点
  1. 同步阻塞
    执行过程中, 所有参与节点都是事务阻塞型, 只执行sql, 但不提交, 并且占用数据库连接资源
  2. 单点故障
    由于协调者的重要性, 一旦协调者出现故障, 参与者会一直阻塞下去
  3. 数据不一致
    当协调者向参与者发送commit请求后, 由于网络原因或者协调者故障, 可能导致只有一部分参与者收到了commit请求然后执行commit操作, 只能通过手动或脚本补偿的方式来处理数据的不一致
三阶段提交 (3PC)
  1. can commit
    协调者向参与者发送 can commit请求, 询问是否可以执行事务提交操作, 然后等待参与者的响应, 参与者如果可以提交就返回YES响应, 进入预备状态, 否则返回NO响应
  2. pre cpmmit
    协调者根据参与者的反馈情况来决定是否可以进行事务的pre commit操作, 假如协调者从所有的参与者获得的反馈都是YES, 那么就会执行事务的预执行, 假如有任何一个参与者向协调者返回了NO响应, 或者等待超时之后协调者没有收到参与者的响应, 那么协调者就执行事务的中断
  3. do commit
    该阶段执行真正的事务提交, 执行提交或中断事务

2PC和3PC的区别

  • 3PC比2PC多了一个can commit阶段, 减少了不必要的资源浪费, 2PC在第一阶段会占用资源, 而3PC在这个阶段不占用资源, 只是校验一下sql, 如果不能执行就直接返回, 减少资源占用
  • 引入超时机制
    2PC只有协调者有超时机制, 超时后, 发送回滚指令
    3PC中协调者和参与者都有超时机制
    协调者超时
    can commit, pre commit中, 如果超时收不到参与者的反馈, 则协调者向参与者
    发送中断指令
    参与者超时: pre commit 阶段, 参与者超时收不到指令会进行中断, do commit阶段, 参与者超时收不到指令会进行提交
TCC

将一个事务拆分为三个步骤

  1. Try
    主要进行业务校验和检查或预留资源, 也可能直接进行业务操作 (数据直接落库)
  2. Confirm
    业务确认阶段, 对Try校验过的业务或预留的资源进行确认, 空或者做一些事
  3. Cancel
    业务回滚阶段, 与Confirm互斥, 用于释放Try预留的资源或业务, 前两个阶段的逆sql操作
消息队列+本地事件表
  • A服务A
  1. 接收到请求, 执行自己具体的业务
  2. 插入自己的事件表 (业务类型, 事件状态)
  3. 返回请求
  4. 后台启动的定时任务, 去查询事件表是否有新事件
  5. 发送消息到MQ中
  6. 修改事件表中的状态
  • 服务B
  1. 消费者监听MQ的消息
  2. 插入自己的事件表
  3. 返回MQ响应, 表示消息已消费
  4. 后台启动的定时任务, 查询事件表
  5. 处理自己的业务
  6. 修改事件表的状态
可靠消息服务

当事务的发起方(事务参与者, 消息发送者)执行完本地事务后, 同时发出一条消息, 事务参与方(事务参与者, 消息的消费者)一定能够接受消息并可以成功处理自己的事务

  1. 服务A, 收到请求, 发送数据到可靠消息服务
  2. 可靠消息服务, 接收到数据, 保存到本地事务表
  3. 可靠消息服务, 返回给服务A成功或失败
  4. 服务A, 接收到成功返回后, 执行本地业务, 保存数据库
  5. 服务A, 发送确认或取消给可靠消息服务
  6. 可靠消息服务, 修改事件表的消息状态
  7. 可靠消息服务, 将消息发送到MQ
  8. 可靠消息服务, 返回给服务A成功或失败
  9. 服务B, 消费MQ中的消息
  10. 服务B, 执行本地事务
  11. 服务B, 返回MQ
  12. 服务B, 返回可靠消息服务
  13. 可靠消息服务, 修改事件状态

分布式ID

描述 优点 缺点
UUID 通用唯一标识码的缩写, 让分布式系统中的所有元素都有唯一的标识信息, 不需要中央控制器来指定 1. 降低全局节点的压力, 使得主键生成速度更快 2. 生成的主键全局唯一 3. 跨服务器合并数据方便 1. 占用16个字符, 空间占用较多 2. 不是递增有序的数字, 数据写入IO随机性很大且索引效率降低
数据库主键自增 MySQL数据设置主键且主键自动递增 1. INT和BIGINT类型占用空间较小 2. 主键自动增长, IO写入连续性好 3. 数字类型查询速度优于字符串 1. 并发性能不高, 磁盘存储, 受限于数据库性能 2. 分库分表, 改造复杂 3. 自增会泄露数据量
Redis自增 Redis计数器, 原子性自增 使用内存, 并发性能好 1. 数据丢失 2. 自增会泄露数据量
雪花算法(snowflake) 分布式ID经典解决方案 1. 不依赖外部组件 2. 性能好 时针回拨

什么是Docker

Docker是一个容器化平台,它以容器的形式将应用程序及所有的依赖项打包在一起,以确保应用程序在任何环境中无缝运行。

什么是Docker镜像

Docker镜像是Docker容器的源代码,Docker镜像用于创建容器,使用Build命令创建镜像

什么是Docker容器

Docker容器包括应用程序及所有的依赖项,作为操作系统的独立进程运行

Docker容器有几种状态

运行、已停止、重新启动、已退出

DockerFile中最常见的指定是什么

DockerFile中的命令COPY和ADD命令有什么区别

COPY和ADD的区别是COPY的SRC只能是本地文件,其他用法一致

Docker的常用命令

容器与主机之间的数据拷贝命令

Docker cp命令用于容器与主机之间的数据拷贝

  • 主机到容器:docker cp /www 96f7f14e99ab:/www/
  • 容器到主机:docker cp 96f7f14e99ab:/www /tmp

启动nginx容器,并挂载本地文件目录到容器html的命令

docker run -d --name nginx -p 80:80 -v /home/nginx:/usr/share/nginx/html nginx

什么是docker Swarm

Docker Swarm是docker的本地集群。它将docker主机池转变为单个虚拟docker主机。Docker Swarm提供标准的docker API,任何已经与docker守护进程通信的工具都可以使用Swarm透明地扩展到多个主机

如何批量清理临时镜像文件

可以使用sudo docker rmi $(sudo docker images -q -f danging=true)命令

本地的镜像文件都存放在哪里

于docker相关的本地资源存在/var/lib/docker/目录下,其中container目录存放容器信息,graph目录存放镜像信息,aufs目录下存放具体的镜像底层文件

容器退出后,通过docker ps命令查看,数据会丢失么

容器退出后会处于终止(exited)状态,此时可以通过docker ps -a查看,其中数据不会丢失,还可以通过docker start来启动,只有删除容器才会清除数据

如何停止所有正在运行的容器

docker kill $(sudo docker ps -q)

如何批量清理后台停止容器

docker rm$(sudo docker ps -a -q)

如何临时退出一个正在交互的容器的终端,而不终止它

按Ctrl+p,后按Ctrl+q,如果按Ctrl+c会使容器内的应用进程终止,进而会使容器终止

很多应用容器都是默认后台运行的,怎么查看他们的输出和日志信息

使用docker logs,后面跟容器的名称或者ID信息

Docker的配置文件放在那里。如何修改配置

Ubuntu系统下Docker的配置文件是/etc/default/docker,CentOS系统配置文件存放在/etc/sysconfig/docker

MQ产品选型

Kafka
  • 吞吐量非常大, 性能非常好, 集群高可用, 生产场景有大规模使用场景, 吞吐量单机百万级
  • 会丢数据, 功能比较单一, 单机容量有限(单机超过64个分区, 响应明显变长)
  • 适合数据量比较大且频繁, 但允许数据有小部分丢失的场景, 如: 日志分析, 大数据采集
  • , 效率在毫秒级别
RabbitMQ
  • 消息可靠性高, 功能全面, 性能好, 高并发, 效率在微秒级别
  • 吞吐量比较低, 吞吐量单机都在万级, 消息积累会严重影响性能, erlang语言不好定制
  • 适合小规模场景
RocketMQ
  • 高吞吐, 高性能, 高可用, 功能非常全面, 消息可以做到0丢失
  • 客户端只支持java
  • 吞吐量单机十万级, 效率在毫秒级别
  • 适合互联网金融领域 (对消息的吞吐量和可靠性都要求较高)

选择partition的原则

生产者发送消息时, 先连接kafka, 然后从zookeeper中获取broker和partition信息, 以及leader信息

  1. partition在写入的时候可以指定需要写入的partition, 如果有指定可以写入对应的partition
  2. 若没有指定partition, 但是设置了数据的key, 则会根据key的值hash出一个partition
  3. 若既没有指定partition, 又没有设置key, 则会采用轮询方式, 即每次取一小段时间的数据写入某个partition, 下一小段的时间写入下一个partition

生产者发送消息到kafka的流程

  1. 生产者从kafka集群获取分区leader信息
  2. 生产者将消息发送给leader
  3. leader将消息写入本地磁盘
  4. follower都会监听leader的数据变更, 然后主动从leader拉取新的消息
  5. follower将消息写入本地磁盘后向leader发送ACK
  6. leader收到所有的follower的ACK之后向生产者发送ACK

ACK应答机制

producer在向kafka写入消息时, 采用请求回应的模式, 可以设置参数来确认ack行为

  • 0: 代表producer往集群发送数据不需要等待集群的返回, 不确保消息发送成功, 安全性最低但效率最高
  • 1(默认): 代表producer往集群发送数据只要leader应答就可以发送下一条, 只确保leader发送成功(持久化到本地文件), 性能与安全性最均衡
  • all/-1: 代表producer往集群发送数据需要ISR集合中follower都完成从leader的同步才会发送下一条, 确保leader发送成功和副本都完成备份, 安全性最高但效率最低, min.insync.replicas=2(默认为1, 推荐设置>= 2, 此时需leader和一个follower同步完后才返回ack给生产者)

消息发送的缓冲区

  1. kafka默认会创建一个消息缓冲区, 用来要存放发送的消息, 缓冲区是32MB
  2. kafka本地线程会去缓冲区中一次拉取16KB的数据, 发送到broker
  3. 若线程拉取的数据未满16KB, 间隔10ms也会将消息发送到broker

消息队列有哪些作用

MQ: Message Queue, 消息队列, 是一种FIFO先进先出的数据结构, 消息由生产者发送到MQ进行排队, 然后由消费者对消息进行处理, 真正的目的是解决通信问题

优点
  • 解耦
    使用消息队列作为两个系统间直接的通信方式, 降低系统之间的依赖, 减少服务之间的影响, 提高系统稳定性和可扩展性, 解耦之后可以实现数据分发, 生产者发送一个消息后, 可以由多个消费者来处理
  • 异步
    系统给消息队列发送完消息后, 就可以立即返回, 然后继续做其他事情, 不需要同步等待, 提高系统的响应速度和吞吐量
  • 削峰
    使用消息队列后, 消息在队列中排队, 消费者可以根据自身能力控制消费速度, 以稳定的系统资源应对突发的流量冲击
缺点
  • 增加了系统的复杂度
    引用MQ后, 数据链路变得复杂, 幂等性, 重复消费, 消息丢失, 消息顺序等问题的带入
  • 系统可用性降低
    MQ的故障会影响系统可用
  • 数据一致性
    A系统发送消息, 需要B, C两个系统一同处理, 若B系统处理成功, C系统处理失败, 会导致数据不一致, 消费者端可能失败

Kafka的消费组与分区

  • 不同的consumer group都是顺序的读取message,offset的值互不影响。这样没有锁竞争,充分发挥了横向的扩展性,吞吐量极高

  • 当启动一个consumer group去消费一个topic的时候,无论topic里面有多少个partition,无论我们consumer group里面配置了多少个consumer thread,这个consumer group下面的所有consumer thread一定会消费全部的partition;即便这个consumer group下只有一个consumer thread,那么这个consumer thread也会去消费所有的partition。因此,最优的设计就是,consumer group下的consumer thread的数量等于partition数量,这样效率是最高的。

  • 每个broker中有多个partition, 一个partition只能被一个消费组里的某一个消费者消费, 从而保证消费顺序, Kafka只在partition的范围内保证消息消费的局部顺序性, 不能在同一个topic中的多个partition中保证总的消费顺序性, 同个消费组中一个消费者可以消费多个partition, 消费组中消费者的数量不能比一个topic中的partition数量多, 否则多出来的消费者消费不到消息

Kafka中的 Controller

Kafka集群中的broker在zk中创建临时序号节点, 序号最小的节点(最先创建的节点)将作为集群的controller, 负责管理整个集群中的所有分区和副本的状态

  • 当某个分区的leader副本出现故障时, 由控制器负责为该分区选举新的leader副本
  • 当检测到某个分区的ISR集合发生变化时, 由控制器负责通知所有broker更新其元数据信息
  • 当使用kafka-topics.sh脚本为某个topic增加分区数量时, 由控制器负责让新分区被其他节点感知到

Kafka中的 Rebalance机制

rebalance是有代价的, 期间kafka会先暂停服务

  • 前提是消费组中的消费者没有指明分区消费, 当消费组里消费者和分区的关系发生变化时, 就会触发rebalance机制, 会重新调整消费者消费哪个分区
  1. consumer group 中的成员个数发生变化
  2. consumer 消费超时
  3. group 订阅的 topic 个数发生变化
  4. group 订阅的 topic 的分区数发生变化

在触发rebalance机制前, 消费者消费分区的策略:

  • range (默认): 范围分区, 通过公式(n=分区数/消费者个数, m=分区数%消费者个数, 前m个消费者消费n+1个, 剩余消费者消费n个)来计算某个消费者消费哪个分区
  • RoundRobin轮询: 大家轮着消费
  • sticky 粘性: 触发rebalance后, 在消费者消费的原分区不变的基础上进行调整

Kafka中的 HW和 LEO

HW(HighWatermark)俗称高水位, 取一个partition对应的ISR集合中最小的LEO(某个副本最后消息的消息位置log-end-offset)作为HW, consumer最多只能消费到HW所在的位置, 每个replica都有HW, leader和follower各自负责更新自己的HW的状态, 对于leader新写入的消息, consumer不能立即消费, leader会等待该消息被所有ISR中的replicas同步后更新HW, 此时消息才可以被consumer消费, 保证了如果leader所在的broker失效, 该消息仍然可以从新选举的leader中获取

MQ 如何保证消息顺序

全局有序和局部有序: MQ只需要保证局部有序, 不需要保证全局有序
生产者将一组有序的消息放到同一个队列中, 而消费者一次消费整个队列当中的消息

  • RabbitMQ
    要保证目标exchange只对应一个队列, 并且一个队列只对应一个消费者
  • Kafka
    生产者通过定制partition分配规则, 将消息分配到同一个partition中, topic下只对应一个消费者

如何保证消息不被重复消费

幂等: 相同的参数和请求多次处理, 不会因为次数增加而导致结果不同
MQ虽然提供了消费者端的ack机制或者offset机制来保证消息被消费后删除, 但不能保证确定性, 还需要从应用程序角度来保证幂等性

  • 若是写redis操作, 每次都是SET, 天然幂等性
  • 基于数据库的唯一键
  • 消费者发送消息时带上一个全局唯一的ID, 消费者拿到消息后, 根据消息的ID去redis查一下是否有已消费的记录, 没有则处理并将ID写入redis, 已消费过则不处理

解决消息积压问题

消费者的消费速度远低于生产者发送消息的速度, 导致kafka中由大量的数据没有被消费, 随着数据堆积的越多, 消费者寻址的性能越来越差, 整个kafka性能也变很差

  • 在一个消费者中启动多个线程, 让多个线程同时消费, 即提升一个消费者的消费能力
  • 可以创建多个分区再启动多个消费者, 多个消费者部署在同一服务器或不同服务器, 提高消费能力, 即充分利用CPU资源
  • 让一个消费者把收到的消息往另外一个topic上发, 另一个topic设置多个分区和多个消费者, 进行具体的业务消费, 即转发消息消费

死信队列和延时队列

  • 死信队列也是一个消息队列, 它用来存放那些没有被成功消费的消息, 通常可以用来作为消息重试
  • 延时队列就是用来存放需要在指定时间被处理的消息的队列, 通常可以用来处理一些具有过期性操作的业务, 比如十分钟内未支付取消订单

简述Kafka的副本同步机制

Kafka 中 partition 分为 leader 节点和 follwer 节点, follwer 节点可能有多份, 读写请求都是由 leader 负责, follower 正常情况是不负责客户端请求的, 它只是从 leader 拉取数据做数据同步, 类似主备模式, 当 leader 挂掉之后在 follwer 中选举出一个新 leader 去接收客户端的请求

简述Kafka架构设计

  • Broker: 节点, 一个kafka节点就是一个broker, 一个或者多个 broker 可以组成 kafka 集群
  • Topic: 主题, 描述一类消息, 可以理解成是一个类别的名称, Kafka 根据 topic 对消息进行分类, 发布到 kafka 集群的每条消息都需要指定一个 topic, 不同的 topic 会被订阅该 topic 的消费者消费
  • Producer: 消息生产者, 向 broker 发送消息的客户端
  • Consumer: 消息消费者, 从 broker 读取消息的客户端
  • ConsumerGroup: 每个 Consumer 属于一个特定的 ConsumerGroup, 一个消息可以被多个不同的 ConsumerGroup 消费, 但是一个 ConsumerGroup 中只能有一个 Consumer 消费该消息
  • Partition: 物理上的一个个的文件夹(文件夹下有数据文件,和相应的索引文件), 一个 topic 可以分为多个 partition, 分区的作用是做负载, 一个主题中的消息量是非常大的, 因此可以通过分区的设置, 来分布式存储这些消息, 分区存储, 可以解决统一存储文件过大的问题, 提高读写的吞吐量, 读和写可以同时在多个分区中进行, 同一个topic在不同的分区的数据是不重复的, 每个 partition 内部消息是有序的
  • Replication 副本: 为主题中的每个分区创建备份, 在集群中, 每个分区的不同副本会被部署在不同的broker上, 每个分区及副本中有一个leader, 其他为follower, 且副本的数量不能大于broker节点的数量, leader负责把数据同步给follower, 读写操作都发生在leader上
  • ISR: 可以同步并且已经同步的broker节点集合, leader宕机后会从ISR集合中选举, 若ISR中的节点性能基较差, 会被踢出

集群中有多个broker, 创建topic时可以指明topic有多个分区(把消息拆分到不同的分区中存储), 可以为分区创建多个副本, 不同的副本存放在不同的broker里, 副本中有一个leader负责读写, 其他follower只负责从leader同步数据, 当leader宕机时顶上

Kafka中高性能高吞吐(读写快)的原因

kafka不基于内存, 而是磁盘存储, 因此消息堆积能力更强

顺序读写

利用磁盘的顺序访问速度可以接近内存, kafka的消息都是append操作, partition是有序的, 节省了磁盘的寻道时间, 同时通过批量操作, 节省写入次数, partition物理上分为多个segment(文件)存储, 方便删除

零拷贝

直接将内存缓冲区的数据发送到网卡传输, 使用的是操作系统的指令支持

传统:

  • 读取磁盘文件数据到内核缓冲区
  • 将内核缓冲区的数据copy到用户缓冲区
  • 将用户缓冲区的数据copy到socket的发送缓冲区
  • 将socket发送缓冲区的数据发送到网卡进行传输
分区

将消息分区存储, 负载增强, 提高并发能力

缓冲区

生产者采用异步发送消息, 当发送一条消息时, 消息并没有发送到broker而是缓存起来, 然后直接向业务返回成功, 当缓存的消息达到一定数量时再批量发送给broker, 减少网络IO, 提高消息发送的吞吐量

Kafka消息高可靠解决方案(保证消息不丢失)

  • 生产者在生产过程中的消费丢失
  1. 使用同步发送
  2. ack应答机制: 0 发送完直接返回, 不管是否发送成功, 1 leader 写入成功就返回, 未同步到follower, 若leader故障则消息丢失, all/-1 等待ISR(跟leader保持同步的所有follower节点)同步完再返回, 设置为1或all
  3. unclean.leader.election.enable: false, 禁止选举ISR以外的follower为leader
  4. tries > 1, 重试次数
  5. min.insync.replicas > 1, 最小同步副本数, 没满足该值前, 不提供读写服务, 写操作会异常, min.insync.replicas与ack可以更大的保证持久性, 确保如果大多数副本没有收到写操作, 则生产者会引发异常
  • 消费者在消费过程中的消息丢失
    设置手工提交offset, 先commit再处理消息时, 如果在处理消息的时候异常了, 但是offset已经提交了, 这条消息对于消费者来说就是丢失了, 先处理消息再commit, 如果消息处理成功后在commit时异常, 导致该消息没有及时删掉, 就会出现重复消费
  • broker 在故障后的消息丢失
    减少刷盘间隔

Kafka中zk的作用

Zookeeper是分布式协调, 不是数据库
首先kafka中broker的状态数据存储在zk中
然后kafka集群的broker中controller选择, 是通过zk的临时节点争抢获得的

Kafka在消费者消费消息时是push还是pull

pull 模式
  • 根据consumer的消费能里进行数据拉取, 可以控制速率
  • 可以批量拉取, 也可以单条拉取
  • 可以设置不同的提交方式, 实现不同的传输语义

当broker没有数据, 会导致consumer不断的在循环中轮询, 直到新消息到达, 会一直占用CPU资源, Kafka可以通过参数设置, 当consumer拉取数据为空或没有达到一定数量时进行阻塞

push 模式
  • 不会导致consumer循环等待

但是速率固定, 忽略了consumer的消费能力, 可能导致拒绝服务或者网络拥塞等情况

设计微服务时遵循的原则

软件是为业务服务的, 好的系统不是设计出来的, 而是进化出来的

  • 单一职责
    让每个服务能独立, 有界限的工作, 每个服务只关注自己的业务, 做到高内聚
  • 服务自治
    每个服务要做到独立开发, 独立测试, 独立构建, 独立部署, 独立运行, 与其他服务进行解耦
  • 轻量级通信
    让每个服务之间的调用是轻量级, 并且能够跨平台, 跨语言, 比如采用RESTful风格, 利用消息队列进行通信
  • 粒度进化
    对每个服务的粒度把控, 服务的粒度随着业务和用户的发展而发展

CAP理论

描述分布式系统下, 节点数据同步的定理

  • Consistency: 一致性, 数据在多个副本节点中保持一致, 比如两个用户访问两个系统A和B, 当A系统数据有变化时, 及时同步给B系统, 让两个用户看到的数据是一致的
  • Availability: 可用性, 系统对外提供服务必须一直处于可用状态, 在任何故障下, 客户端都能在合理的时间内获得服务端非错误的响应
  • Partition tolerance: 分区容错性, 在分布式系统中遇到任何网络分区故障, 系统仍然能对外提供服务

一个分布式系统最多只能同时满足C, A, P三项中的两项: 只要有网络调用, 网络总是不可靠的

  • 当网络发生故障时, 系统A与系统B没法进行数据同步, 即不满足P, 同时两个系统依然可以访问, 此时相当于是单机系统而非分布式系统, 既然是分布式系统, P必须满足
  • 当P满足时, 如果用户1通过系统A对数据data进行了修改, 也要让用户2通过系统B拿到data的新值, 就必须等待网络将系统A和系统B的数据同步好, 并且在同步期间, 任何人不能访问系统B, 即系统不可用, 否则数据就不是一致的, 此时满足CP
  • 当P满足时, 如果用户1通过系统A对数据data进行了修改, 也要让系统B能继续提供服务, 就只能接受在系统A没有将数据同步到系统B期间, 用户2通过系统B拿到的值不是新值, 牺牲一致性, 此时满足AP

BASE理论

  • Basically Available: 基本可用, 分布式系统在出现不可预知故障时, 允许损失部分可用性, 保证核心功能的可用
  • Soft state: 软状态, 弱状态, 允许系统中的数据存在中间状态, 并且该中间状态的存在不影响系统的整体可用性, 允许系统在不同节点的数据副本之间进行数据同步的过程存在延时
  • Eventually consistent: 最终一致性, 系统中所有的数据副本, 在经过一段时间的同步后, 最终能够达到一个一致的状态, 即需要系统保证最终数据能够达到一致, 而不需要实时保证系统数据的强一致性

BASE理论并没有要求数据的强一致性, 而是允许数据在一定的时间段内不一致, 但在最终某个状态会达到一致, 在生产环境中, 很多公司会采用BASE理论来保证数据的一致, 因为系统的可用性相比强一致性来说更加重要

保证幂等性

  • 查询操作
    查询一次和查询多次, 在数据不变的情况下, 查询的结果是一样的, select是天然的幂等操作
  • 删除操作
    删除操作也是幂等的, 删除一次和多次删除都是把数据删除
  • 唯一索引
    防止新增脏数据
  • token机制
    防止页面重复提交, 将token与redis配合使用
  • traceId
    操作时唯一

Authentication 认证 与 Authorization 授权

认证是验证用户的身份的凭据(比如用户名和密码), 通过这个凭据系统得以知道你就是你, 即系统里有你这个用户, Authentication被称为身份/用户验证
而授权发生在认证之后, 授权主要掌管我们访问系统的权限, 比如某些特定的系统资源只允许某些特定权限的用户可以访问操作
认证与授权一般在系统中搭配一起使用, 保证系统的安全性

Session, Cookie, JWT

Session和Cookie都是用来记录用户的状态, Cookie数据保存在浏览器端, Session数据保存在服务器端, 相对来说Session安全性更高, 敏感信息不要放入Cookie, 最好将Cookie信息加密, 用到时再去服务器端解密
首先需要保证保存Session信息服务器的可用性, 并且服务端维护了一个Session列表, 每次请求都会在该列表中查询, 然后判断是否其值有效, 而且Cookie只适合浏览器无法适用移动端, 而通过JWT (Json Web Token) 不需要在服务端维护数据了, 只需要请求时将值携带过来, 由于有签名可以验证其合法性, 并且可以存储用户信息, 解析后可直接得到
利用JWT可以有效防止CSRF (Cross Site Request Forgery)攻击

分布式系统下, Session 共享

在某些场景下, 是可以没有Session的, 其实在很多接口类系统中, 都提倡API无状态服务, 即每一次的接口访问都不依赖于session, 不依赖于前一次的接口访问, 用JWT的token
现在的系统会把session放到Redis中存储, 虽然架构上变得复杂, 并且需要多访问一次Redis, 但可以实现session共享, 支持水平扩展(增加Redis服务器), 服务器重启session不丢失(注意session在Redis中的刷新/失效机制), 不仅可以跨服务器session共享, 甚至可以跨平台(比如网页端和APP端)进行共享

Etcd

概念

etcd 是Go语言编写的一个开源, 分布式, 强一致, 高可用的key-value型关键元数据存储系统, 并通过Raft一致性算法处理和确保分布式一致性, 解决了分布式系统中数据一致性的问题

特点
  • 简单: 部署简单, 使用简单 (并提供HTTP API接口), 数据结构简单(数据存储就是键值对的有序映射)
  • 可用性: 一半以上节点存活即可提供服务
  • 一致性: Raft共识算法保证各节点数据一致性
  • 数据持久化: 默认数据一更新就进行持久化
  • 存储: 数据分层存储在文件目录中, 类似文件系统
  • Watch机制: 基于对指定key的更改事件的监听并通知
  • 安全通信: 支持SSL证书认证
场景
  • 键值对存储: 用于键值存储的组件, 存储是etcd最基本的功能
  • 服务注册与发现: 接收提供方的服务注册与登记, 然后返回给请求方, 以便请求方去调用
  • 配置中心: 将一些配置信息放到etcd上进行集中管理, 应用在启动时主动去etcd获取一次配置信息, 同时在etcd节点上注册一个watcher等待, 以后每次配置有更新的时候, etcd都会实时通知订阅者, 达到获取最新配置信息的目的
  • 分布式锁: 基于Raft算法, etcd保证了数据的强一致性, 存储到集群中的值必然是全局一致的, 很容易实现分布式锁(保持独占, 控制时序)
架构
  • etcd Server: 对外接收和处理客户端的请求
  • gRPC Server: etcd与其他etcd节点之间的通信和信息同步
  • MVCC: 多版本控制, etcd的存储模块, 键值对的每一次操作行为都会被记录存储, 这些数据底层存储在BoltDB数据库中
  • WAL: 预写式日志, etcd中的数据提交前都会记录到日志
  • Snapshot: 快照, 以防WAL日志过多, 用于存储某一时刻etcd的所有数据, WAL与Snapshot结合可以有效的进行数据存储和节点故障恢复
  • Raft: 实现etcd数据一致性的关键, etcd节点之间通过Raft实现一致性通信












Golang 面试总结相关推荐

  1. Golang面试整理 三 简历如何书写

    Golang面试整理 三 一.CRUD项目简历如何写,以及如何应对面试 一.CRUD项目简历如何写,以及如何应对面试 1.扬长避短,找一个擅长的点,重点突出,以此掩盖项目的不足: 2.将技能亮点写到个 ...

  2. 【Golang 面试算法二叉树 手动建树】

    Golang 面试算法二叉树 手动建树 前言 代码实现 前言 面试中有很多有关二叉树的题目,且需要手动建树,这里记录一下如何用Golang来快速构建一个二叉树. 代码实现 我们知道二叉树可以扁平化到数 ...

  3. Golang面试宝典——Go语言实现排序算法之冒泡排序

    关于Golang面试宝典 最近几年,Go的热度持续飙升,国内外很多大公司都在大规模的使用Go.Google是Go语言诞生的地方,其他公司如Facebook.腾讯.阿里.字节跳动.百度.京东.小米等都在 ...

  4. golang 面试经验分享

    golang 面试经验分享 1.分享一下golang一面(hr面): 首先是简单的自我介绍: 根据简历上的项目简单介绍一下项目使用的技术栈: 有没有了解过公司 前一段实习经验的感受与想法,为什么从上一 ...

  5. golang面试问题汇总(陆续更新)

    由于本人在准备找golang的春招实习,所以开此贴方便记录 图床有些问题,最近准备搭建一个个人博客,到时候会放出链接 由于近期看到百度文库等抄袭搬运猖獗,故只放出部分内容,如需全部请私聊. golan ...

  6. Golang精编100题-搞定golang面试

    Golang精编100题 能力模型 级别 模型 初级 primary 熟悉基本语法,能够看懂代码的意图: 在他人指导下能够完成用户故事的开发,编写的代码符合CleanCode规范: 中级 interm ...

  7. python运维开发招聘_GitHub - PlutoaCharon/LiunxNotes: 校招-运维开发(Liunx,Python,Golang)面试学习笔记...

    校招-运维开发(Liunx,Python,Golang)面试学习笔记 1. 网络基础类 2. Linux系统管理类 3. Linux服务管理类 4. 数据库管理 ​ 索引(包括分类及优化方式,失效条件 ...

  8. Golang 正在成为互联网大厂的主流编程语言!

    这个曲线图叫「技术采用生命周期」,是用来衡量用户对某项新技术接受程度的模型.根据曲线维度,消费者采用新技术的过程可以分成五个阶段: 创新者(2.5%)早期采用者(13.5%)早期大众(34%)晚期大众 ...

  9. Golang相关面试题

    Golang (63条消息) go/golang面试中的高频八股文问题_光哥2020的博客-CSDN博客_golang八股文 1.go的profile工具? profile就是定时采样,收集cpu,内 ...

最新文章

  1. 火爆全网!《算法刷题宝典》资源,免费下载!(含代码数据)
  2. SpringBoot 启动错误搜集
  3. 【C++ 语言】引用 ( 引用简介 | 指针常量 | 常量指针 | 常引用 | 引用参数 | 引用 指针 对比 )
  4. 约瑟夫问题(vector的使用)
  5. JavaSE----常用类(String、StringBuilder、StringBuffer)
  6. 多模块顺序_软件架构基础 3: 什么是好的模块化代码?高内聚、低耦合如何衡量?...
  7. order by case when then end
  8. MySQL8.0.22解压安装教程
  9. 【MATLAB航空航天工具箱】学习笔记--闰秒
  10. EBS采购订单创建发票
  11. Scrapy爬取二手房信息
  12. 英语单词 One 个人 5. 身体动作
  13. foo/bar/baz/qux
  14. 群晖文件管理系统使用教程
  15. HTML实现banner图切换
  16. 软件测试————第二章
  17. 浅陌初心 / vue3-admin-element
  18. 2、简单的onclick点击事件
  19. 网页短链接的实现原理
  20. 《极简算法史》读书札记

热门文章

  1. nodejs+vue毕业生求职招聘平台系统
  2. Python3.7实现中控考勤机自动连接
  3. Linux线程安全之---信号量
  4. window上记事本python_[Python] wxPython 高防Windows10记事本 (end...)
  5. cad中怎么随意移动图形_cad中怎么随意移动图形_绘制CAD图纸的过程中CAD快捷键失灵了怎么办?...
  6. 微信小程序常见网络请求失败问题总结及解决方案
  7. Ubuntu14.04 +GTX1070 Ti +cuda 8.0+cudnn5.1+tensorflow 1.2 的安装
  8. Java基本类型占用的字节数(char占用几个字节问题)
  9. 纸飞机【最小链覆盖等于最长反链】
  10. CE MAPI学习(二)结束一个MAPI SESSION