Golang
相比较于其他语言, Go 有什么优势或者特点
- Go 允许跨平台编译,编译出来的是二进制的可执行文件,直接部署在对应系统上即可运行
- Go 在语言层面上天生支持并发编程,通过 goroutine 和 channel 实现。channel 的理论依据是 CSP 并发模型 (通信协作模型), 即所谓的通过通信来共享内存, 而不是用共享内存来通信;Go 在 runtime 运行时里实现了属于自己的调度机制:GMP,降低了内核态和用户态的切换成本。
- Go 是静态类型语言, 代码风格是强制性的统一,如果没有按照规定来,会编译不通过。
进程,线程,协程
并发:多线程程序在单核上运行。并行:多线程程序在多核上运行
- 程序
编译成功后得到的二进制文件, 占用磁盘空间, 一个程序可以启动多个进程
- 进程
即运行起来的程序, 每个进程都有自己的独立内存空间,拥有自己独立的地址空间、独立的堆和栈,既不共享堆,亦不共享栈。一个程序至少有一个进程,一个进程至少有一个线程。进程切换只发生在内核态,是系统资源分配的最小单位
- 线程
线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,是由操作系统调度,是操作系统调度(CPU调度)执行的最小单位。对于进程和线程,都是由内核进行调度,有 CPU 时间片的概念, 进行抢占式调度。内核由系统内核进行调度, 系统为了实现并发,会不断地切换线程执行, 由此会带来线程的上下文切换,一个进程可以有多个线程,每个线程会共享父进程的资源
- 协程
独立的栈空间, 共享堆空间,协程是由程序员在协程的代码中显示调度,属于用户态线程,协程是对内核透明的, 也就是系统完全不知道有协程的存在, 完全由用户自己的程序进行调度。在栈大小分配方便,且每个协程占用的默认占用内存很小,只有 2kb ,而线程需要 8mb,相较于线程,因为协程是对内核透明的,所以栈空间大小可以按需增大减小,轻量且开销较小。
协程与线程主要区别是它将不再被内核调度,而是交给了程序自己而线程是将自己交给内核调度,所以golang中就会有调度器的存在。
并发编程
Go语言通过goroutine和channel实现并发。goroutine是一种轻量级的线程,可以同时执行多个goroutine,而不需要显式地管理线程的生命周期。channel是用于goroutine之间通信的管道。
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 小的多
select
select语句是Go语言中用于处理通道操作的一种机制。它可以同时监听多个通道的读写操作,并在其中任意一个通道就绪时执行相应的操作
通过使用select语句,我们可以实现对多个通道的并发操作,并根据就绪的通道执行相应的操作。这在处理并发任务时非常有用
Go 的垃圾回收机制
Go语言中的垃圾回收器(Garbage Collector)是自动管理内存的机制,用于回收不再使用的内存。垃圾回收器会自动检测不再使用的对象,并释放其占用的内存空间
堆内存上分配的数据对象,不再使用时,不会自动释放内存,就变成垃圾,在程序的运行过程中,如果不能及时清理,会导致越来越多的内存空间被浪费,导致系统性能下降。
内存回收
- 手动释放占用的内存空间
程序代码中也可以使用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写数据
- 如果recvq队列不为空,说明缓冲区没有数据或无缓冲区,且有等待取值的goroutine在排队, 此时直接从recvq等待队列中取出一个G,并把数据写入,最后把该G唤醒,结束发送过程;
- 如果缓冲区有空余位置,则把数据写入缓冲区中,结束发送过程;
- 如果缓冲区没有空余位置,将当前G加入sendq队列,进入休眠,等待被读goroutine唤醒;
从channel读数据
- 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq队列中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
- 如果等待发送队列sendq不为空,说明缓冲区已满,从缓冲队列中首部读取数据,从sendq等待发送队列中取出G,把G中的数据写入缓冲区尾部,结束读取过程;
- 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
对已经关闭的 channel 进行读写,会怎么样
Go语言中的通道(channel)是一种非常有用的特性,用于在不同的goroutine之间传递数据, 在使用通道时,需要根据实际情况判断何时关闭通道,以避免出现不必要的竞态和内存泄漏。
- 当 channel 被关闭后,如果继续往里面写数据,程序会直接 panic 退出
- 关闭已经关闭的channel会发生Panic
- 关闭值为 nil 的channel会发生Panic
- 通道关闭后, 读取操作仍然可以从通道中读取到之前写入的数据。这是因为通道中的数据并没有立即消失,而是在读取完毕后被垃圾回收器回收。当关闭后的 channel 没有数据可读取时,将得到零值,即对应类型的默认值。
if v, ok := <-ch; !ok {
fmt.Println("channel 已关闭,读取不到数据")
}
还可以使用下面的写法不断的获取 channel 里的数据:
for data := range ch {
}
使用for-range读取channel, 这样既安全又便利, 当channel关闭时, 循环会自动退出, 无需主动检测channel是否关闭, 可以防止读取已经关闭的channel, 造成读取数据为通道所存储类型的零值
并发安全性
并发安全性是指在并发编程中,多个goroutine对共享资源的访问不会导致数据竞争和不确定的结果。为了确保并发安全性,可以采取以下措施:
- 使用互斥锁(Mutex):通过使用互斥锁来保护共享资源的访问,一次只允许一个goroutine访问共享资源,从而避免竞争条件。
- 使用原子操作(Atomic Operations):对于简单的读写操作,可以使用原子操作来保证操作的原子性,避免竞争条件。
- 使用通道(Channel):通过使用通道来进行goroutine之间的通信和同步,避免共享资源的直接访问。
- 使用同步机制:使用同步机制如等待组(WaitGroup)、条件变量(Cond)等来协调多个goroutine的执行顺序和状态。
runtime
runtime包是Go语言的运行时系统,提供了与底层系统交互和控制的功能。它包含了与内存管理、垃圾回收、协程调度等相关的函数和变量
指针
指针是一种变量,存储了另一个变量的内存地址。通过指针,我们可以直接访问和修改变量的值,而不是对变量进行拷贝。
指针在传递大型数据结构和在函数间共享数据时非常有用。
接口 interface
Go语言中的接口(interface)是一种非常重要的特性,用于定义一组方法
接口是一种动态类型,它可以包含任何实现了它所定义的方法集的类型。在使用接口时,需要注意以下几点:
- 接口是一种引用类型的数据结构,它的值可以为nil。
- 实现接口的类型必须实现接口中所有的方法,否则会编译错误。
- 接口的值可以赋给实现接口的类型的变量,反之亦然。
- 在实现接口的类型的方法中,可以通过类型断言来判断接口值的实际类型和值。
map 类型
map 是一种无序的键值对集合,也称为字典。map中的键必须是唯一的,而值可以重复。map 提供了快速的查找和插入操作,适用于需要根据键快速检索值的场景。
map 是使用哈希表、链表来实现的
我们从map中访问一个不存在的键时,它会返回该值类型的零值。
map是一种引用类型的数据结构,它的底层实现是一个哈希表。在使用map时,需要注意以下几点:
- map是无序的,即元素的顺序不固定。每次迭代map的顺序可能不同
- map的键必须是可以进行相等性比较的类型,如int、string、指针等。(通俗来说就是可以用 == 和 != 来比较的,除了slice、map、function这几个类型都可以)
- map的值可以是任意类型,包括函数、结构体等。
- map不是并发安全的, 在多个goroutine之间使用map时需要进行加锁,避免并发访问导致的竞态问题。
map 为什么是不安全的
Go 官方认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问),导致大部分程序付出加锁代价(性能),所以决定了不支持并发安全。
因为它没有内置的锁机制来保护多个 goroutine 同时对其进行读写操作,而是会对某个标识位标记为 1,当多个 goroutine 同时对同一个 map 进行读写操作时,就会出现数据竞争和不一致的结果,当它检测到标识位为 1 时,将会直接 panic。
如果想实现map线程安全
- 使用 map + 读写锁 sync.RWMutex
- 使用 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 对了
map的有序遍历
在Go语言中,map是无序的,每次迭代map的顺序可能不同。如果需要按特定顺序遍历map,可以采用以下步骤:
- 创建一个切片来保存map的键。
- 遍历map,将键存储到切片中。
- 对切片进行排序。
- 根据排序后的键顺序,遍历map并访问对应的值。
Go 的逃逸行为
逃逸分析是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包,提供了各种锁,如果使用了这个包,基本上就以悲观锁的工作模式了
乐观锁没有加锁和解除锁的步骤,直觉上会快一些;但是乐观锁这么做的前提是总认为不会发生并发,如果并发发生的概率很大,重试的次数会增加,这种情况下乐观锁的性能就差很多了。
悲观锁有加锁和解除锁的步骤,直觉上会慢一些;但是当有很多进程或者线程对同一个数值进行修改时,能避免大量的重试过程,这种情况下悲观锁的性能相对就很高了。
发编程中的自旋状态
自旋状态是并发编程中的一种状态,指的是线程或进程在等待某个条件满足时,不会进入休眠或阻塞状态,而是通过不断地检查条件是否满足来进行忙等待。
在自旋状态下,线程会反复执行一个忙等待的循环,直到条件满足或达到一定的等待时间。 这种方式可以减少线程切换的开销,提高并发性能。然而,自旋状态也可能导致CPU资源的浪费,因为线程会持续占用CPU时间片,即使条件尚未满足。
自旋状态是一种在等待条件满足时不进入休眠或阻塞状态的并发编程技术。它可以减少线程切换的开销,但需要权衡CPU资源的使用和等待时间的长短
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。
- 队列 A 最多只允许有 一个writer,如果有其他 writer,需要在 队列B 等待;
- 当一个 writer 到了 队列A 后,只允许它 之前的reader 执行读操作,新来的 reader 需要在 队列A 后面排队;
- 当前面的 reader 执行完读操作之后,writer 执行写操作;
- 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 uint32
m 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同时操作共享资源, 可以用锁或者原子操作去实现并发安全, 原子操作内部也是实现了锁
原子操作和锁
原子操作和锁是并发编程中常用的两种同步机制,它们的区别如下:
- 作用范围:
原子操作(Atomic Operations):原子操作是一种基本的操作,可以在单个指令级别上执行,保证操作的原子性。原子操作通常用于对共享变量进行读取、写入或修改等操作,以确保操作的完整性。
锁(Lock):锁是一种更高级别的同步机制,用于保护临界区(Critical Section)的访问。锁可以用于限制对共享资源的并发访问,以确保线程安全。
- 使用方式:
原子操作:原子操作是通过硬件指令或特定的原子操作函数来实现的,可以直接应用于变量或内存位置,而无需额外的代码。
锁:锁是通过编程语言提供的锁机制来实现的,需要显式地使用锁的相关方法或语句来保护临界区的访问。
- 粒度:
原子操作:原子操作通常是针对单个变量或内存位置的操作,可以在非常细粒度的层面上实现同步。
锁:锁通常是针对一段代码或一组操作的访问进行同步,可以控制更大粒度的临界区。
- 性能开销:
原子操作:原子操作通常具有较低的性能开销,因为它们是在硬件级别上实现的,无需额外的同步机制。
锁:锁通常具有较高的性能开销,因为它们需要进行上下文切换和线程同步等操作。
综上所述,原子操作和锁是两种不同的同步机制,用于处理并发编程中的同步问题。原子操作适用于对单个变量的读写操作,具有较低的性能开销。而锁适用于对一段代码或一组操作的访问进行同步,具有更高的性能开销。选择使用原子操作还是锁取决于具体的场景和需求。
需要注意的是,原子操作通常用于对共享变量进行简单的读写操作,而锁更适用于对临界区的访问进行复杂的操作和保护。在设计并发程序时,需要根据具体的需求和性能要求来选择合适的同步机制。
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的创建很轻量
- 可以限制goroutine数量, 避免无限制的增长占用系统资源, 减轻runtime调度压力
- 减少栈扩容的次数
- 频繁创建goroutine的场景下, 资源复用, 节省资源 (一般场景效果不明显, 需要一定规模)
go对goroutine有一定的复用能力, 所以要根据场景选择是否使用协程池, 不恰当的场景不仅得不到收益, 反而增加系统复杂性
ants是一个高性能的 goroutine 池,实现了对大规模 goroutine 的调度管理,允许使用者在开发并发程序的时候限制 goroutine 数量,复用资源,达到更高效执行任务的效果
type Pool struct {
work chan func()
sem chan struct{}
}
func New(size int) *Pool {
return &Pool{
work: make(chan func()),
sem: make(chan struct{}, size),
}
}
func (p *Pool) NewTask(task func()) {
select {
case p.work <- task:
case p.sem <- struct{}{}:
go p.worker(task)
}
}
func (p *Pool) worker(task func()) {
defer func() { <-p.sem }()
for {
task()
task = <-p.work
}
}
定时器原理
一开始,timer 会被分配到一个全局的 timersBucket 时间桶。每当有 timer 被创建出来时,就会被分配到对应的时间桶里了。
为了不让所有的 timer 都集中到一个时间桶里,Go 会创建 64 个这样的时间桶,然后根据 当前 timer 所在的 Goroutine 的 P 的 id 去哈希到某个桶上:
func (t *timer) assignBucket() *timersBucket {
id := uint8(getg().m.p.ptr().id) % timersLen
t.tb = &timers[id].timersBucket
return 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 不是并发安全的,在不安全的并发执行中是不会报错的,只是数据可能会出现丢失
- Slice 的一些注意写法
a := make([]string, 5)
fmt.Println(len(a), cap(a))
a = append(a, "aaa")
fmt.Println(len(a), cap(a))
a:=[]string{}
fmt.Println(len(a), cap(a))
a = append(a, "aaa")
fmt.Println(len(a), cap(a))
a := make([]string, 0, 5)
fmt.Println(len(a), cap(a))
a = append(a, "aaa")
fmt.Println(len(a), cap(a))
b := make([]int, 1, 3)
a := []int{1, 2, 3}
copy(b, a)
fmt.Println(len(b))
- 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_temp
value := value_temp
}
- slice 入参注意点
如果 slice 作为函数的入参,通常希望对 slice 的操作可以影响到底层数据,但是如果在函数内部 append 数据超过了 cap,导致重新分配底层数组,这时修改的 slice 将不再是原来入参的那个 slice 了。因此通常不建议在函数内部对 slice 有 append 操作,若有需要则显示的 return 这个 slice。
切片移除元素
要移除切片中的数据,可以使用切片的切片操作或使用内置的append函数来实现
- 使用切片的切片操作
利用切片的切片操作,可以通过指定要移除的元素的索引位置来删除切片中的数据。
例如,要移除切片中的第三个元素,可以使用切片的切片操作将切片分为两部分,并将第三个元素从中间移除。
numbers := []int{1, 2, 3, 4, 5}
indexToRemove := 2
numbers = append(numbers[:indexToRemove], numbers[indexToRemove+1:]...)
fmt.Println(numbers)
- 使用append函数
另一种方法是使用append函数,将要移除的元素之前和之后的部分重新组合成一个新的切片。这种方法更适用于不知道要移除的元素的索引位置的情况
numbers := []int{1, 2, 3, 4, 5}
elementToRemove := 3
for i := 0; i < len(numbers); i++ {
if numbers[i] == elementToRemove {
numbers = append(numbers[:i], numbers[i+1:]...)
break
}
}
fmt.Println(numbers)
参数传递切片和切片指针有什么区别
切片底层就是一个结构体,里面有三个元素, 分别表示切片底层数据的地址,切片长度,切片容量
- 当切片作为参数传递时,其实就是一个结构体的传递,因为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的指针
defer、panic、recover 三者的用法
defer语句是Go语言中的一项重要特性,可以用于在函数返回前执行一些清理或收尾工作,例如释放资源、关闭连接、解锁互斥锁等。
需要注意的是,defer语句并不是一种异步操作,它只是将被延迟执行的函数加入到一个栈中,在函数返回前按照先进后出的顺序执行,也就是说后定义的defer语句先执行,先定义的defer语句后执行
panic 和 recover 是Go语言中用于处理异常的机制。当程序遇到无法处理的错误时,可以使用panic引发一个异常,中断程序的正常执行。而recover用于捕获并处理panic引发的异常,使程序能够继续执行。
当产生 panic 的时候,会先执行 panic 前面的 defer 函数后才真的抛出异常。一般的,recover 会在 defer 函数里执行并捕获异常,防止程序崩溃。
package main
import "fmt"
func main() {
defer func(){
fmt.Println("b")
}()
defer func() {
if err := recover(); err != nil {
fmt.Println("捕获异常:", err)
}
}()
panic("a")
}
return 与 defer
return 并不是原子操作, 底层是两个步骤
- 返回值赋值, 返回值有匿名返回值, 具名返回值
- 执行defer
- 执行RET指令, 函数携带当前返回值退出
若为匿名返回值, 赋值后, defer中的操作对返回值没有影响, 若为具名返回值, 赋值后, defer中对返回值的操作, 影响最后的返回值
slice 和 array 的区别
在Go语言中,数组和切片(slice)都是用于存储一组相同类型的元素。它们的区别在于长度的固定性和灵活性。数组的长度是固定的,而切片的长度是可变的。
数组 array 是固定长度的,不能动态扩容, 在编译时就确定大小, 并且是值类型的,也就是说是拷贝复制的, 由于长度固定, 在某些场景下使用不方便, 不灵活
切片是基于数组的一种封装,它提供了更便捷的操作和灵活性。切片的底层是一个指向数组的指针,它包含了切片的长度和容量信息。 长度不固定支持动态扩容, 引用类型
拷贝大切片一定比拷贝小切片代价大吗
Go语言中只有值传递
切片本质内部结构是一个struct, 包含 Data uintptr, Len int, Cap int, 第一个字段是指向切片底层数组的指针,这是切片的存储空间,第二个字段是切片的长度,第三个字段是容量。将一个切片变量分配给另一个变量只会复制三个机器字,大切片跟小切片的区别无非就是 Len 和 Cap的值比小切片的这两个值大一些,如果发生拷贝,本质上就是拷贝上面的三个字段。所以在值拷贝时, 大切片并不需要更昂贵的操作
切片的深浅拷贝
深浅拷贝的本质,就是看拷贝内容是数据还是数据的地址, 即复制出来的新对象与原来的对象在它们发生改变时,是否会相互影响
- 使用=操作符拷贝切片,这种就是浅拷贝
- 使用[:]下标的方式复制切片,这种也是浅拷贝
- 使用Go语言的内置函数copy()进行切片拷贝,开辟一个新的内存空间, 这种就是深拷贝
零切片、空切片、nil切片是什么
- 零切片
我们把切片内部数组的元素都是零值或者底层数组的内容就全是 nil的切片叫做零切片,使用make创建的、长度、容量都不为0的切片就是零值切片:
slice := make([]int, 5)
slice := make([]*int, 5)
- 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与Memcached的区别
两者都是非关系型内存键值数据库
区别:
- 线程模型
- Memcached处理请求采用多线程模型,并且基于IO多路复用技术,主线程接收到请求后,分发给子线程处理。
优点是当某个请求处理比较耗时,不会影响到其他请求的处理。缺点是CPU的多线程切换必然存在性能损耗,同时,多线程在访问共享资源时必然要加锁,也会在一定程度上降低性能
- Redis同样采用IO多路复用技术,但它处理请求采用是单线程模型,从接收请求到处理数据都在一个线程中完成。
缺点一旦某个请求处理耗时比较长,那么整个Redis就会阻塞住,直到这个请求处理完成后返回,才能处理下一个请求,使用Redis时一定要避免复杂的耗时操作。由于Redis是内存数据库,它的访问速度非常地快,所以它的性能瓶颈不在于CPU,而在于内存和网络带宽
Redis6.0又进一步完善了多线程,在接收请求和发送请求时使用多线,进一步提高了处理性能
- 数据结构
- Memcached支持的数据结构很单一,仅支持string类型的操作。并且对于value的大小限制必须在1MB以下,过期时间不能超过30天。使用Memcached时,我们只能把数据序列化后写入到Memcached中。然后再从Memcached中读取数据,再反序列化为我们需要的格式
- 而Redis对于不同的数据结构可以采用不同的操作方法,非常灵活
- 淘汰策略
- Memcached必须设置整个实例的内存上限,数据达到上限后触发LRU淘汰机制,优先淘汰不常用使用的数据。但它的数据淘汰机制存在一些问题:刚写入的数据可能会被优先淘汰掉
- Redis没有限制必须设置内存上限,如果内存足够使用,Redis可以使用足够大的内存。同时Redis提供了多种淘汰策略
- 持久化
- Memcached不支持数据的持久化,如果Memcached服务宕机,那么这个节点的数据将全部丢失。
- Redis支持将数据持久化磁盘上,提供RDB和AOF两种方式
- 高可用
- Memcached没有主从复制架构,只能单节点部署,如果节点宕机,那么该节点数据全部丢失
- Redis拥有主从复制架构和哨兵节点,在主节点宕机时,主动把从节点提升为主节点,继续提供服务
redis 是单线程还是多线程, 为什么快
单线程
Redis无论什么版本, 都是工作单线程, 指令串行执行, 内部保证线程安全, 外部使用时业务上要自行保证(互斥锁)
而6.x 版本为 IO 多线程, 提高吞吐量, 更好的压榨系统和硬件资源, 单机可支持约 55000-75000 的 QPS
原子操作:
- 单指令
- pipeline管道 (一个客户端的指令集合, 客户端先攒后发再执行)
- lua脚本的方式
- 事务的执行期间 (事务有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 将尝试执行内存淘汰策略以腾出空间, 并且可能抛出 Out-of-Memory 错误
内存空间不足的情况下会发生淘汰, 可以设置数据达到多大后执行淘汰策略, 在 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 Database)
- RDB 是Redis默认的持久化方式。在指定的时间间隔内周期性地将 redis 中的全量数据以快照的方式写入到一个二进制文件中,默认的文件名dump.rdb,然后将该文件同步到硬盘中以达到持久化的目的。
- RDB自动触发:配置redis.conf,当用户设置了多个save选项,只要其中任一条满足,都会触发一次BCSAVE操作
save 900 1
#900秒之内至少一次写操作
save 300 10
#300秒之内至少发生10次写操作
- RDB的优点是快速和紧凑,适合用于备份和恢复数据。
- RDB的缺点是在发生故障时可能会丢失一部分数据,因为RDB是定期进行持久化的,而不是实时的。
- 它会先 fork 一个子进程,将数据的写入交给子进程,而父进程不会涉及到磁盘的 IO 操作,所以 RDB 的性能非常好。由于 RDB 文件只存储了某个时刻的内存数据,并没有什么逻辑命令,所以在进行重启恢复时,能很快的加载进来。
- AOF(Append Only File)
- 将收到的每一条写操作命令都持续增量的追加到磁盘上的aof 文件中, 由于只是一个append到文件的操作(增量),所以写到硬盘上的操作比较快。在服务器启动时,通过重新执行这些命令来还原数据集
- aof 文件同步策略:
always:每一条AOF记录都立即同步到文件,性能很低,但较为安全。
everysec:每秒同步一次,性能和安全都比较中庸的方式,也是redis推荐的方式。这样可以很大程度减少数据丢失, 同时它采用追加的方式进行写文件,这样即使持久化失败,影响较少, 如果遇到物理服务器故障,可能导致最多1秒的AOF记录丢失。
no:Redis永不直接调用文件同步,而是让操作系统来决定何时同步磁盘。性能较好,但很不安全
- AOF的优点是可以提供更好的数据安全性,因为它记录了每个写操作,可以在发生故障时进行恢复。
- AOF的缺点是相对于RDB来说,文件体积较大,恢复数据的速度较慢。
- 当AOF文件随着写命令的运行会越来越大时,文件大小触碰到临界时,rewrite会被运行。rewrit不会在原文件上进行,而是创建一个临时文件,遍历数据库,将每个key、value对输出到临时文件。输出格式就是Redis的命令,但是为了减小文件大小,会将多个key、value对集合起来用一条命令表达。
在rewrite期间的写操作会保存在内存的rewrite buffer中,rewrite成功后这些操作也会复制到临时文件中,在最后临时文件会代替AOF文件
Redis 事务
事务是先发送指令, 在服务端攒着, 再执行, 而且事务若有指令失败, 失败就是失败, 其他继续执行, 没有回滚
- Multi: 开始事务
- Exec: 执行事务
- Discard: 回滚事务
如何实现分布式锁
保证多机器, 多进程, 多线程访问资源的同步问题和数据一致性问题
类cas自旋式分布式锁, 询问的方式, 尝试加锁 (MySQL, Redis)
Redis: 工作单线程, 业务处理为串行方式
- 加锁: SETNX key value , 原子操作, 当key不存在时, 完成创建并返回成功, 否则返回失败, 获取锁成功后执行后续逻辑, 只加锁但未释放锁会出现死锁
- 释放锁: DEL key, 通过删除键值对来释放锁, 以便其他线程通过SETNX命令获得锁, 加锁后, 程序还没执行释放锁, 程序挂了, 会出现死锁
- 为锁设置过期时间: EXPIRE key timeout, 设置key的超时时间, 保证锁在没有被显式释放时, 也能在一定时间后自动释放, 避免资源被永久锁住, 当过期时间到达, Redis会自动删除对应的key-value, SETNX和EXPIRE非原子性, Redis支持nx和ex操作是同一原子操作 set key value [expiration EX seconds|PX milliseconds] [NX|XX]
- 锁误删除: 若线程A成功获得锁并设置过期时间30秒, 但线程A执行时间超过30秒, 锁过期自动释放, 此时线程B获得锁并设置过期时间, 随后线程A执行完成并通过DEL命令来释放锁, 但此时线程B加的锁还没有执行完成, 线程A实际释放了线程B的锁, 即避免超时时间设置不合理时, 自己的锁被其他线程释放掉, 导致锁一直失效, 通过在value中设置当前线程加锁的标识, 在删除之前验证key对应的value, 判断是否当前线程持有, 可生成一个UUID标识当前线程
- 超时解锁导致并发: 若线程A成功获得锁并设置过期时间30秒, 但线程A执行时间超过30秒, 锁过期自动释放, 此时线程B获得锁, 线程A和线程B并发执行, 使用Redission, 通过WatchDog机制为将要过期但未释放的锁增加有效时间
- 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 临时顺序节点 (临时节点在客户端与集群断开连接后被自动删除, 顺序节点按照前后创建的顺序维护一个递增值, 并以该值作为节点名称的一部分)
- 创建一个临时顺序节点 /业务ID/lock-, 节点的数据是 write, 表示是写锁
- 获取 zk 中 /业务ID 下所有的子节点
- 判断自己是否是最小的节点, 如果是则表示该客户端获得了锁, 即上锁成功, 如果不是, 说明前面还有其他的临时节点在使用中, 没被删除, 即有别的客户端还在获得锁中, 当前客户端则上锁失败, 然后通过watch机制来监听节点的变化, 如果有变化, 则回到第二步
Redis作为限流器或计数器
在指定的时间周期内, 限制累积的次数, 下一周期则会清零
- 计数器算法: 使用Redis的 incr 原子自增性, 再结合key的过期时间, 但会有一个临界问题 (在上一个周期的后半段与下一个周期的前半段所在的时间段内, 可能会出现超于限制数的情况)
- 滑动时间窗口算法: 解决计数器算法的临界问题, 将实际周期切分为多个小的时间窗口, 分别在每个小的时间窗口中计数, 然后根据时间将窗口向前滑动, 并删除过期的小时间窗口, 最终只需要统计滑动窗口范围内的小时间窗口的总数
Redis 集群方案
主从复制
高可用, 解决单点故障问题, 在不同的机器上部署着同一 Redis 程序。在这多台机器里,我们会选择一个节点作为主节点,它负责数据的写入。其他节点作为从节点,从节点负责读取, 且定时的和主节点同步数据。一旦主节点不能使用了,那么就可以在从节点中挑选一个作为主节点,重新上岗服务。
可以人工进行故障节点切换, 也可以利用哨兵监控来自动切换从为主, 即哨兵会不断的检测主从节点是否能正常工作, 当某个 master 不能正常工作时,Sentinel 会启动一个故障转移过程,将其中的一个副本提升为 master,并通知其他从节点对应新的 master 相关信息, 还会告知已连接过来的客户端程序关于主节点新的地址, 客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址
- 数据冗余
主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
- 故障恢复
当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。
- 负载均衡
在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
- 读写分离
可以用于实现读写分离,主库写、从库读,读写分离不仅可以提高服务器的负载能力,同时可根据需求的变化,改变从库的数量;
从节点开启主从复制, 配置文件中
replicaof
masterauth
- 第一阶段是建立链接、协商同步;
- 第二阶段是主服务器同步数据给从服务器;
- 第三阶段是主服务器发送新写操作命令给从服务器
主从复制共有三种模式:全量复制、基于长连接的命令传播、增量复制
- 主从服务器第一次同步的时候,就是采用全量复制,此时主服务器会两个耗时的地方,分别是生成 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提供锁, 避免主从模式同步不及时导致数据不一致而产生锁错误)
- 请求redis, 根据key取值
- 取不到就去抢锁
- 抢到锁之后再请求数据库, 更新redis
- 没得到锁则休眠一段时间重试, 回到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 属性,将时间戳设置到该属性上,然后定时的对其排序,查看最近要执行的记录,如果时间到了,则取出来消费后删除,即可达到延迟队列的目的。
秒杀系统
-
业务特点:
1、瞬时并发量大,秒杀时会有大量用户在同一时间进行抢购,瞬时并发访问量突增几倍、甚至几十倍以上
2、库存量少,一般秒杀活动商品量很少,这就导致了只有极少量用户能成功购买到。
3、业务和流程较为简单,一般都是下订单、扣库存、支付订单。
-
技术难点:
1、若秒杀活动若与其他营销活动同时进行,可能会对其他活动造成冲击,极端情况下可能导致整个服务宕机。
2、页面流量突增,秒杀活动用户访问量会突增。需确保访问量的突增不会对服务器、数据库、Redis等造成过大的压力。
3、秒杀活动库存量小,瞬时下单量大,易造成超卖现象
-
架构设计思想
1、限流:由于库存量很少,对应的只有少部分用户才能秒杀成功。所以要限制大部分用户流量,只准少量用户流量进入后端服务器。
2、削峰:秒杀开始瞬间,大量用户进来会有一个瞬间流量峰值。把瞬间峰值变得更平缓是设计好秒杀系统关键因素。一般的采用缓存和MQ实现流量的削峰填谷。
3、异步:秒杀可以当做高并发系统处理。即可以从业务上考虑,将同步的业务,设计成异步处理的任务。
4、缓存:秒杀瓶颈主要体现在下单、扣库存的数据操作中。关系型数据库写入和读取效率较低。若将部分操作放到缓存中能极大提高并发效率(如使用Redis操作库存)
-
客户端优化
1、秒杀页面:
如果秒杀页面的资源,如:CSS、JS、图片、商品详情等都经后端,服务肯定承受不住。如果将这个页面进行静态化,秒杀时肯定能起到压力分散的作用。
2、防止提前下单:
使用JS控制提交订单按钮,如果秒杀时间,就不能点击该按钮。
-
服务端优化
1、对查询秒杀商品进行优化
将首次查询到的商品信息进行数据放入缓存,后面再访问时直接返回缓存的信息。
2、对库存的优化
在设置秒杀活动时就将商品库存放于Redis中,在下单扣库存时,直接对Redis进行操作。
3、后端流量控制优化(参加用户量过大时)
使用消息队列、异步处理等方式解决。即超过系统水位线的请求直接拒绝掉。
-
核心思想:
1、层层过滤,逐渐递减瞬时访问,降低下游的压力,减少最终对数据库的冲击
2、充分利用缓存与消息队列,提高请求处理速度以及削峰填谷的作用
网络
当在浏览器上输入一个网址,其内部发生了什么
-
DNS 解析
当用户在浏览器中输入网址时,浏览器会首先向DNS服务器发送请求,获取该网址对应的IP地址。DNS解析是将域名转换为IP地址的过程,它是整个过程的第一步。
1.1 首先搜索浏览器自身的DNS缓存,有缓存直接返回;
1.2 浏览器自身DNS不存在,浏览器就会调用一个类似gethostbyname的库函数,,此函数会先去检测本地hosts文件,查看是否有对应ip。
1.3 如果本地hosts文件不存在映射关系,就会查询路由缓存,路由缓存不存在就去查找本地DNS服务器(一般TCP/IP参数里会设首选DNS服务器,通常是8.8.8.8))
1.4 如果本地DNS服务器还没找到就会向根服务器发出请求
-
TCP连接
当浏览器获取到服务器的IP地址后,它会通过TCP协议与服务器建立连接。TCP连接是一种可靠的、面向连接的协议,它保证了数据的可靠传输
-
HTTP请求
一旦TCP连接建立成功,浏览器会向服务器发送HTTP请求。HTTP请求包括请求方法、请求头、请求体等信息,它告诉服务器浏览器需要获取哪些资源
-
服务器处理请求
当服务器接收到HTTP请求后,它会根据请求的内容进行处理。服务器可能需要查询数据库、读取文件、执行代码等操作,以生成响应内容
-
HTTP响应
服务器处理完请求后,会向浏览器发送HTTP响应。HTTP响应包括响应状态码、响应头、响应体等信息,它告诉浏览器服务器返回了什么内容
-
页面渲染
一旦浏览器接收到HTTP响应,它会根据响应的内容进行页面渲染。浏览器会解析HTML、CSS、JavaScript等文件,并将它们渲染成页面
-
TCP断开连接
当浏览器完成页面渲染后,它会通过TCP协议向服务器发送一个断开连接的请求。服务器接收到请求后,会断开与浏览器的TCP连接
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连接
TCP/IP协议栈
互联网的核心是一系列协议, 总称互联网协议, 正是这一些协议规定了电脑如何连接和组网
常见的互联网分层模型为TCP/IP4层模型: 应用层, 传输层, 网络层, 链路层
TCP是面向连接的, 可靠的传输层协议, 指定端口号, 标识主机上的一个进程
TCP 总共有11个状态分别是:
- LISTEN :等待连接。
- SYN_SENT : 客户端主动发起连接请求(发送 SYN n)。
- SYN_RECV : 服务端接到连接请求后,响应 (发送 ACK n+1,SYN j)。
- ESTABLISHED :连接建立成功(发送 ACK j+1)。
- FIN_WAIT1 : 客户端主动关闭连接,等待对方确认是否可以关闭(发送 FIN x)。
- FIN_WAIT2 : 客户端收到对方确定可以关闭回应(接收到ACK x+1)。
- CLOSE_WAIT : 服务端准备关闭连接时的状态,此时连接还未关闭。
- LAST_ACK : 服务端发送可以关闭的消息(发送FIN y)。
- CLOSING :客户端接到服务器关闭连接的信息,发送连接正式关闭消息(接收到 FIN y,发送ACK y+1)。
- TIME_WAIT : 客户端进入time wait 时间,等2倍MSL 时间后,关闭连接。
- CLOSED :服务端接到客户端正式关闭消息后,关闭连接。(接收到 ACK y+1)
三次握手
- 首先Client端发送连接请求SYN报文
- Server端接受连接后回复ACK报文并同时发送SYN报文,并为这次连接分配资源。
- Client端接收到SYN报文后也向Server段发送ACK报文,并分配资源,
- Server端接收到ACK报文后, TCP连接就建立了,
最主要的目的就是双方确认自己与对方的发送与接收都是正常的
- 客户端状态流转: 启动 —> SYN_SENT —> ESTABLISHED
- 服务器状态流转: LISTEN —> SYN_RECV —> ESTABLISHED
四次挥手
中断连接端可以是Client端,也可以是Server端
- 客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。
- 服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSE_WAIT 状态。
- 客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。
- 等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。
- 客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态
- 服务端收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。
- 客户端在经过 2MSL 一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。(MSL报文最大生存时间)
- 客户端状态流转: ESTABLISHED —> FIN_WAIT1 —> FIN_WAIT2 —> CLOSING —> TIME_WAIT —> CLOSED
- 服务器状态流转: ESTABLISHED —> CLOSE_WAIT —> LAST_ACK —> CLOSED
客户端为什么不直接关闭连接,而是在最后要进入TIME_WAIT,进行等待,等待时间为什么是2倍MSL。
首先先明确一下MSL的定义,MSL是Maximum Segment Lifetime的英文缩写,可译为“最长报文段寿命”,它是任何报文在网络上存在的最长的最长时间,超过这个时间报文将被丢弃。
TIME_WAIT状态存在有2个理由:
- 可靠的实现TCP全双工连接终止。
假设最终的ACK丢失了。服务器会重新发送它最终的那个FIN,因此客户端必须维护状态信息,已允许它重发最终的那个ACK
- 允许老的重复分节在网络上消失
我们关闭这个连接后,马上重新建立一个新连接,这个时候接受到老连接的一个迷途分组或者重复分组到达连接,这时会出现严重错误。TCP为了防止重复分组在连接终止后出现,所以在2倍MSL时间里不能建立新的连接,等到所有的重复分组失效,方可建立新的连接。
为什么连接的时候是三次握手,关闭的时候却是四次握手
- 建立连接时, 因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。
- 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据, 服务端收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
TCP与UDP
TCP |
UDP |
面向连接 |
面向无连接 |
系统资源消耗较多 |
系统资源消耗少 |
程序结构较复杂 |
程序结构简单 |
流式数据包传输 |
报文传递 |
通过回执保证数据准确性 |
不保证数据准确性 |
保证数据顺序 |
不保证数据顺序 |
通讯速度较慢 |
通讯速度较快 |
-
优缺点
TCP 稳定, 安全, 有序, 但效率低, 开销大, 开发复杂度高
UDP 效率高, 开销小, 开发复杂度小, 但稳定性差, 安全性低, 无序
-
场景
TCP 适合对数据传输安全性, 稳定性要求较高的场景, (网络文件储传输, 上传/下载等)
UDP 适合对数据实时传输要求较高的场景, (视频直播, 在线电话会议等)
Tcp如何解决丢包和乱序的问题
发送方发送1,2,3,4,5 一共五份数据,1,2先到了,于是接收方返回一个ack=3,表示接下来想要接受一个序号为3的数据包,但是3由于某些原因丢失了,没发到接收方那里,4到达了,于是接收方继续发送一个ack=3,然后5又到达了,接收方继续发送一个ack=3,于是发送方一共收到3个ack=3,于是马上重发3号数据包,然后接收方接收到了3号数据包,此时由于4,5号数据包也已经被接收了,所以发送ack=6,表示接下来希望接收序号为6的数据包
epoll模型
epoll是一种在Linux系统中用于高效处理大量并发连接的I/O事件通知机制。它具有以下特点:
- 支持高并发:epoll使用事件驱动的方式,能够同时处理大量的并发连接,适用于高并发的网络应用场景。
- 高效的事件通知机制:epoll采用了基于事件驱动的方式,当有事件发生时,内核会将事件通知给应用程序,而不需要应用程序轮询检查事件是否发生,从而减少了系统资源的消耗。
- 支持边缘触发和水平触发:epoll提供了两种工作模式,边缘触发(EPOLLET)和水平触发(EPOLLIN/EPOLLOUT)。边缘触发模式只在状态发生变化时通知应用程序,而水平触发模式则在状态可读或可写时都会通知应用程序。
- 支持多种I/O事件类型:epoll可以同时监控多种I/O事件类型,包括读事件、写事件、错误事件等。
- 高效的内核数据结构:epoll使用红黑树和双向链表等高效的数据结构来管理大量的文件描述符,提高了事件的处理效率。
总之,epoll模型具有高并发、高效的事件通知机制和多种I/O事件类型的支持,适用于处理大量并发连接的网络应用场景。
哪些问题会引起接口性能问题
- 深度分页问题, 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以参数的方式传过来
- 未加索引, 加索引之前确保该字段区分度较高, 离散度好, 另外加索引的 alter 操作, 可能引起表锁, 执行sql时要在低峰期
- 索引失效, (添加索引的字段区分性很差, 索引语句在OR中, LIKE的时候出现类似’%xxx’的语句. 索引发生了隐式变换, 不满足最左前缀规则, where条件里索字段有计算, where条件里索引字段使用了函数)
- join 过多, join关联的表不宜过多, 一般来说2-3张比较合适, 建议从代码层面进行拆分, 在业务层先查询一张表的数据, 然后以关联字段作为条件查询关联表, 然后在业务层进行数据的拼装, 建立正确的索引会比join快, 毕竟内存里拼接数据比网络传输和磁盘IO快很多
- 子查询过多, 一般不建议使用子查询, 可以把子查询改成join来优化
- in 中的值太多, 可以把元素分个组, 每组查一次
- 单纯的数据量过大, 分表或分库+分表
- 循环调用, 这种情况一般都循环调用同一段代码, 每次循环的逻辑一致, 前后不关联, 可以使用多线程或多协程的方式并发或并行进行
- 顺序调用, 一次性的顺序调用, 而且调用之间没有结果上的依赖, 可以使用多线程或多协程的方式并发或并行进行
- 锁类型使用不合理(读写锁: 读是可以共享的, 但是读的时候不能对共享变量写, 而在写的时候, 读写都不能进行), 在可以加读写锁的时候, 如果加成了互斥锁, 那么在读远远多于写的场景下, 效率会极大降低
- 锁过粗, 把锁包裹的范围过大, 则加锁的时间会过长
- 机器问题 (fullGC, 机器重启, 线程打满)
链表与数组区别
线性表: 表中的数据按顺序依次排列,就像用一条线把数据串联起来一样, 数组和链表都是线性表结构
数组在内存中是一串连续的内存空间, 这种结构就决定了数组查询数据速度很快,只需要知道首地址(在栈内存中记录的就是数组的首地址,可以直接获取),再结合寻址公式(i_address = first_address + data_size*i) 就可以很快找到对应元素的地址,从而取出数据, 更准确地说,数组是根据其下标定位元素很快(可以运用寻址公式),进而找到目标元素,而不是查找某一个具体的值很快
数组中各个元素的内存地址都是连续,不间断的,删除某个元素之后需要保证数组仍然是连续的,所以就需要移动数据,比如要删除 array[2],删除之后需要依次将 array[3]、array[4]、array[5] 向前移动一位,同理,如果此时将 0 添加到数组中的第 2 位,即 array[1] 的位置,同样需要先将 array[1] 及其之后的各个元素依次向后移动 1 位,给新数据腾出位置才能添加, 因为要移动元素,所以无论是添加数据还是删除数据,效率都不高
在内存中,链表中的数据是分散的,无须存储在一块连续的内存空间中,链表中存储了 3 个元素分别是 1、2、3,每个元素都有一个指针,指向下一个元素的内存地址,1 的指针就指向 2 的内存地址,2 的指针就指向 3 的内存地址,依次类推。
不同元素之间的物理空间间隔也是不确定的,所以这样的结构就无法通过一个固定的公式来求出某个元素的内存地址,只能从首元素开始依次向后查找,直到找到目标元素。如果目标元素位于链表的最后一位,则需要遍历整个链表才能找到它,效率很低。
同样,正是因为这样的结构,使得链表添加和删除元素效率很高,无须移动其他已存在的元素,只需要修改元素指针即可。比如,删除 2,则只需要将 1 的指针指向 3 即可, 添加元素也是一样,要在 2 和 3 之间添加元素 0 ,只需要随机分配一块空间存储 0,然后将 2 的指针指向 0,0 的指针指向 3 即可
链表和数组的区别:
-
物理存储结构不同
链表是顺序存储结构,数组是链式存储结构
-
内存分配方式不同
数组采用静态分配,链表采用动态分配
-
元素的存取方式不同
数组直接存取,链表需要遍历链表
-
元素的插入和删除方式不同
数组插入和删除时需移动元素,链表插入和删除时无需移动元素
跨站请求伪造(CSRF)
CSRF(Cross-site Request Forgery,跨站请求伪造)攻击是一种常见的Web攻击,它利用用户在登录某个网站后的有效 session 来发送恶意请求。攻击者通过引导用户访问恶意网站,将用户的数据提交到目标网站,欺骗目标网站相信该请求是用户发送的。
CSRF攻击的关键是攻击者可以在不知情的情况下利用用户当前已登录的Web应用程序身份进行操作,而简单的身份验证只能证明请求是来自某个特定的用户,但无法证明请求是该用户自愿发出的,这是CSRF攻击能够成功的因素之一。
XSS攻击主要是通过向目标网站中注入恶意的脚本代码,CSRF 利用用户对该网站的信任来获取用户数据。
预防
- 随机化Token(CSRF Token):Token是用于验证网站请求者身份的一种机制,可以防止CSRF攻击。该Token会在每次访问页面时刷新,以确保每次请求都需要新的Token。例如,在web应用程序中,可以通过hidden field的方式将Token加入到表单中,提交时验
- Referer检查:在服务端校验请求头中的Referer字段,确保请求是来自合法的来源页面,常用于辅助Token机制的验证
MySQL
MySQL事务
ACID 特性
- 原子性(Atomicity):事务是一个不可分割的单位,一个事务里的所有操作属于一个整体, 要么全部生效,要么全部不生效。由 undo log 日志保证, 它记录了需要回滚的日志信息, 事务回滚时撤销已经执行成功的SQL
- 一致性(Consistency):数据库总是从一个一致性状态转换到另一个一致性状态, 事务开始前和结束后, 数据库的完整性没有被破坏, 事务执行后的实际结果与预期结果一样
- 隔离性(Isolation):事务并发执行时,各个事务之间相互影响的程度
- 持久化(Durability):事务操作的结果是不会丢失的, 永久保存, 由内存和 redo log 来保证, MySQL修改数据同时在内存和 redo log 日志记录这次操作, 宕机时可以从 redo log 恢复. InnoDB redo log 写盘, InnoDB 进入 prepare 状态, 如果前面 prepare 成功, bin log 写盘, 将事务日志持久化到 bin log, 如果持久化成功, 那么 InnoDB 事务则进入 commit 状态 (在 redo log 中写一个 commit 记录). redo log 的刷盘会在系统空闲时进行
MySQL 的默认提交事务是指在执行单条SQL语句时, 是否自动将其作为一个独立的事务提交给数据库, 从而决定了对数据库的修改操作是否立即生效并永久保存到数据库中
隔离级别
- 未提交读: 事务读取的数据可能是另一个事务已修改但还没提交的,这部分数据有可能产生回滚。导致后续的操作依赖了无效的数据, 会出现所谓的脏读问题
- 已提交读: 如果想防止脏读,就需要等待其他事务提交后再进行读取操作。
- 可重复读: 已提交读的隔离级别考虑到了数据回滚的无效性,却无法阻止事务的多次提交。比如事务 A 不断的对表进行修改提交,那么事务 B 就会在不同的时间点读取到不同的数据。为了让事务 B 在执行期间读取的数据都是一致的,就有了可重复读的隔离级别,即事务 B 在执行期间,其他事务不得进行修改操作。
- 可串行化: 上面的可重复读隔离级别保证了事务执行期间读取的一致性。然而这里并不包括插入、删除操作。即会出现读多读少数据的情况,这种现象叫做幻读。为了解决幻读,只得进行串行化执行事务,才能互不影响。而此时的事务并发性是最低的
幻读和不可重复读的侧重点不同: 不可重复读侧重于数据修改, 两次读取到的同一行数据不一样, 而幻读侧重于添加或删除, 两次查询返回的数据行数不同
索引
Mysql 的索引分类
- 从数据结构划分: B+ 树、hash 索引、全文索引
- 从物理结构划分: 聚集索引、非聚集索引
- 从逻辑用户划分: 主键、唯一索引、复合索引、普通单列索引
聚集索引、非聚集索引、主建的区别
- 聚集索引
将数据存储与索引放到了一块, 并且是按照一定的顺序组织的, 找到索引也就找到了数据, 数据的物理存放顺序与索引顺序是一致的, 即只要索引是相邻的, 那么对应的数据也是相邻的存储在磁盘上, 在InnoDB引擎里面, 一张表的数据对应的物理文件本身就是按照B+树来组织的, 而聚集索引就是按照每张表的主键来构建一颗B+树, 叶子节点存储了这个表的每一行数据记录, InnoDB里面一张表里只能存在一个聚集索引
- 查询通过聚簇索引可以直接获取数据, 相比非聚簇索引需要第二次查询(非覆盖索引情况下) 效率更高
- 聚簇索引对于范围查询的效率更高, 因为其数据是按照大小排列的
- 聚簇索引适合用在排序的场合, 非聚簇索引不合适
- 非聚集索引
叶子节点不存储数据, 存储的是数据行地址, 也就是说根据索引查找到数据行的位置再去磁盘获取数据, 除了主键索引以外的其他索引, 称为非聚集索引, 也叫二级索引,若基于非聚集索引来查询一条完整的记录, 最终还是需要访问主键索引来检索, 即需要跳转查找。排序规则是逻辑排序,因此可以有多个非聚集索引存在。
- 主建
唯一标识某行记录,不允许有 null 的数据,要求数据必须唯一。在设置某个字段为主建时,数据库一般会自动在这个主建上建立一个唯一索引,并且如果之前表没有创建过聚集索引,还会在这个主建上建立一个聚集索引。InnoDB每个表中一定有主键, 主键一定是聚簇索引, 不手动设置则会使用unique索引, 没有unique索引, InnoDB会默认选择或添加一个隐藏列作为主键索引来存储这个表的数据行, 一般情况建议使用自增id作为主键, 因为id本身具有连续性使得对应的数据也会按照顺序存储在磁盘上, 写入性能和检索性能都很高, 若使用uuid这种随机id, 那么在频繁插入数据时, 就会导致索引树不断变化, 频繁进行磁盘IO, 从而导致性能较低
索引分为主键索引和非主键索引, 如果一条SQL语句操作了主键索引, MySQL就会锁定这条主键索引, 如果一条SQL语句操作了非主键索引, MySQL会先锁定该主键索引, 再锁定相关的主键索引
有哪些情况会让索引失效
- 在 where 字段 上使用了函数或其他隐式转换
- Like 模糊查询,开头使用了 “%”,例如 like ‘%hello%’
- where 条件里使用了 or
- or 条件中只要出现了非索引列,将会全表扫描
- or 单条件只有主键的话,or 也是会走主键索引的, 该案例同样适用于普通索引(这里的普通索引并非联合索引,联合索引的话只能是使用联合索引中的第一个字段作为单条件)
- or 条件中是联合索引的话,即使是联合索引所有的字段都进行条件查询,依旧会进行全表扫描
- or 条件使用了联合索引中非首字段进行条件查询,会全表扫描
- or 条件为主键(或者其他普通索引)以及联合索引的首个字段,会使用到主键索引以及联合索引
在使用or进行条件连接时,只有所有的条件字段都建立了索引时才会使用索引,否则全表扫描,这个索引包括主键以及联合索引,但是要注意的是,联合索引只能是首个字段作为条件,当使用联合索引中的其他字段作为条件进行or连接时,就会出现全表扫描。
- 建立了复合索引,但 where 条件里使用的是第二个字段的搜索
最左匹配原则是指
mysql 建立联合索引后,是按最左匹配原则来筛选记录的,即检索数据是从联合索引的最左边第一个字段开始匹配, 会一直向右匹配直到遇到范围查询 (<, >, between, like) 就停止匹配了。
如果 where 里的条件只有第二个字段,那么将无法应用到索引。
MySQL创建联合索引的规则是根据索引最左边的字段进行排序, 在第一个字段排序的基础上再进行第二个字段排序, 类似 order by col1,col2… 所以第一个字段是绝对有序的, 第二个字段就是无序的了, MySQL强调最左前缀匹配
索引的底层数据结构 B+ 树是怎么样的
- B+树 只在叶子节点存储具体的数据或者数据的指向指针,而非叶子节点存放索引数据。这样可以压缩树的高度, 降低磁盘 IO,还能充分利用磁盘的预读功能,做一次磁盘IO时, 页(page 16kb)空间可获得更多有效的索引值, 查询更快
- B树 (b-tree) 是在非叶子节点存放了数据,在查询索引时,只要找到索引值也就可以找到数据了,这样可以提前终止搜索。但每个节点就得存储索引值+数据值,占用的页空间会比较大,需要的磁盘 IO 次数也会变多,即使是不需要关心的数据也会被预加载出来,浪费性能
B+ 树优势
- 单一节点存储更多的元素,使得查询的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 里行锁依赖索引实现,如果没有索引存在,则会直接进行表锁!
- 记录锁:只锁住某一条记录。当对唯一索引(包括主键)进行精确查询时,会使用记录锁。
- 间隙锁:当使用范围查询时,会对符合条件的区间数据上锁。在涉及到普通索引(即不是唯一索引)的查询时,都会使用间隙锁。
- Next-key 锁:临建锁,可以理解为 记录锁 + 间隙锁。当对唯一索引进行范围查找或对唯一索引进行查找但结果不存在时(可以理解为锁住不存在的记录),会使用临建锁。
上面的间隙锁、临建锁有效的防止了事务幻读情况产生,避免了在查找期间有数据新增或删除。
MySQL锁是加在索引记录上面的, 且行锁加锁的基本单位是next-key lock,只是会在不同的场景会退化为记录锁或者间隙锁
- 唯一索引等值查询
记录存在,next-key lock退化为记录锁
记录不存在,next-key lock退化为间隙锁
- 非唯一索引等值查询
记录存在,除了next-key lock锁外,还会向后一个区间加上间隙锁,一共两把锁
记录不存在,只会加上next-key lock锁,再退化为间隙锁,只有一把锁
- 范围查找
唯一索引范围查找,如果查找的条件存在,则会有一条记录锁,然后会给后面的范围加上next-key lock(某些条件下会退化为间隙锁)
非唯一索引范围查找,next-key lock 不会退化为记录锁或者间隙锁
锁是在遍历索引的时候加上的,并不是针对输出结果加锁。因此当在线上执行update、delete、select…for update等有加锁性质的语句,需要判断语句是否走索引,如果是全表扫描的话,会对每一个索引加next-key lock,等于把整个表锁住了
事务里锁的应用是怎么样的
InnoDB 行锁是通过给索引项加锁实现的, 如果不通过索引条件检索数据, 那么 InnoDB 将对表中所有的数据加锁, 实际效果跟表锁一样
-
可重复读
可重复读使用的是 MVCC 快照,所以在读取数据时大多数时候不需要使用锁。
但使用了 UPDATE, DELETE,或 SELECT with FOR UPDATE(排它锁) 或 LOCK IN SHARE MODE(共享锁),则会根据下面的情况来使用锁:
在唯一索引上精确查找某条记录时,使用记录锁, 即将该条记录锁住, 其他事务无法再对该条数据进行修改, 直到该事务结束
对于其他的搜索,InnoDB 将会锁定扫描到的索引范围,使用间隙锁或临建锁来防止幻读的产生
-
已提交读
它保证每个事务只能读取到已经提交的数据,从而避免了脏读的问题。读提交级别在并发环境下提供了更高的数据一致性,但仍可能导致不可重复读的问题。
也是使用 MVCC 机制来读取数据,不过在使用 UPDATE, DELETE,或 SELECT with FOR UPDATE(排它锁) 或 LOCK IN SHARE MODE(共享锁)时和上面的机制不一样,当存储引擎将筛选到的记录交给 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 即多版本并发控制,读取数据时通过一种类似快照的方式将数据保存下来, 不同的事务会看到自己特定版本的数据。MVCC 不需要加锁的,它能提高事务的并发处理能力。
当进行读操作时,如果有其他写操作的事务并发进行,那么此时可以根据事务的隔离级别选择读取最新版本亦或自己之前版本的数据。
MVCC 只在 RC 和 RR 两个隔离级别下工作, 其他两个隔离级别与 MVCC 不兼容, 因为 RU 总是读取最新的数据行而不是符合当前版本的数据行 (读到未提交事务的变更数据), 串行化会对所有读取的数据行都加锁
聚簇索引记录中有两个隐藏列:
- trx_id: 用来存储每次对某条聚簇索引记录进行修改时候的事务ID
- roll_pointer: 每次对哪条聚簇索引记录有修改的时候, 都会把老版本写入 undo log 日志中, 这个 roll_pointer 就是存了一个指针, 它指向这条聚簇索引记录的上一个版本的位置, 通过它来获取上一个版本的信息 (插入操作的 undo log 日志没有这个属性, 因为其没有老版本)
已提交读和可重复读的区别就在于它们生成 ReadView 的策略不同
- 开始事务时创建 ReadView, ReadView 维护当前活动的事务 id, 即未提交的事务 id, 排序生成一个数组
- 访问数据, 获取数据中的事务 id (获取的是事务 id 最大的记录), 对比 ReadView
- 如果在 ReadView 的左边 (比 ReadView 都小), 可以访问 (在左边意味着该事务已经提交), 如果在 ReadView 的右边 (比 ReadView 都大) 或者就在 ReadView 中, 不可以访问, 获取 roll_pointer, 取上一版本重新比对 (在右边意味着, 意味着在 ReadView 生成之后出现, 在 ReadView 中意味该事务还未提交)
已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的 ReadView, 而可重复度隔离级别则在第一次读的时候生成一个 ReadView, 之后的读都复用之前的 ReadView
这就是 MySQL 的 MVCC, 通过版本链实现多版本, 可并发读写, 通过 ReadView 生成策略的不同实现不同的隔离级别
并发事务下先查询再修改问题
程序编码时
- 在事务中若是先查询,然后在查询出的值上做判断或者操作加减,最后再将结果写入数据库,此时并发下会有问题,因为并发多事务中查询的值不是最新的(可重复读),此时即使因为事物特性更新操作会阻塞等待,但写入时的值已经错误
解决方案是锁定再更新,即先查询时加读锁,避免其他事务修改, 其他事务会阻塞
- 更新最好不要加减后将值写入,而是在更新上直接操作计算,即 update table set money=money-num where …,此时多事务下会阻塞等待,update操作会获取最新的值, 并且在一些数值字段上设置无符号,扣减小于0会报错,或者更新条件加上 where money-num>0,不满足该条件则更新不执行,影响行数为0
mysql 的主从复制
整体上来说,复制有3个步骤:
- master log dump 线程, 主从复制的基础是主库记录数据库的所有变更记录到 bin log (bin log 是数据库服务器启动的那一刻起, 保存所有修改数据库结构或内容的一个文件), 主节点 log dump 线程, 当 bin log 有变动时, log dump 线程读取其内容并发送给从节点
- slave I/O 线程, 从节点 I/O 线程接收 binlog 内容, 将将其写入到它的中继日志(relay log);
- slave SQL 线程, 从节点的 SQL 线程读取 relay log 文件内容, 对数据更改进行重做
主从节点使用 bin log 文件+ position 偏移量来定位主从同步的位置, 从节点会保存其已经接收到的偏移量, 如果从节点发生宕机重启, 则会自动从 position 的位置发起同步
复制方式:
- 全同步复制
当向主库写数据时, 只有等所有的 slave 节点将同步的bin log日志写入 relay log,并且响应 ack 确认后,此次的事务才会提交, 然后返回客户端。数据完整性高,但性能低
- 半同步复制
当向主库写数据时, 只要有一个 salve 节点响应 ack 后就可以认为同步成功,但细分为了两种,一种是 AFTER_COMMIT:先在主库提交事务, 然后同步从库, 等待从库的 ack 确认才告诉客户端是否 Ok。另一种是 AFTER_SYNC:主库先不提交事务, 只有从库有 replay log ,回复了 ack 后才进行提交事务。后面一种数据一致性较高
- 异步复制 (默认)
当向主库写数据时, 立刻返回客户端, 即一旦有需要复制的就通知 slave, 但不会等待确认成功才进行后续操作。
数据库的读写分离
读写分离的基本原理就是让主数据库处理事务性增, 改, 删操作(INSERT, UPDATE, DELETE), 而从数据库处理SELECT查询操作, 数据库复制被用来把事务性操作导致的变更同步到其他从数据库, 主库负责写数据和读数据, 从库仅负责读数据
- 实现负载均衡, 将读操作和写操作分离到不同的数据库上, 避免主服务器出现性能瓶颈
- 主服务器进行写操作时, 不影响查询应用服务器的查询性能, 降低阻塞, 提高并发
- 数据拥有多个备份, 提高数据安全性, 同时当主服务器故障时, 可立即切换到其他服务器, 提高系统可用性
存储引擎
InnoDB
它是 mysql 的默认存储引擎,能够实现 ACID 特性的事务,并且能提交、回滚、恢复数据,能很好的保障用户数据。同时支持了行级锁、聚集索引以及外键约束,是一个完善的存储引擎。
- 支持事务,行锁,外键,聚集索引和非聚集索引
- 不支持全文索引
在并发事务里, 每一个事务中的增删改操作相当于加上了行锁, 若其他事务中也对该记录进行修改, 则会阻塞
MyISAM
是 mysql 最开始的存储引擎,占用空间小,能快速存储,但不支持事务,提供了基于表级别的锁粒度,适用于配置或只读功能的应用程序。
- 支持全文索引,支持非聚集索引
- 不支持事务,行锁,外键
Mysql 的三层架构
连接层: 主要负责连接池、通信协议、认证授权等;
SQL 层: 这一层是 mysql 的大脑,通过一系列组件得到数据操作的最优解。
存储层: 负责数据的存储、检索。
执行计划是什么?怎么看
执行计划是 mysql 根据我们的查询语句进行一系列的分析后得到的优化方案。我们可以通过执行计划来获取执行过程。
explain select 语句, 主要看三个字段 type, key, Extra
执行计划的 type 为index_merge,意为索引合并,就是说对多个索引分别进行条件扫描,然后将它们各自的结果进行合并
type 中的属性效率 all < index < range < ref < ref_eq < const < system
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
- 未建索引
创建合适的索引, 让查询尽可能走索引树, 避免全表扫描
- 索引失效
- 对经常出现在 where 条件里,并且数据重复率不高的字段建立索引
- 能使用 in 就不使用 or,前者能命中索引,后者会让索引失效
- 避免在 where 字段上计算,例如 where a / 3 = 1,这样会让索引失效
- 使用 like 匹配时, %写到最右
- 避免在 where 字段上使用 NULL 值的判断
- 避免对字段进行数据类型转化, 会让索引失效 (在MySQL中, 某些查询会做类型转换, ‘123’=>123, ‘a’=> 0, ‘bcd’ => 0)
- 子查询过多
用JOIN代替子查询, 但JOIN也不宜关联太多表
- 打开慢查询日志配置,有针对性的分析响应缓慢的语句
慢查询该如何优化
- 检查是否走了索引, 如果没有则优化SQL利用索引
- 检查所利用的索引, 是否是最优索引
- 检查所查字段是否都是必须的, 是否查询了过多字段, 查出了多余数据
- 检查表中数据是否过多, 是否应该要进行分库分表了
- 检查数据库实例所在的机器的性能配置, 是否太低, 是否增加资源
分布式事务
两阶段提交 (2PC)
- 概念
参与者将操作成败通知协调者, 再由协调者根据所有参与者的反馈情况, 决定各参与者是否要提交操作或中止操作
- 准备阶段
事务协调者给每个参与者发送Prepare消息, 每个参与者要么直接返回失败, 要么在本地执行事务, 写本地的undo和redo日志, 但不提交
- 执行阶段
如果协调者收到了参与者的失败消息或者超时, 直接给每个参与者发送回滚(Rollback)消息, 否则发送提交(Commit)消息, 然后再释放资源
- 同步阻塞
执行过程中, 所有参与节点都是事务阻塞型, 只执行sql, 但不提交, 并且占用数据库连接资源
- 单点故障
由于协调者的重要性, 一旦协调者出现故障, 参与者会一直阻塞下去
- 数据不一致
当协调者向参与者发送commit请求后, 由于网络原因或者协调者故障, 可能导致只有一部分参与者收到了commit请求然后执行commit操作, 只能通过手动或脚本补偿的方式来处理数据的不一致
三阶段提交 (3PC)
- can commit
协调者向参与者发送 can commit请求, 询问是否可以执行事务提交操作, 然后等待参与者的响应, 参与者如果可以提交就返回YES响应, 进入预备状态, 否则返回NO响应
- pre cpmmit
协调者根据参与者的反馈情况来决定是否可以进行事务的pre commit操作, 假如协调者从所有的参与者获得的反馈都是YES, 那么就会执行事务的预执行, 假如有任何一个参与者向协调者返回了NO响应, 或者等待超时之后协调者没有收到参与者的响应, 那么协调者就执行事务的中断
- do commit
该阶段执行真正的事务提交, 执行提交或中断事务
2PC和3PC的区别
- 3PC比2PC多了一个can commit阶段, 减少了不必要的资源浪费, 2PC在第一阶段会占用资源, 而3PC在这个阶段不占用资源, 只是校验一下sql, 如果不能执行就直接返回, 减少资源占用
- 引入超时机制
2PC只有协调者有超时机制, 超时后, 发送回滚指令
3PC中协调者和参与者都有超时机制
协调者超时
can commit, pre commit中, 如果超时收不到参与者的反馈, 则协调者向参与者
发送中断指令
参与者超时: pre commit 阶段, 参与者超时收不到指令会进行中断, do commit阶段, 参与者超时收不到指令会进行提交
TCC
将一个事务拆分为三个步骤
- Try
主要进行业务校验和检查或预留资源, 也可能直接进行业务操作 (数据直接落库)
- Confirm
业务确认阶段, 对Try校验过的业务或预留的资源进行确认, 空或者做一些事
- Cancel
业务回滚阶段, 与Confirm互斥, 用于释放Try预留的资源或业务, 前两个阶段的逆sql操作
消息队列+本地事件表
- 接收到请求, 执行自己具体的业务
- 插入自己的事件表 (业务类型, 事件状态)
- 返回请求
- 后台启动的定时任务, 去查询事件表是否有新事件
- 发送消息到MQ中
- 修改事件表中的状态
- 消费者监听MQ的消息
- 插入自己的事件表
- 返回MQ响应, 表示消息已消费
- 后台启动的定时任务, 查询事件表
- 处理自己的业务
- 修改事件表的状态
可靠消息服务
当事务的发起方(事务参与者, 消息发送者)执行完本地事务后, 同时发出一条消息, 事务参与方(事务参与者, 消息的消费者)一定能够接受消息并可以成功处理自己的事务
- 服务A, 收到请求, 发送数据到可靠消息服务
- 可靠消息服务, 接收到数据, 保存到本地事务表
- 可靠消息服务, 返回给服务A成功或失败
- 服务A, 接收到成功返回后, 执行本地业务, 保存数据库
- 服务A, 发送确认或取消给可靠消息服务
- 可靠消息服务, 修改事件表的消息状态
- 可靠消息服务, 将消息发送到MQ
- 可靠消息服务, 返回给服务A成功或失败
- 服务B, 消费MQ中的消息
- 服务B, 执行本地事务
- 服务B, 返回MQ
- 服务B, 返回可靠消息服务
- 可靠消息服务, 修改事件状态
分布式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容器的源代码,Docker镜像用于创建容器,使用Build命令创建镜像
什么是Docker容器
Docker容器包括应用程序及所有的依赖项,作为操作系统的独立进程运行
Docker容器有几种状态
运行、已停止、重新启动、已退出
DockerFile中最常见的指定是什么
指令 |
解释 |
FROM |
指定基础镜像,即当前镜像是基于哪个镜像 |
ADD |
将构建的主机的文件复制到镜像(镜像源可以是文件、目录、归档文件或远程URL) |
COPY |
拷贝文件/目录到镜像中。类似于ADD,不过仅用于文件和目录。 |
CMD |
设置在容器中执行的默认命令 |
MAINTAINER |
指定作者 |
RUN |
指定构建过程中的要运行的命令 |
ENV |
设置环境变量 |
WORKDIR |
指定默认的工作目录,即进入容器后默认的目录 |
VOLUME |
创建挂载点,用于共享和持久化 |
ENTRYPOINT |
指定容器要运行的命令,与CMD有区别 |
USER |
设置运行容器和后续构建指令要使用的账户名 |
EXPOSE |
指定对外暴露的端口 |
Dockerfile中的命令COPY和ADD命令有什么区别
COPY和ADD的区别是COPY的SRC只能是本地文件,其他用法一致
Dockerfile文件的压缩技巧
- 选择合适的基础镜像:选择一个适合的基础镜像可以减小镜像的大小
- 最小化安装依赖项:只安装应用程序所需的依赖项,避免安装不必要的软件包, 可以使用–no-cache-dir选项来避免缓存软件包
- 删除不必要的文件和目录:在构建镜像时,删除不需要的文件和目录,以减小镜像的体积
- 使用多阶段构建(Multi-stage Builds):使用多阶段构建可以减小最终镜像的大小。在第一个阶段中,可以使用完整的开发环境来构建应用程序,然后在下一个阶段中只复制构建好的应用程序文件,避免将构建工具和依赖项带入最终镜像中。
- 压缩文件和目录:在构建镜像时,可以使用压缩工具来压缩文件和目录,以减小镜像的大小。可以使用tar命令将文件和目录压缩为归档文件,并在Dockerfile中进行解压缩
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
MQ产品选型
Kafka
- 吞吐量非常大, 性能非常好, 集群高可用, 生产场景有大规模使用场景, 吞吐量单机百万级
- 会丢数据, 功能比较单一, 单机容量有限(单机超过64个分区, 响应明显变长)
- 适合数据量比较大且频繁, 但允许数据有小部分丢失的场景, 如: 日志分析, 大数据采集
- , 效率在毫秒级别
RabbitMQ
- 消息可靠性高, 功能全面, 性能好, 高并发, 效率在微秒级别
- 吞吐量比较低, 吞吐量单机都在万级, 消息积累会严重影响性能, erlang语言不好定制
- 适合小规模场景
RocketMQ
- 高吞吐, 高性能, 高可用, 功能非常全面, 消息可以做到0丢失
- 客户端只支持java
- 吞吐量单机十万级, 效率在毫秒级别
- 适合互联网金融领域 (对消息的吞吐量和可靠性都要求较高)
选择partition的原则
生产者发送消息时, 先连接kafka, 然后从zookeeper中获取broker和partition信息, 以及leader信息
- partition在写入的时候可以指定需要写入的partition, 如果有指定可以写入对应的partition
- 若没有指定partition, 但是设置了数据的key, 则会根据key的值hash出一个partition
- 若既没有指定partition, 又没有设置key, 则会采用轮询方式, 即每次取一小段时间的数据写入某个partition, 下一小段的时间写入下一个partition
生产者发送消息到kafka的流程
- 生产者从kafka集群获取分区leader信息
- 生产者将消息发送给leader
- leader将消息写入本地磁盘
- follower都会监听leader的数据变更, 然后主动从leader拉取新的消息
- follower将消息写入本地磁盘后向leader发送ACK
- 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给生产者)
消息发送的缓冲区
- kafka默认会创建一个消息缓冲区, 用来要存放发送的消息, 缓冲区是32MB
- kafka本地线程会去缓冲区中一次拉取16KB的数据, 发送到broker
- 若线程拉取的数据未满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机制, 会重新调整消费者消费哪个分区
- consumer group 中的成员个数发生变化
- consumer 消费超时
- group 订阅的 topic 个数发生变化
- 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消息高可靠解决方案(保证消息不丢失)
- 使用同步发送
- ack应答机制: 0 发送完直接返回, 不管是否发送成功, 1 leader 写入成功就返回, 未同步到follower, 若leader故障则消息丢失, all/-1 等待ISR(跟leader保持同步的所有follower节点)同步完再返回, 设置为1或all
- unclean.leader.election.enable: false, 禁止选举ISR以外的follower为leader
- tries > 1, 重试次数
- 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的消费能力, 可能导致拒绝服务或者网络拥塞等情况
设计微服务时遵循的原则
软件是为业务服务的, 好的系统不是设计出来的, 而是进化出来的
- 单一职责
让每个服务能独立, 有界限的工作, 每个服务只关注自己的业务, 做到高内聚
- 服务自治
每个服务要做到独立开发, 独立测试, 独立构建, 独立部署, 独立运行, 与其他服务进行解耦
- 轻量级通信
让每个服务之间的调用是轻量级, 并且能够跨平台, 跨语言, 比如采用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
概念
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实现一致性通信