面试知识准备
Java
集合框架
ArrayList
是基于动态数组实现的 List
接口的可变长度集合类,适合频繁读取、较少修改的场景
本质上是一个 Object 数组
- 默认初始容量为 10
- 元素顺序按插入顺序保存,允许重复元素和
null
- 每次扩容都会拷贝整个旧数组到新数组
grow()
- 由于数组支持随机访问,
get()
方法时间复杂度是 O(1) - 删除后会将后面所有元素向前移动一位,时间复杂度 O(n)
LinkedList
是基于双向链表实现的 List
接口实现类,也实现了 Deque
接口,因此它可以作为栈、队列或双端队列使用
1 | private static class Node<E> { |
由 first
(头节点)和 last
(尾节点)两个引用串联整个链表
add(E e)
:内部使用linkLast()
将元素插入尾部get(int index)
:为了加快访问,LinkedList
会根据index
判断从头还是尾遍历(根据size()
)remove(int index)
:删除操作是通过修改指针实现的,不需要移动其他元素(**O(n)**(查找) + O(1)(删除))
HashSet
基于哈希表实现的集合,主要用来存储不重复的元素,且对元素的存取速度非常快,但不保证元素的顺序
HashSet
内部是靠一个HashMap
来实现的HashSet
中的元素作为HashMap
的 key,value 是一个固定的常量对象HashMap
通过元素的hashCode()
定位元素所在的桶(数组索引),通过equals()
判断元素是否相同- 扩容机制:默认初始容量(数组大小)是 16;默认加载因子(load factor)是 0.75
- 扩容时,HashMap 会把底层数组的大小翻倍。重新计算每个元素的索引,将元素重新分布到新数组中
LinkedHashSet
是 HashSet
的子类,除了保证元素唯一外,还能保证元素的插入顺序
- 在
HashMap
的基础上,维护了一条双向链表,记录元素的插入顺序 - 添加元素时,除了像
HashSet
一样放到对应桶,还会把元素串到双向链表尾部。迭代时,根据双向链表顺序返回元素
TreeSet
是基于红黑树实现的有序集合,保证元素按照自然顺序或自定义排序顺序排列
- 底层使用
TreeMap
(红黑树实现),元素必须实现Comparable
接口或传入Comparator
比较器 - 插入、删除、查找时间复杂度是 O(log n)
- 自平衡的二叉搜索树
HashMap
是基于哈希表实现的键值对集合,数组 + 链表/红黑树 结合的数据结构
底层维护一个数组(称为“桶数组”,默认大小16)
每个数组元素是链表或红黑树的头节点
同一个桶内通过链表或红黑树存储多个哈希冲突的节点
如果链表长度超过阈值(默认8),将链表转换成红黑树以提高查找效率
LinkedHashMap
继承自 HashMap
,保持了插入顺序或访问顺序,底层维护了一个双向链表
- 可以通过重写
removeEldestEntry()
实现缓存(如 LRU 缓存),自动删除最旧条目
TreeMap
基于红黑树实现的有序键值对集合,键必须实现 Comparable
接口或传入 Comparator
- 红黑树,每个节点包含键值对
PriorityQueue
是一个 优先队列,即元素出队的顺序不是插入顺序,而是优先级顺序(默认自然顺序,可传 Comparator 自定义)
- 使用 最小堆(二叉堆) 实现,底层是 数组
- 初始容量默认 11,扩容为原容量的 1.5 倍
ArrayDeque
是一个 基于数组的双端队列,支持栈、队列、双端队列操作,效率比 LinkedList
高(无链表节点开销)
- 底层是一个 循环数组
- 初始容量为 16,每次满时容量扩大为 原容量的 2 倍
Go
GMP模型
在多进程/多线程的操作系统中,就解决了阻塞的问题,因为一个进程阻塞cpu可以立刻切换到其他进程中去执行,而且调度cpu的算法可以保证在运行的进程都可以被分配到cpu的运行时间片。这样从宏观来看,似乎多个进程是在同时被运行
但新的问题就又出现了,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU虽然利用起来了,但如果进程过多,CPU有很大的一部分都被用来进行进程调度了
很明显,CPU调度切换的是进程和线程。尽管线程看起来很美好,但实际上多线程开发设计会变得更加复杂,要考虑很多同步竞争等问题,如锁、竞争冲突等
协程
N:1关系
N个协程绑定1个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1个进程的所有协程都绑定在1个线程上
- 某个程序用不了硬件的多核加速能力
- 一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了
1:1关系
1个协程绑定1个线程,这种最容易实现。协程的调度都由CPU完成了,不存在N:1缺点
- 协程的创建、删除和切换的代价都由CPU完成,有点略显昂贵了
M:N关系
M个协程绑定N个线程,是N:1和1:1类型的结合,克服了以上2种模型的缺点,但实现起来最为复杂
协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程
goroutine
Go为了提供更容易使用的并发方法,使用了goroutine和channel。goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上
Go中,协程被称为goroutine,它非常轻量,一个goroutine只占几KB,并且这几KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发。虽然一个goroutine的栈只占几KB,但实际是可伸缩的,如果需要更多内容,runtime
会自动为goroutine分配。
Goroutine特点:
- 占用内存更小(几kb)
- 调度更灵活(runtime调度)
早期goroutine调度器
M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的
缺点:
- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争
- M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M’。
- 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销
GMP模型设计思想
G —— goroutine协程(用户态)
P —— processor处理器
M —— thread(内核)
- 全局队列(Global Queue):存放等待运行的G
- P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列
- P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个
- M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去
P和M的个数问题
1、P的数量:
- 由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行
2、M的数量:
- go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略
- runtime/debug中的SetMaxThreads函数,设置M的最大数量
- 一个M阻塞了,会创建新的M
M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来
P和M何时会被创建
1、P何时创建:在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P
2、M何时创建:没有足够的M来关联P并运行其中的可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M
调度器的设计策略
复用线程:避免频繁的创建、销毁线程,而是对线程的复用
- work stealing机制:当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程
- hand off机制:当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行
利用并行:GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行
抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方
全局G队列:在新的调度器中依然有全局G队列,当P的本地队列为空时,优先从全局队列获取,如果全局队列为空时则通过work stealing机制从其他P的本地队列偷取G
go func() 调度流程
- 我们通过 go func()来创建一个goroutine
- 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中
- G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行
- 一个M调度G执行的过程是一个循环机制
- 当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P
- 当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中
调度器的生命周期
M0:是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了
G0:是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0
1 | package main |
- runtime创建最初的线程m0和goroutine g0,并把2者关联
- 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表
- 示例代码中的main函数是main.main,runtime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列
- 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine
- G拥有栈,M根据G中的栈信息和调度信息设置运行环境
- M运行G
- G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序
runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束
可视化GMP
go tool trace
trace记录了运行时的信息,能提供可视化的Web页面
1 | package main |
1 | go run trace.go |
Debug trace
1 | package main |
1 | go build trace.go |
- SCHED:调试信息输出标志字符串,代表本行是goroutine调度器的输出;
- 0ms:即从程序启动到输出这行日志的时间;
- gomaxprocs: P的数量,本例有2个P, 因为默认的P的属性是和cpu核心数量默认一致,当然也可以通过GOMAXPROCS来设置;
- idleprocs: 处于idle状态的P的数量;通过gomaxprocs和idleprocs的差值,我们就可知道执行go代码的P的数量;
- threads: os threads/M的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量;
- spinningthreads: 处于自旋状态的os thread数量;
- idlethread: 处于idle状态的os thread的数量;
- runqueue=0: Scheduler全局队列中G的数量;
- [0 0]: 分别为2个P的local queue中的G的数量
调度器调度场景分析
G1创建G2
P拥有G1,M1获取P后开始运行G1,G1使用**go func()**创建了G2,为了局部性G2优先加入到P1的本地队列
G执行完毕
G1运行完成后(函数:goexit),M上运行的goroutine切换为G0,G0负责调度时协程的切换(函数:schedule)。从P的本地队列取G2,从G0切换到G2,并开始运行G2(函数:execute)。实现了线程M1的复用
G2开辟过多的G
G2本地满再创建G7
G2在创建G7的时候,发现P1的本地队列已满,需要执行负载均衡(把P1中本地队列中前一半的G,还有新创建G转移到全局队列)
G2本地未满再创建G8
G8加入到P1点本地队列的原因还是因为P1此时在与M1绑定,而G2此时是M1在执行。所以G2创建的新的G会优先放置到自己的M绑定的P上
唤醒正在休眠的M
在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行
假定G2唤醒了M2,M2绑定了P2,并运行G0,但P2本地队列没有G,M2此时为自旋线程(没有G但为运行状态的线程,不断寻找G)
被唤醒的M2从全局队列获取批量G
M2尝试从全局队列(简称“GQ”)取一批G放到P2的本地队列(函数:**findrunnable()**)。M2从全局队列取的G数量符合下面的公式:
n = min(len(GQ) / GOMAXPROCS + 1, len(GQ/2))
1 | // 从全局队列中偷取,调用时必须锁住调度器 |
M2从M1中偷取G
全局队列已经没有G,那m就要执行work stealing(偷取):从其他有G的P哪里偷取一半G过来,放到自己的P本地队列。P2从P1的本地队列尾部取一半的G
自旋线程的最大限制
自旋本质是在运行,线程在运行却没有执行G,就变成了浪费CPU. 为什么不销毁线程,来节约CPU资源。因为创建和销毁CPU也会浪费时间,我们希望当有新goroutine创建时,立刻能有M运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费CPU,所以系统中最多有GOMAXPROCS个自旋的线程,多余的没事做线程会让他们休眠
G发送系统调用/阻塞
假定当前除了M3和M4为自旋线程,还有M5和M6为空闲的线程(没有得到P的绑定,注意我们这里最多就只能够存在4个P,所以P的数量应该永远是M>=P, 大部分都是M在抢占需要运行的P),G8创建了G9,G8进行了阻塞的系统调用,M2和P2立即解绑,P2会执行以下判断:如果P2本地队列有G、全局队列有G或有空闲的M,P2都会立马唤醒1个M和它绑定,否则P2则会加入到空闲P列表,等待M来获取可用的p(为什么不与自旋线程绑定?因为自旋线程已经MP绑定了,无法再绑定P)
G发送系统调用/非阻塞
假如G8进行了非阻塞系统调用
M2和P2会解绑,但M2会记住P2,然后G8和M2进入系统调用状态。当G8和M2退出系统调用时,会尝试获取P2,如果无法获取,则获取空闲的P,如果依然没有,G8会被记为可运行状态,并加入到全局队列,M2因为没有P的绑定而变成休眠状态(长时间休眠等待GC回收销毁)
参考文档
GC回收机制
Go V1.3标记-清除(mark and sweep)算法
暂停程序业务逻辑, 分类出可达和不可达的对象,然后做上标记
开始标记,程序找出它所有可达的对象,并做上标记
标记完了之后,然后开始清除未标记的对象
操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 STW(stop the world),STW的过程中,CPU不执行用户代码,全部用于垃圾回收,这个过程的影响很大,所以STW也是一些回收机制最大的难题和希望优化的点。所以在执行第三步的这段时间,程序会暂定停止任何工作,卡在那等待回收执行完毕
停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束
缺点:
- STW,stop the world;让程序暂停,程序出现卡顿;
- 标记需要扫描整个heap;
- 清除数据会产生heap碎片
Go V1.5三色并发标记法
Golang中的垃圾回收主要应用三色标记法,GC过程和其他用户goroutine可并发运行,但需要一定时间的STW(stop the world)
每次新创建的对象,默认的颜色都是标记为“白色”
每次GC回收开始, 会从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合
本次遍历是一次遍历,非递归形式,是从程序抽次可抵达的对象遍历一层
遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合
重复第三步, 直到灰色中无任何对象
当我们全部的可达对象都遍历完后,灰色标记表将不再存在灰色对象,目前全部内存的数据只有两种颜色,黑色和白色。那么黑色对象就是我们程序逻辑可达(需要的)对象,这些数据是目前支撑程序正常业务运行的,是合法的有用数据,不可删除,白色的对象是全部不可达对象,目前程序逻辑并不依赖他们,那么白色对象就是内存中目前的垃圾数据,需要被清除
回收所有的白色标记表的对象. 也就是回收垃圾
思考:没有STW的三色标记法会出现什么问题?
条件1: 一个白色对象被黑色对象引用**(白色被挂在黑色下)**
条件2::灰色对象与它之间的可达关系的白色对象遭到破坏**(灰色同时丢了该白色)**
如果当以上两个条件同时满足时,就会出现对象丢失现象!
屏障机制
“强 - 弱” 三色不变式
- 强三色不变色实际上是强制性的不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况
- 黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。 这样实则是黑色对象引用白色对象,白色对象处于一个危险被删除的状态,但是上游灰色对象的引用,可以保护该白色对象,使其安全
插入屏障(不在栈)
具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)
满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)
删除屏障
具体操作: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色
满足:弱三色不变式. (保护灰色对象到白色对象的路径不会断)
Go V1.8混合写屏障机制
- GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)
- GC期间,任何在栈上创建的新对象,均为黑色
- 被删除的对象标记为灰色
- 被添加的对象标记为灰色
满足: 变形的弱三色不变式
场景一: 对象被一个堆对象删除引用,成为栈对象的下游
场景二:对象被一个栈对象删除引用,成为另一个栈对象的下游
场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游
场景四:对象从一个栈对象删除引用,成为另一个堆对象的下游
在面向对象这块,go和java有什么区别
1.类与结构体
Java:是一种典型的面向对象语言,所有代码都必须定义在类中。每个对象都是类的实例,支持封装、继承和多态等特性。
Go:没有类的概念,使用
struct
(结构体)来定义数据结构。方法可以绑定到结构体上,但不支持传统的类继承机制。
2.继承与组合
Java:支持单继承,即一个类只能继承一个父类,但可以实现多个接口。通过继承,子类可以复用父类的属性和方法。
Go:不支持继承,提倡通过组合来复用代码。可以在结构体中嵌套其他结构体,实现类似继承的效果,但不会自动继承方法。
3.接口与多态
Java:接口需要显式声明实现关系,类必须使用
implements
关键字来实现接口。多态通过继承和接口实现。Go:采用结构化类型系统,接口的实现是隐式的。只要一个类型实现了接口中定义的所有方法,就被视为实现了该接口,无需显式声明。
4.语言设计理念
Java:强调面向对象的设计,适合构建大型、复杂的企业级应用。
Go:强调简洁性和组合优于继承的原则,适合构建高性能、可维护的系统,如微服务和云原生应用。
c++、go和java有什么区别,优点和缺点
1.语言特性
特性 | C++ | Go | Java |
---|---|---|---|
语言范式 | 多范式(面向对象、泛型、过程式) | 多范式(过程式、并发、结构化) | 面向对象(类和接口) |
编译与运行 | 编译为本地机器码,直接执行 | 编译为本地机器码,快速启动 | 编译为字节码,由 JVM 执行 |
内存管理 | 手动管理,支持指针操作 | 自动垃圾回收,内存安全 | 自动垃圾回收,成熟的内存管理 |
并发模型 | 通过线程和锁机制实现 | 原生支持 Goroutines 和 Channel | 通过线程和同步块实现 |
标准库 | 丰富但复杂,学习曲线陡峭 | 简洁实用,适合快速开发 | 功能全面,适合企业级应用 |
错误处理 | 通过异常机制处理 | 返回值和错误分离,简洁明了 | 通过异常机制处理 |
2.性能与资源消耗
- **C++**:性能最优,适合对资源和速度要求极高的应用,如游戏引擎、操作系统和嵌入式系统。
- Go:启动速度快,内存占用低,适合构建高并发的网络服务和微服务架构。
- Java:通过 JIT 编译和 JVM 优化,性能接近原生代码,适合构建大型企业级应用。
3.开发效率与生态系统
- **C++**:灵活性高,但开发复杂度大,调试和维护成本较高。
- Go:语法简洁,编译速度快,工具链完善,适合快速迭代开发。
- Java:生态系统成熟,拥有丰富的框架和库,适合构建复杂的企业应用。
4.应用场景
- **C++**:适用于对性能和资源控制要求极高的系统级开发,如操作系统、游戏引擎和高频交易系统。
- Go:适用于构建高并发、可扩展的网络服务和微服务架构,特别是在云原生应用中表现出色。
- Java:适用于构建大型、复杂的企业级应用,如电商平台、银行系统和企业资源规划系统。
项目技术总结
微精弘
网络流量切换
背景
为了提升系统在调用外部认证服务(统一验证 OAuth、正方教务系统 ZF)过程中的可用性与鲁棒性,项目设计实现了一套基于断路器模式的网络流量切换机制
该机制实现了对多个 API 接口的高可用访问策略,能够根据接口返回结果自动判断接口是否失效,并动态地切换或恢复流量,提高系统稳定性与容错性
当某一认证服务频繁失败时,自动暂停使用,并定期进行探测,恢复后重新纳入负载均衡池中,避免影响整体服务质量
优势
- 快速失败:避免访问异常接口拖慢整体服务
- 自动恢复:接口恢复后可自动重新启用
- 动态流量切换:可在多个接口之间智能选择最可用者
该机制主要涉及三大组件:
- 负载均衡器(LoadBalance)
- 熔断器状态快照(ApiSnapShot) Closed,Open,HalfOpen
- 存活性探测器(LiveNessProbe)
核心模块
CircuitBreaker 熔断器主控结构
1 | type CircuitBreaker struct { |
- LB:根据接口可用性和类型进行负载均衡
- SnapShot:维护每个接口的调用快照(ApiSnapShot),用于统计失败次数、控制状态切换
方法 | 说明 |
---|---|
Fail(api, loginType) | 接口调用失败计数,失败超限则从负载池中移除并进入 Open 状态 |
GetApi(zfFlag, oauthFlag) | 从可用接口中选出目标接口 |
Success(api, loginType) | 接口调用成功,重置失败计数,切回 Closed |
LoadBalance 负载均衡器
支持两个类型接口:
- ZF(教务系统)
- Oauth(统一认证)
负载策略:随机轮询(randomLB)
通过 Pick() 方法从可用接口列表中随机选出一个
1 | func (lb *LoadBalance) Pick(zfFlag, oauthFlag bool) (string, funnelApi.LoginType, error) |
- 若 ZF 和 Oauth 都可用,随机选择一个
- 若仅一方可用,则返回该类型
- 若全部不可用,抛出错误
ApiSnapShot 接口快照
记录失败/总调用次数与状态
1 | type ApiSnapShot struct { |
- **Fail()**:失败计数 +1,当失败数 > 50 且状态为 Closed,切换为 Open
- **Success()**:重置错误计数并切换回 Closed 状态
LiveNessProbe 存活性探测器
失效接口被熔断后,会被移出负载池并加入 LiveNessProbe。定时轮询检测其是否恢复:
1 | func (l *LiveNessProbe) Start(ctx context.Context) |
- 通过构造表单请求目标接口
- 若返回状态码为 200、412、416,则视为恢复成功
- 成功后重新加入负载池,更新熔断状态为 Closed
流程
1 | 调用接口 |
微信小程序登录流程
1. 小程序端获取临时登录凭证(code)
小程序调用 wx.login()
方法,获取临时登录凭证 code
。该 code
有效期为5分钟,且每次调用都会生成新的 code
。此凭证仅能使用一次。
2. 将 code 发送至开发者服务器
小程序通过 wx.request()
将获取到的 code
发送给开发者服务器。服务器需配置在微信公众平台的“开发设置”中的“request合法域名”中,以确保请求合法性。
3. 开发者服务器调用微信接口,换取用户标识
服务器使用 appid
、appSecret
和接收到的 code
,调用微信提供的 auth.code2Session
接口,获取用户的 openid
(用户唯一标识)和 session_key
(会话密钥)。
4. 生成并返回自定义登录态(token)
服务器根据获取到的 openid
和 session_key
,生成自定义的登录态标识(如 token
),并返回给小程序。小程序将该 token
保存在本地(如使用 wx.setStorageSync()
),用于后续请求中验证用户身份
如果要拓展到两个及以上单体服务,token怎么在多个服务中使用
1.通过 API 网关统一验证和转发
将 token
附加在请求头中发送给 API 网关,验证通过后,API 网关将请求转发给相应的后端服务。
2.后端服务独立验证 token
每个后端服务都具备验证 token
的能力,通过共享的公钥来验证签名。
3.服务间使用用户上下文传递
单体服务更新项目程序,会有短暂时间真空期,怎么解决
1.蓝绿部署
准备新环境:在绿色环境中部署新版本的应用程序。
切换流量:将用户流量从蓝色环境切换到绿色环境。(负载均衡,DNS,云服务平台,灰度发布)
验证和回滚:如果新版本运行正常,保留绿色环境;如果出现问题,快速切换回蓝色环境。
2.滚动更新
逐个更新实例:依次将每个实例替换为新版本。
保持服务可用:在更新过程中,其他实例继续处理请求,确保服务不中断。
3.灰度发布
小范围测试:将新版本部署给一部分用户,观察其表现。
逐步扩展:如果新版本稳定,逐步扩大其覆盖范围,最终完全替代旧版本。
4.负载均衡或反向代理
流量控制:在部署新版本时,将流量引导到健康的实例。
健康检查:定期检查实例的健康状态,自动将流量从故障实例转移出去。
5.采用高可用架构
多实例部署:部署多个实例,使用负载均衡器分发流量。
故障转移:当某个实例出现故障时,自动将流量转移到其他健康实例。
精弘毅行报名系统
提交队伍(高并发)
防止高并发抢名额导致的超卖、重复、系统崩溃等问题
- 连接池优化:合理配置数据库与 Redis 连接池(900)
- 令牌桶限流:限制瞬时高并发流量,防止服务器雪崩(5000)
- Redis 单用户 QPS 限制:
- 利用 Redis 的 INCR + SETNX 设置一个临时计数器
- 每次访问将 openid_Limit +1,过期时间为 60s
- 超过 200 次/分钟就拒绝请求
- Redis + Lua 脚本原子操作:
- 是否已经提交过队伍(SISMEMBER)
- 是否票数已达上限(GET 判断剩余票数)
- 原子提交操作:DECR 剩余票数;SADD 记录已提交队伍
- 提交队伍前的业务验证:是否加入队伍;是否为队长;队伍人数是否≥4
如何解决数据一致性问题?
异步落库 + 补偿机制
写入Redis 成功后,不立即写 MySQL,而是写入消息队列,后台异步写入 MySQL
Redis 写成功后,即使 MySQL 写失败,可以 记录失败队列,定期重试
论坛
技术选型
- 语言:Java
- 版本:JDK17
- 开发框架:Spring Boot
- RPC框架:Dubbo
- RPC通信协议:triple
- RPC API:Protobuf(IDL)
- 前后端交互协议:HTTP
- 服务注册中心:Nacos
- 配置中心:Nacos
API 模块
- 包含:注解、RPC 接口及其所需的入参出参类。
- 作用:作为对外暴露的 SDK,供其他系统通过 RPC 调用本系统服务。
common 模块
- 包含:通用工具类和基础配置。
- 例如:常量、配置、异常处理、工具类、安全注解、API 调用工具等。
- 作用:为整个项目提供基础能力支撑。
service 模块
- 包含:
- RPC 接口实现。
- 业务逻辑代码。
- 消息队列的生产者与消费者。
- 工具类、转换器、数据库操作类。
- 对其他系统 RPC 调用的代理类等。
- 作用:承担系统核心的内部服务逻辑,是业务处理的主要模块。
start 模块
- 包含:
- HTTP 请求接口及其实现。
- 定时任务处理器。
- 作用:项目的启动入口,负责对外暴露 HTTP 接口与调度任务。