分布式系统一致性算法Raft的实现
1.请简要介绍一下Raft算法,并解释它在分布式系统中的作用。
Raft算法是一种一致性算法,用于在分布式系统中实现数据一致性和容错能力。它通过领导人选举、日志复制、日志持久化和日志压缩等机制来确保系统的可靠性和一致性。
2.在你的项目中,你是如何处理数据竞争和并发安全性的?有哪些具体的策略或技术?
我处理数据竞争和并发安全性问题的一种策略是使用互斥锁或读写锁来保护共享资源。我确保每个关键操作都在锁的保护下进行,避免多个线程同时修改共享状态。此外,我还采用了原子操作和同步机制来处理并发访问问题,例如使用原子变量或信号量控制并发执行的顺序。
互斥锁和读写锁相关:
互斥锁(Mutex Lock):
互斥锁是一种排他性锁,它会阻止其他线程同时进入被保护的临界区。 当一个线程持有互斥锁时,其他线程需要等待该线程释放锁后才能进入临界区。 互斥锁适用于对临界区进行独占式访问的场景,例如对共享变量的读写操作。
读写锁(Read-Write Lock):
读写锁允许多个线程同时读取共享资源,但在有写操作时会阻止其他线程的读和写操作。 当某个线程持有读锁时,其他线程也可以持有读锁,但不能持有写锁;当某个线程持有写锁时,其他线程都无法持有读或写锁。 读写锁适用于读操作频繁、写操作较少的场景,可以提高并发读取的效率。
互斥锁的性能:
互斥锁在保护临界区时,由于其排他性质,可能会导致并发读取的效率较低。 当存在大量读操作和少量写操作时,互斥锁可能会降低读取操作的性能,因为它会阻止其他线程同时进行读取操作。
读写锁的性能:
读写锁在读操作较多的情况下性能较高,因为允许多个线程同时持有读锁。 但在存在频繁的写操作时,由于写操作需要排他性保护,可能会导致读取操作的性能下降。 综合来看,如果你的应用场景中读操作远远多于写操作,那么读写锁的性能可能会更好,因为它允许多个线程同时进行读取。而如果存在大量的写操作或者写操作频繁,互斥锁可能更适合,因为它可以保证对临界区的互斥访问。
3.Raft算法中的领导人选举过程是怎样的?你是如何实现基于半数投票原则的领导人选举的?
领导人选举过程是Raft算法中的关键部分。基于半数投票原则,节点需要收到来自其他节点的选票才能成为领导人。
在Raft算法中,领导人选举过程主要分为以下几个步骤:
随机超时: 在Raft中,每个节点都有一个随机的选举超时时间(election timeout),超时时间一般在150ms到300ms之间。如果在该时间内没有收到来自当前领导人的心跳信号或其他节点的投票请求,则该节点会认为当前领导人失效,开始进行新一轮的选举。
提交投票请求: 节点在超时后,会向集群中的其他节点发送投票请求。 投票请求中包含了该节点的任期号(term)和候选人ID(candidate ID)等信息。
收到投票请求: 其他节点收到投票请求后,会检查请求中的任期号与自己的任期号大小关系,若请求中的任期号比自己的任期号大,则更新自己的任期号,并转换为跟随者状态。 然后节点会比较候选人的日志信息和自己的日志信息,若候选人日志比自己的日志新,则投票给候选人,并重置自己的选举超时时间。
获取多数派支持: 当候选人获得了集群中超过半数的投票,它就成为了新的领导人。 如果没有任何一个候选人在当前任期内获得多数派支持,那么选举失败,需要重新进入到随机超时状态。
在我的项目中,我实现了一个选举机制,其中每个节点周期性地发送心跳信号给其他节点,并等待来自其他节点的回应。如果一个节点超过一定时间没有收到足够多的回应,它就会发起选举并请求其他节点投票。
4.你是如何实现日志复制、日志持久化和日志压缩的?为什么需要进行日志压缩?
在Raft算法中,日志复制、日志持久化和日志压缩是实现一致性的关键步骤之一。
日志复制: 当领导人收到客户端的请求时,它会将该请求作为一个日志条目附加到自己的日志中,并向其他跟随者节点发送附加日志的请求。跟随者节点收到附加日志请求后,会根据请求中的信息进行日志复制,即将领导人的日志复制到自己的日志中。
日志持久化:在Raft中,为了保证安全性和持久性,每个节点需要将自己的日志持久化到稳定的存储介质中,如硬盘或闪存。当节点收到来自领导人的附加日志请求后,在将日志复制到自己的日志中之前,需要先将这些日志持久化到持久化存储中。
日志压缩: 随着时间推移,节点的日志会越来越大,为了减少存储空间的占用和提高性能,需要对日志进行压缩。Raft算法中的一种常见的日志压缩方法是“快照”(snapshot),即将当前系统状态的快照保存下来,然后丢弃之前已经被快照覆盖的日志条目。当跟随者节点收到领导人发送的快照信息后,它可以丢弃部分或全部已经包含在快照中的日志条目。
为什么需要进行日志压缩呢?主要有以下几个原因:
1.存储空间占用: 随着时间推移,日志会越来越大,如果不进行压缩,将占用大量的存储空间。 2.传输效率: 在进行日志复制时,较大的日志会增加网络传输的负担,导致性能下降。通过压缩日志,可以减少传输数据的大小,提高传输效率。 3.快速恢复: 通过使用快照进行日志压缩,可以快速将节点恢复到某一历史状态,而无需逐个复制较老的日志条目。
5.你提到了基于复制状态机的KV存储服务,可以详细介绍一下你是如何实现的吗?
我使用状态机模式将Raft算法和KV存储结合起来。当Raft的状态达到设定时,我对状态机进行快照,以避免历史日志过多导致性能下降。通过复制状态机,我可以在集群中的所有节点上保持相同的键值对状态。
6.在项目开发过程中,你遇到了哪些挑战和难点?你是如何解决的?
主要是处理并发访问的正确性和性能问题。我通过仔细设计数据结构和锁机制,以及进行详细的测试和性能优化来解决这些问题。此外,调试分布式程序也是一个挑战,我采用了日志记录、追踪和模拟测试等方法来定位和解决问题。
7.除了功能的实现,你是否考虑了系统的性能和可靠性?有什么具体的优化或改进措施?
我优化了网络通信的效率,减少了不必要的数据传输。我还实现了心跳机制和故障检测,以及失败恢复和节点重连等功能,提高了系统的容错能力和可用性。
8.在调试分布式程序时,你采用了哪些方法和工具?是否有什么特别的经验或教训?
我经常使用日志记录和追踪工具来跟踪程序的执行过程和状态变化。我还会模拟各种故障和边界条件,以测试系统在异常情况下的行为。此外,我也学会了使用调试器和性能分析工具,帮助我定位和解决问题。
9.缓存相关:
去设计⼀个分布式缓存系统要从哪些⽅⾯考虑
-
⾸先得考虑本地缓存的问题:
- 数据的存储⽅式:该⽤什么数据结构存储,是简单的哈希表还是像 redis ⼀样多种数据类型
- 缓存策略:缓存达到上限后如何淘汰,选择 LRU 还是 LFU 等
- 过期时间:是否要设置过期时间,过期时间是否要搭配缓存淘汰策略⼀起使⽤
- 缓存失效:缓存失效或者没有命中该如何处理
- 并发访问:并发访问时如何保证系统安全和稳定
-
其次考虑分布式部署后的问题:
- 负载均衡:缓存数据如何均衡地分布在不同节点,系统该如何均衡地处理请求负载
- 节点通信:节点之间如何进⾏通信
- 缓存⼀致性:如果数据在不同节点上均存在,当⼀个节点的数据发⽣变化时如何通知其他节点使得它们进⾏更新
- 系统可⽤性:分布式环境下,会有某个节点发⽣故障或⽹络中断的问题,该如何设计容错机制和故障恢复策略
缓存雪崩,击穿,穿透分别是什么,如何应对?
缓存雪崩:如果某⼀时刻有⼤量缓存过期或者缓存系统发⽣故障宕机,此时⼜有⼤量的⽤户请求,那么这些请求就会直接访问数据库,从⽽导致数据库的压⼒剧增,严重的就会使数据库宕机,导致⼀系列的连锁反应,这样的情况叫做缓存雪崩。
缓存击穿: 如果缓存中的某个热点数据过期了,此时⼤量的请求就会穿过缓存访问到数据库上,数据库很容易就会被⾼并发的请求击垮,这就是缓存击穿问题
缓存击穿问题可以看作是缓存雪崩的⼀个⼦集
缓存穿透:缓存穿透是请求数据库中并不存在的数据,因此当这种数据访问时,会先访问缓存⽽后再访问数据库,但是数据库中也没有,因此并不会更新缓存,后续这类请求再来还是会访问数据库,因此当这类请求⼤量出现时,数据库就会⾯临⾼并发压⼒。
缓存雪崩等怎么应对,⼀般应对措施有:
- 使⽤cache集群,保证缓存的⾼可⽤(⽐如 redis 的主从+ sentinel 或 cluster ) (使用分布式系统)
集群是由多个节点组成的分布式系统,这些节点可以一起工作以提供高性能和容错能力。
在这种情况下,我们可以使用 Redis 缓存数据库作为例子,并采用以下两种方式之一来实现高可用性:
Redis 主从复制 + Sentinel:这种架构中,有一个主节点和多个从节点。主节点负责处理写操作并复制数据到从节点,而从节点则负责处理读操作。同时,还有一个 Sentinel 进程监控主节点的可用性。如果主节点发生故障,Sentinel 将选举一个新的主节点,并且同步其他从节点的数据。感觉和区块链中的主从多链结构有点像哎。
(Sentinel是一个分布式的系统,由多个Sentinel节点组成,它们通过互相通信来监控Redis实例的健康状态。每个Sentinel节点都会监测Redis实例是否可用,并在必要时自动启动故障转移过程来重新分配Redis实例。此外,Sentinel还提供了自动故障恢复、配置管理和集群管理等功能。
Sentinel的工作原理如下:
Sentinel节点会周期性地ping Redis实例,检查其是否可用。
如果Redis实例未响应,则Sentinel会将其标记为“主观下线”。
如果Sentinel节点之间达成共识,并且多数节点都将Redis实例标记为“主观下线”,则该Redis实例将被标记为“客观下线”。
如果已经选择了一个主Redis实例,而该实例发生故障,则Sentinel节点将选择一个新的主Redis实例,并开始故障转移过程。
故障转移过程中,Sentinel节点将从一组备选Redis实例中选择一个新的主Redis实例,并协调将其设置为新的主实例。
Sentinel节点还会更新其他Redis实例的配置,以确保它们正确地指向新的主Redis实例。)
Redis 集群:这种架构中,数据被分片并存储在多个节点上。每个节点都负责管理自己的数据片段,并与其他节点进行通信以维护整个集群的一致性和可用性。如果某个节点故障,集群会自动进行故障转移并重新分配数据。
- 分级多层缓存,⽐如对于热点数据,使⽤带过期机制的本地缓存代替⼀部分缓存系统功能(把经常访问的放在本地存储)
在这个策略中,通常会设置两个或多个缓存层级。其中,第一级缓存是本地缓存(比如使用内存作为缓存介质),它位于应用程序的内存中,响应速度非常快。当应用程序需要访问数据时,首先会检查本地缓存是否存在该数据。如果存在且未过期,则直接从本地缓存中返回数据,避免了对后端缓存系统的访问。
如果本地缓存中不存在或已过期,那么请求会继续发送到下一级缓存,即后端缓存系统。后端缓存系统可以是分布式缓存(如Redis、Memcached等),提供更大的存储容量和持久化能力。后端缓存系统可以根据具体的业务需求来配置缓存过期时间,以保证数据的一致性和及时性。
通过在本地缓存层级使用带过期机制的缓存,可以减少对后端缓存系统的访问频率,提高热点数据的读取性能。同时,也能够在一定程度上降低后端缓存系统的压力,减少系统资源的占用。
- 合理的熔断及限流保护机制,⽐如 hystrix 熔断, google 的⾃适应熔断等,可以⾮常有效地避免缓存雪崩(防止故障扩散)以及什么是熔断这个问题
熔断(Circuit Breaker)是一种故障保护机制,用于在分布式系统中防止故障从一个服务扩散到另一个服务,提高系统的稳定性。通常在使用熔断机制时,会对一段时间内某个服务的错误率、响应时间等指标进行监控。当这些指标超过预设的阈值时,系统就会启动熔断机制,将该服务的请求直接熔断,避免造成更大的影响。
具体来说,熔断机制通常包括三个状态:关闭状态、打开状态和半开状态。
关闭状态(Closed):正常情况下,熔断器处于关闭状态,所有请求都可以正常地通过。此时,系统会对服务的请求或响应进行监控。
打开状态(Open):当服务出现故障时,熔断器会立即切换到打开状态。此时,请求不能通过,并直接返回一个预设的错误信息。打开状态下的时间长度也是需要设定的,一般为几秒钟或几分钟。这样,当服务出现故障时,熔断器能够快速地切断对该服务的请求,避免进一步影响整个系统。
半开状态(Half-Open):在打开状态下,熔断器会定期尝试恢复服务,这时熔断器进入半开状态。在半开状态下,只允许一个请求通过,并且如果该请求成功,则认为服务已经恢复正常,熔断器重新切换到关闭状态;如果该请求失败,则认为服务仍然出现故障,熔断器重新切换到打开状态。
- 合理使⽤ singleflight 机制(这个是防止多次重复请求)
Singleflight机制是一种用于避免重复请求的机制,可以在高并发场景下减少对缓存系统的冗余访问,提高性能和效率。
核心思想是,在并发请求中只有一个请求会实际去获取数据,而其他请求会等待该请求的结果并共享同一个结果。这个机制可以有效地避免缓存穿透问题(即大量无效的请求穿透缓存直接访问后端系统),减轻后端系统的负载压力。
在使用Singleflight机制时,可以按照以下步骤进行操作:
当收到一个请求时,首先检查缓存中是否存在所需的数据。
如果缓存存在数据,则直接返回给请求方。
如果缓存不存在数据,则使用Singleflight机制,创建一个唯一的标识符,并将其分配给当前请求。
检查是否有其他正在处理相同标识符的请求。
如果有其他请求正在处理,则当前请求会等待其他请求的结果,并与其共享同一个结果。
如果没有其他请求正在处理,则当前请求会去实际获取数据,并将结果保存到缓存中。 返回获取到的数据给当前请求,并通知其他等待相同结果的请求。
你的项目如何应对缓存雪崩和击穿问题?
项⽬实现了 sigleFlight 机制。
singleFlight 的原理是:在多个并发请求触发的回调操作⾥,只有第⼀个回调⽅法被执⾏,其余请求(落
在第⼀个回调⽅法执⾏的时间窗⼝⾥)阻塞等待第⼀个回调函数执⾏完成后直接取结果,以此保证同⼀时刻只有⼀个回调⽅法执⾏,达到防⽌缓存击穿的⽬的
- 缓存失效时的保护性更新
- 防⽌突增的接⼝请求对后端服务造成瞬时⾼负载
具体实现: (感觉和grpc那块很像)
利⽤ mutex 互斥锁和 sync.WaitGroup 机制来实现多 goroutine 的并发控制策略:
步骤:
1.代码中导入sync包,以便使用互斥锁和等待组。
2.创建互斥锁:定义一个互斥锁变量,通常命名为mutex
3.在需要保护共享资源的代码块中使用互斥锁:通过调用Lock()和Unlock()方法来明确需要保护的临界区。
了解的缓存淘汰策略有哪些?
- FIFO:先进先出就是每次淘汰最早添加的记录,但是很多记录添加地早也访问的很频繁,因此命中率不⾼
- LFU(最少使用):淘汰缓存中使⽤频率最低的,LFU认为如果数据过去被访问多次,那么将来被访问的频率也会更⾼。
LFU的实现需要维护⼀个按照访问次数排序的队列,每次访问后元素的访问次数改变,队列重新排序。
LFU算法的命中率⽐较⾼,但缺点是维护每个记录的访问次数对内存的消耗⽐较⾼,并且如果数据的访问模式发⽣改变,LFU需要较⻓的时间去适应,也就是它受历史访问的影响⽐较⼤。在保证⾼频数据有效性场景下,可选择这类策略
- LRU(最近最少使用):LRU是⽐较折中的淘汰算法,LRU认为如果数据在最近被访问过,那么将来被访问到的概率也⽐较⾼。
LRU算法的实现相较于LFU简单很多,只需要维护⼀个队列,访问到的数据移到队⾸,每次淘汰队尾即可。(不用统计次数了)
在热点数据场景下较适⽤,优先保证热点数据的有效性。
- ARC(自适应替换缓存):ARC 介于 LRU 和 LFU 之间,借助 LRU 和 LFU 基本思想实现,以获得可⽤缓存的最佳使⽤
ARC 算法根据数据项的访问情况来判断哪些数据应该被保留在缓存中。它维护两个列表,一个用于记录最近最少使用的数据(类似于 LRU),另一个用于记录最不经常使用的数据(类似于 LFU)。当缓存空间不足时,ARC 会根据当前的访问模式动态地调整这两个列表的大小,以提供更好的缓存命中率。
10.一致性哈希相关问题
什么是⼀致性哈希?为什么在项⽬中要使⽤它?
⼀致性哈希的主要⽬的是解决缓存系统的⽔平扩展性和负载均衡问题。
在传统的缓存系统中,数据的存储位置通常通过将键映射到固定的缓存节点来确定。然⽽,这种⽅式在节点的增加或减少时会导致⼤量的数据迁移,影响性能和可⽤性。
具体来说,一致性哈希算法将整个哈希空间看作一个环,将节点和数据都映射到这个环上,每个节点在环上占据一个位置。当有新的数据需要存储时,先计算出数据的哈希值,然后按照顺时针方向在环上查找,直到找到第一个大于等于该哈希值的节点位置,将数据存储在这个节点上。如果环上没有合适的节点,则将数据存储在第一个节点上。
这种算法的优点在于,当节点数量发生变化时,只有一小部分数据需要重新映射到新的节点上,而大部分数据仍然可以保持在原来的节点上,从而避免了数据大规模迁移的问题。另外,一致性哈希算法还可以提高缓存命中率,因为它能够保持相对均衡的数据分布,避免某些节点负载过重的情况。
在本项⽬中,⼀致性哈希⽤于确定缓存数据的分⽚和分布。每个节点都负责⼀定范围的哈希键,当需要查询或存储数据时,通过⼀致性哈希可以快速定位到对应的节点。这种⽅式有效地避免了数据倾斜和热点问题,并提供了良好的负载均衡和扩展性。
11.细化锁的粒度问题:
分析GeeCache中的锁使用情况,将锁的粒度细化,以提高并发性能,并进行性能测试验证。
具体的实现方法如下:
将缓存数据分片:根据 key 的 hash 值或者其他方式将缓存数据分成多个片段,每个片段独立使用一个锁进行保护。这样不同片段之间的读写操作就不会相互阻塞,进而提高并发性能。
使用读写锁:对于读多写少的场景,可以使用读写锁来代替互斥锁,加快读操作的速度。在 GeeCache 中,我们可以对每个片段使用一个读写锁,这样多个线程同时读取同一个片段时,不会相互阻塞。而当有线程需要写入数据时,会先获取该片段的写锁,此时其他线程的读写操作都会被阻塞,直到写操作完成。
细化锁的粒度:在具体实现时,可以考虑将锁的粒度细化到更小的单位,比如对于每一个 key 值都使用一个锁进行保护。这样可以最大化地减少锁的竞争,提高并发性能。
测试代码:
func BenchmarkGet(b *testing.B) {
cache := NewCache(1024)
for i := 0; i < 10000; i++ {
cache.Set(strconv.Itoa(i), strconv.Itoa(i))
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
key := rand.Intn(10000)
cache.Get(strconv.Itoa(key))
}
})
}
func BenchmarkGetWithFineGrainedLock(b *testing.B) {
cache := NewCacheWithFineGrainedLock(1024)
for i := 0; i < 10000; i++ {
cache.Set(strconv.Itoa(i), strconv.Itoa(i))
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
key := rand.Intn(10000)
cache.Get(strconv.Itoa(key))
}
})
}
func BenchmarkGetWithRWLock(b *testing.B) {
cache := NewCacheWithRWLock(1024)
for i := 0; i < 10000; i++ {
cache.Set(strconv.Itoa(i), strconv.Itoa(i))
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
key := rand.Intn(10000)
cache.Get(strconv.Itoa(key))
}
})
}
12.etcd 是如何保证强⼀致性的呢
etcd 使⽤ Raft 协议来保证强⼀致性,包括两个过程:leader选举和主从数据同步
leader选举:
- 集群初始化时,每个节点都是 follower ,都维护⼀个的计时器,如果计时器时间到了还没有收到 leader 的消息,⾃⼰就会变成 candidate ,竞选 leader
- 当 follower ⼀定时间内没有收到来⾃主节点的⼼跳,则认为主节点宕机,会将⾃⼰变为 candidate,并发起选举,当收到包括⾃⼰在内超过半数的节点投票后,选举成功;当票数不⾜或者选举超时则选举失败,若本轮未选出主节点则⻢上进⾏新⼀轮选举
- 集群中存在⾄多⼀个主节点,通过⼼跳机制与其他节点同步数据
- candidate 节点收到来⾃主节点的信息后,会⽴即终⽌选举进⼊ follower ⻆⾊,为了避免陷⼊选主失败循环,每个节点未收到⼼跳后发起选举的时间是⼀定范围内的随机值,这样来避免两个节点同时发起选举
主从数据同步:
- client 连接 follower 或者 leader ,如果是读请求, follower 也可以处理,如果是写请求,则连接到 follower 后还需要转发给 leader 处理
- leader 接收到 client 的写请求后,将该请求转为 entries (批量的entry),写⼊到⾃⼰的⽇志中,得到在⽇志中的 index ,将该 entries 发送给所有的 follower
- follower 接收到 leader 的 AppendEntriesRPC 请求后,会将 leader 传过来的 entries 写⼊到⽂件中(并不⽴即刷盘),然后向 leader 回复确认, leader 收到过半的确认后就认为可以提交了,会应⽤到⾃⼰的状态机中,然后更新 commitIndex ,回复 client
- 在下⼀次 leader 发送给 follower 的⼼跳中,会将 leader 的 commitIndex 发送给 follower, follower 发现 commitIndex 更新了则也将 commitIndex 之前的⽇志都进⾏提交和应⽤到状态机中在 leader 收到数据操作的请求时,先不着急更新本地缓存(数据是持久化在磁盘上的),⽽是⽣成对应的log ,然后把⽣成 log 的请求⼴播给所有的 follower ,每个 follower 在收到请求之后听从 leader 的命令,也写⼊ log ,返回确认。 leader 收到过半的确认之后进⾏⼆次提交,正式写⼊数据(持久化),然后告诉 follower ,让他们也持久化
13.etcd分布式锁实现的基础机制是怎样的
Lease 机制
租约机制,etcd 可以为存储的 key-value 对设置租约,当租约到期, key-value 将失效删除
同时也⽀持续约,通过客户端可以在租约到期之前续约
Lease 机制可以保证分布式锁的安全性,为锁对应的 key 配置租约,即使锁的持有者因故障⽽不能主动释放锁,锁也会因为租约到期⽽⾃动释放
Revision 机制
每个key都带有⼀个 Revision 号,其是全局唯⼀的,通过 Revision 号的⼤⼩可以知道写操作的顺序 在实现分布式锁时,多个客户端同时抢锁,根据 Revision 号⼤⼩依次获得锁,可以避免⽺群效应
Prefix 机制
例如,⼀个名为 /etcd/lock 的锁,两个争抢它的客户端进⾏写操作, 实际写⼊的 key 分别为: key1="/etcd/lock/UUID1" , key2="/etcd/lock/UUID2" 。 其中, UUID 表示全局唯⼀的 ID ,确保两个 key 的唯⼀性。代码随想录知识星球写操作都会成功,但返回的 Revision 不⼀样, 那么,如何判断谁获得了锁呢?通过前缀 /etcd/lock 查询,返回包含两个 key-value 对的的 KeyValue 列表, 同时也包含它们的 Revision ,通过 Revision ⼤⼩,客户端可以判断⾃⼰是否获得锁。
14.能说⼀说⽤ etcd 时它处理请求的流程是怎样的吗
etcd主要分为四部分:
- http server:⽤于处理⽤户发送的 api 请求以及其他 etcd 节点的同步与⼼跳信息请求
- store:⽤于处理 etcd ⽀持的各类功能的事务,包括数据索引,节点状态变更,监控反馈,事件处理执⾏等,是 etcd 对⽤户提供的⼤多数 api 功能的具体实现
- raft:强⼀致性算法的实现
- wal:预写⽇志,除了在内存中存有所有数据的状态以及节点的索引外,etcd 就通过 wal 进⾏持久化存储;其中 snapshot 是为了防⽌数据过多⽽进⾏的状态快照, entry 表示存储的具体⽇志内容通常⼀个⽤户请求发过来会经由 http server 转发给 store 进⾏具体的事务处理,如果涉及到节点的修改,则交给 raft 模块进⾏状态变更,⽇志记录,然后再同步给别的 etcd 节点以确认数据提交,最后进⾏数据的提交,再次同步
15.怎么做到,实现了状态机并且会在Raft的状态达到设定时进行快照,并基于复制状态机实现了KV存储服务
要实现基于复制状态机的 KV 存储服务,并且在 Raft 的状态达到设定时进行快照,可以按照以下步骤进行:
1.设计状态机:首先,需要设计一个合适的状态机来表示你的 KV 存储服务。状态机应该定义键值对的操作,例如插入、删除、更新等,以及相应的状态转换规则。
2.实现 Raft 算法:Raft 是一种共识算法,用于确保多个副本节点之间的一致性和可用性。根据 Raft 算法的规范,你需要实现节点之间的选主过程、日志复制机制和故障恢复等功能。这些功能可以通过消息传递和心跳机制来实现。
3.复制状态机:在 Raft 算法中,每个节点都有一个复制状态机。当 Leader 节点接收到客户端的请求后,它会将该请求作为日志项追加到日志中,并将日志项发送给其他节点进行复制。每个节点在收到日志项后,按照相同的顺序应用到自己的状态机上,从而保持各个节点的状态一致。你需要实现复制状态机的逻辑,包括日志的追加、复制和应用。
4.快照机制:为了减少存储的压力和加快系统启动的速度,当 Raft 的状态达到设定条件时,可以进行快照操作。快照是对当前状态机的一个快照,它包含了一个特定时间点的数据状态。通过快照机制,可以删除旧的日志项,并通过加载快照进行状态恢复。你需要实现快照的生成和加载逻辑。
5.构建 KV 存储服务:在完成上述步骤后,将状态机和 Raft 算法结合起来,构建基于复制状态机的 KV 存储服务。客户端可以向 Leader 节点发送请求,Leader 节点将请求转发给其他节点进行复制,并且根据状态机的逻辑进行相应的操作。每个节点都会维护自己的状态机状态,并通过复制状态机保持一致性。
抖声app
1.请描述一下你在项目中使用Gin框架的经验,你觉得Gin框架有哪些优点和局限性?你是如何解决在项目中遇到的具体问题的?
对于Gin框架的优点和局限性,Gin是一个轻量级的Web框架,具有性能高效、易用、快速上手等特点。在项目中,我们可以通过Gin提供的路由、中间件等功能来构建灵活的API接口。但是,由于Gin框架较为轻量,可能在一些高级功能和扩展方面相对不足,需要根据具体业务需求进行衡量。
遇到问题首先肯定会先阅读报错信息,然后定位到相关行数进行错误查看,并尝试解决。如果解决不了,就会选择上stack overflow,gpt上面去询问,然后对于这些问题,一般我都会记录在自己的笔记中,以防遇见同样的问题。
2.你在项目中使用了Gorm进行数据库操作,请谈谈你对Gorm的理解以及在实际项目中的应用经验,它与原生SQL操作相比有何优势?
Gorm是一种ORM库,它简化了数据库操作、提高了开发效率和可维护性,让我们能够更专注于业务逻辑的实现。
在我看来,Gorm的优势主要有以下几点:
简洁易用:与原生SQL相比,Gorm提供了更加简洁的API接口,让我们不必关注过多的SQL语句细节,可以更加快速地开发和维护代码。
数据库无关性:Gorm的API与具体的数据库无关,因此可以轻松地切换不同的数据库,而无需更改过多的代码。
自动映射:通过Gorm提供的struct tag和约定,可以自动将数据库中的表和列映射到Go语言的struct和字段上,减少了手动映射的工作量。
关联查询:Gorm支持多种类型的关联查询,例如一对多、多对多等,可以轻松地处理复杂的查询需求。
在实际项目中,我经常使用Gorm进行数据库操作。例如,在一个电商网站的订单模块中,我使用Gorm定义了订单、商品和用户三个模型,并使用Gorm进行关联查询和事务处理,确保数据的一致性和完整性。同时,Gorm也提供了丰富的查询API,可以方便地实现各种复杂的查询需求。
当然,Gorm也有一些局限性。例如,在某些极端情况下,使用原生SQL可能会更加高效。因此,在选择Gorm或原生SQL操作时,需要根据具体业务场景和需求进行衡量和选择。
3.项目中使用了Redis实现缓存功能,你能谈谈在实际项目中如何选择合适的缓存策略?以及在使用Redis时所遇到的挑战和解决方案?
在选择合适的缓存策略时,我会根据具体业务场景和需求进行评估。例如,对于热门视频排行榜这种频繁访问且数据变化较少的情况,可以使用Redis缓存进行缓存并设置合适的过期时间。在使用Redis时,我会注意数据一致性和缓存更新的时机,例如在数据更新时同步更新缓存。
4.集成Minio对象存储服务是一个关键的模块,你能分享一下在项目中遇到的与该模块相关的技术难点以及你的解决方案?
集成Minio对象存储服务可能会遇到的技术难点包括文件上传、下载和管理等功能的实现。针对文件上传,可以使用Minio提供的客户端SDK或API进行调用,并确保文件的安全性和可靠性。对于文件下载和管理,可以设计相应的接口和权限控制机制,保证用户只能访问其有权限的文件。
文件上传:
接收客户端传输的文件数据,可以通过HTTP的multipart/form-data格式进行上传。 使用Minio提供的客户端SDK或API,调用相关方法将文件上传到Minio对象存储服务中。 在上传过程中,可以对文件进行校验、验证文件类型、大小限制等操作,确保文件的完整性和安全性。
文件下载:
设计相应的接口,接收客户端的请求,传入需要下载的文件的标识符或路径等参数。 在后端,通过调用Minio提供的客户端SDK或API,从Minio对象存储服务中获取对应的文件。 将获取到的文件流返回给客户端,使其能够下载文件。
文件管理:
设计管理接口,允许管理员或特定权限的用户进行文件管理操作,例如添加、删除、修改文件信息等。 在后端,使用Minio提供的SDK或API,进行对应的操作,如创建存储桶、删除文件等。 考虑权限控制,确保只有具备相应权限的用户能够进行文件管理操作。
权限控制:
针对文件的访问权限,可以设计相应的权限控制机制,确保用户只能访问其有权限的文件。 可以使用用户身份认证机制(如JWT)来验证用户的身份,并在接口层进行鉴权操作,判断用户是否具备访问该文件的权限。
错误处理和安全性:
在接口设计中,要考虑错误处理机制,对于上传、下载或管理文件过程中的异常情况进行合理的处理与响应。 对于文件上传和下载的安全性,可以使用合适的加密算法或协议对文件进行加密传输,确保数据的安全性。
5.项目中采用JWT进行用户身份验证和授权管理,你能详细介绍一下JWT的工作原理和在实际项目中如何应用的具体经验?
JWT的工作原理是基于令牌的用户身份验证和授权管理。在实际项目中,我会使用JWT生成和解析令牌,通过令牌验证用户的身份,并通过中间件进行权限控制。同时,为了增强安全性,我会注意令牌的有效期设置和刷新机制的设计。
遇到的问题
当缓存抖动的时候,会触发大量的cache rebuild,因为我们使用了预加载,容易造成OOM( 内存溢出)。
解决方案:因此我们需要使用消息队列来进行逻辑异步化,对于当前请求,只返回MySQL中的部分数据即可。
优势(或者说创新):敏感词过滤
1.正则表达式的效率比较低,在大规模的敏感词过滤场景中可能存在性能问题。使用Trie树作为敏感词过滤的算法可以提高效率,同时在过滤时也比较容易实现(不推荐)
2.前缀树(推荐) 在项目中,需要先调用buildTrieTree方法,将敏感词列表构造成Trie树,然后再使用filterText方法对文本进行过滤
优势:
效率高:前缀树可以高效地进行敏感词的匹配和查找。在构建Trie树时,将敏感词列表转化为一个树形结构,通过节点之间的边表示字符之间的关系,这种存储方式使得在查找过程中可以快速定位到匹配失败的位置,避免了不必要的比较操作,因此具有较高的查询效率。
精确匹配:前缀树可以对文本进行精确匹配,只要在构建Trie树时将敏感词添加到树中,就可以准确地过滤出所有的敏感词。相比之下,正则表达式的匹配是基于模式匹配的,可能会误判一些非敏感词或者漏掉一些敏感词。
可扩展性强:使用前缀树可以方便地扩展和修改敏感词库。当需要添加、删除或修改敏感词时,只需更新Trie树即可,无需修改复杂的正则表达式。这种可扩展性使得维护和管理敏感词库更加灵活和方便。
防止规则重叠:正则表达式的匹配是基于规则的,当多个规则存在重叠时,可能导致匹配结果不准确。而前缀树通过精确匹配避免了这个问题,每个敏感词都会被单独匹配,不会被其他规则干扰。
具体实现方法如下:
1.将敏感词列表插入到前缀树中。对于每个敏感词,从根节点开始,依次遍历每个字符,并在需要时创建新的子节点。在最后一个字符处,将该节点标记为敏感词结束节点。
2.对于输入文本,从第一个字符开始,依次遍历每个字符。在前缀树中,从根节点开始,沿着字符的路径移动,并检查每个节点是否被标记为敏感词结束节点。
3.如果找到了敏感词,则可以使用特定的符号或替代词来替换它。
4.继续遍历文本,直到处理完所有字符。
docker相关问题
docker简介:
Docker 是一个开源的应用容器引擎,基于 Go 语言并遵从 Apache2.0 协议开源。
Docker 可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。
容器是完全使用沙箱机制,相互之间不会有任何接口(类似 iPhone 的 app),更重要的是容器性能开销极低。
Docker的应用场景
Web 应用的自动化打包和发布。
自动化测试和持续集成、发布。
在服务型环境中部署和调整数据库或其他的后台应用。
从头编译或者扩展现有的 OpenShift 或 Cloud Foundry 平台来搭建自己的 PaaS 环境。
docker常用命令
1、如何列出可运行的容器?
docker ps
2、启动nginx容器(随机端口映射),并挂载本地文件目录到容器html的命令是?
docker run -d -P --name nginx2 -v /home/nginx:/usr/share/nginx/html nginx
3、进入容器的方法有哪些?
1、使用 docker attach 命令
2、使用 exec 命令,例如docker exec -i -t 784fd3b294d7 /bin/bash
4、容器与主机之间的数据拷贝命令是?
docker cp 命令用于容器与主机之间的数据拷贝
主机到容器:
docker cp /www 96f7f14e99ab:/www/
容器到主机:
docker cp 96f7f14e99ab:/www /tmp/
5、当启动容器的时候提示:exec format error?如何解决问题
检查启动命令是否有可执行权限,进入容器手工运行脚本进行排查。
6、本地的镜像文件都存放在哪里?
与 Docker 相关的本地资源都存放在/var/lib/docker/目录下,其中 container 目录存放容器信息,graph 目录存放镜像信息,aufs 目录下存放具体的内容文件。
7、如何退出一个镜像的 bash,而不终止它?
按 Ctrl-p Ctrl-q。
8、退出容器时候自动删除?
使用 –rm 选项,例如 sudo docker run –rm -it ubuntu
9、如何批量清理临时镜像文件?
可以使用 sudo docker rmi $(sudo docker images -q -f danging=true)命令
10、如何查看镜像支持的环境变量?
使用 sudo docker run IMAGE env
11、本地的镜像文件都存放在哪里
于 Docker 相关的本地资源存放在/var/lib/docker/目录下,其中 container 目录 存放容器信息,graph 目录存放镜像信息,aufs 目录下存放具体的镜像底层文件。
12、容器退出后,通过 docker ps 命令查看不到,数据会丢失么?
容器退出后会处于终止(exited)状态,此时可以通过 docker ps -a 查看,其中数据不会丢失,还可以通过 docker start 来启动,只有删除容器才会清除数据。
13、如何停止所有正在运行的容器?
使用 docker kill $(sudo docker ps -q)
14、如何清理批量后台停止的容器?
答:使用 docker rm $(sudo docker ps -a -q)
15、如何临时退出一个正在交互的容器的终端,而不终止它?
按 Ctrl+p,后按 Ctrl+q,如果按 Ctrl+c 会使容器内的应用进程终止,进而会使容器终止。
1. 什么是 Docker 容器?
Docker 容器 在应用程序层创建抽象并将应用程序及其所有依赖项打包在一起。这使我们能够快速可靠地部署应用程序。容器不需要我们安装不同的操作系统。相反,它们使用底层系统的 CPU 和内存来执行任务。这意味着任何容器化应用程序都可以在任何平台上运行,而不管底层操作系统如何。我们也可以将容器视为 Docker 镜像的运行时实例。
2. 什么是 DockerFile?
Dockerfile 是一个文本文件,其中包含我们需要运行以构建 Docker 映像的所有命令。Docker 使用 Dockerfile 中的指令自动构建镜像。我们可以docker build用来创建按顺序执行多个命令行指令的自动构建。
3. 如何从 Docker 镜像创建 Docker 容器?
为了从镜像创建容器,我们从 Docker 存储库中提取我们想要的镜像并创建一个容器。我们可以使用以下命令:
$ docker run -it -d <image_name>
4. Docker Compose 可以使用 JSON 代替 YAML 吗?
是的,我们可以对Docker Compose文件使用 JSON 文件而不是YAML
$ docker-compose -f docker-compose.json up
5. 什么是Docker Swarm?
Docker Swarm 是一个容器编排工具,它允许我们跨不同主机管理多个容器。使用 Swarm,我们可以将多个 Docker 主机变成单个主机,以便于监控和管理。
6. 如果你想使用一个基础镜像并对其进行修改,你怎么做?
我们可以使用以下 Docker 命令将图像从 Docker Hub 拉到我们的本地系统上:
$ docker pull <image_name>
7. 如何启动、停止和终止容器?
要启动 Docker 容器,请使用以下命令:
$ docker start <container_id>
要停止 Docker 容器,请使用以下命令:
$ docker stop <container_id>
要终止 Docker 容器,请使用以下命令:
$ docker kill <container_id>
8. 如何构建Dockerfile?
为了使用我们概述的规范创建映像,我们需要构建一个 Dockerfile。要构建 Dockerfile,我们可以使用以下docker build命令:
$ docker build
9.使用什么命令将新镜像推送到 Docker Registry?
要将新镜像推送到 Docker Registry,我们可以使用以下docker push命令:
$ docker push myorg/img
10.使用Docker Compose时如何保证容器A先于容器B运行?
Docker Compose 是一个用来定义和运行复杂应用的Docker工具。一个使用Docker容器的应用,通常由多个容器组成。使用Docker Compose不再需要使用shell脚本来启动容器。Compose 通过一个配置文件来管理多个Docker容器。简单理解:Docker Compose 是docker的管理工具。
Docker Compose 在继续下一个容器之前不会等待容器准备就绪。为了控制我们的执行顺序,我们可以使用“取决于”条件,depends_on 。这是在 docker-compose.yml 文件中使用的示例
version: "2.4"
services:
backend:
build: . # 构建自定义镜像
depends_on:
- db
db:
image: mysql
用 docker-compose up 命令将按照我们指定的依赖顺序启动和运行服务。
11:Docker 安全吗?
Docker利用Linux内核中很多安全特性来保证不同容器之间的隔离,并且通过签名机制来对镜像进行验证。
大量生产环境的部署证明,Docker虽然隔离性无法与虚拟机相比,但仍然具有极高的安全性。
12. 一个完整的Docker由哪些部分组成?
DockerClient 客户端
Docker Daemon 守护进程
Docker Image 镜像
DockerContainer 容器
grpc常见面试题
1.gRPC是什么,有哪些优点?
gRPC是一种高性能、开源的远程过程调用(RPC)框架,它可以使不同平台和语言之间的服务相互通信。它的优点包括:高效性、跨平台、异步流处理、支持多种语言、安全、易于使用和开源。
2.gRPC和REST的区别是什么?
REST是基于HTTP协议的一种风格,而gRPC是一个独立于协议的RPC框架。REST基于资源的状态转移,使用标准的HTTP方法,而gRPC使用协议缓冲区(Protocol Buffers)进行序列化和反序列化。gRPC支持异步流处理和双向流,而REST通常只支持请求/响应模式。
3.Protocol Buffers是什么,为什么它被用于gRPC中?
Protocol Buffers是一种语言中立、平台中立、可扩展的序列化格式,它可以用于数据交换和持久化。它被用于gRPC中,因为它可以实现高效的序列化和反序列化,从而提高了gRPC的性能和效率。
4.gRPC的流程是什么?
gRPC流程分为四个步骤:定义服务、生成源代码、实现服务、启动服务。首先,需要定义要实现的服务及其接口,使用Protocol Buffers编写接口定义文件。其次,使用编译器生成客户端和服务器端的源代码。然后,实现生成的接口。最后,启动服务器并将其部署在适当的位置。
5.gRPC支持哪些类型的序列化?
gRPC支持两种类型的序列化:二进制(使用Protocol Buffers)和JSON。其中,二进制序列化在效率和性能方面比JSON序列化更优秀。但是,JSON序列化在可读性方面更好,可以方便地进行调试和测试。