✨ 欢迎来到我的个人博客
🤔 我在这里分享技术、读书、生活还有思考。
Kafka的rebalance?什么情况下会出现?
Kafka 的 Rebalance (重平衡) 是 Consumer Group (消费者组) 中的一个核心机制,它用于在 Consumer Group 内部重新分配 Topic 的分区(Partition)所有权。 Rebalance 确保了在集群运行过程中,Consumer Group 里的所有消费者能均匀、独占地消费所有相关的分区。 1.什么是 Rebalance (重平衡)? 在 Kafka 中,一个 Consumer Group 消费一个或多个 Topic。每个分区在同一时刻只能被 Consumer Group 内的一个 Consumer 实例消费。 Rebalance 就是 Consumer Group 内部达成一致,确定“谁”来消费“哪个”分区的过程。 1.1 核心目标 负载均衡:将分区均匀地分配给组内所有健康的 Consumer 实例。 高可用性:当有 Consumer 实例失败或退出时,Rebalance 机制会将它之前负责的分区重新分配给组内其他Consumer,确保消费不会中断。 1.2 Rebalance 的过程 整个过程由 Consumer Group 的 Coordinator(协调器,通常是某个 Broker) 负责协调: -Join Group (加入组):新的 Consumer 加入或旧的 Consumer 重新连接时,会向 Coordinator 发送请求。 -Sync Group (同步组):Coordinator 在收到所有 Consumer 的 Join 请求后,会选出一个 Leader Consumer。 -分配方案:Leader Consumer 负责制定分区到 Consumer 的映射关系(分配策略)。 -执行分配:Coordinator 将分配方案通知给所有 Consumer,各个 Consumer 按照方案开始消费新分配的分区。 2.什么情况下会出现 Rebalance? 任何导致 Consumer Group 内部成员发生变化或分区信息发生变化的操作,都会触发 Rebalance。 ...
Kafka如何实现主从同步?
Kafka 实现主从同步(即 Leader 副本和 Follower 副本之间的数据同步)是其保证数据高可用性和持久性的核心机制。这个过程是**异步拉取(Pull)的,并由 ISR(同步副本集合)机制严格管理。 1.异步拉取 与一些数据库的 Push 模式不同,Kafka 的副本同步采用 Pull 模型: 主动方 (Follower):Follower 副本是主动方。它会不断地向 Leader 副本发送请求,请求拉取新的消息数据。 拉取单位:Follower 拉取的最小单位是 **日志段(Log Segment)**中的一批消息。 这种拉取模式允许 Follower 控制自己的复制速率。如果 Follower 暂时负载过高,它可以减慢拉取速度,避免被 Leader 的高速写入压垮。 2.关键同步指标 Follower 在同步过程中,会维护和使用两个关键的偏移量(Offset): LEO (Log End Offset):表示该 Follower 已成功写入本地日志的最新消息的下一个 Offset。 HW (High Watermark):表示所有 ISR 集合中的副本都已经复制并确认写入的最新消息的下一个 Offset。 重要性:HW 之前的消息对 Consumer 是可见且安全的,而 HW 之后的 Leader 消息对 Consumer 是不可见的,以防 Leader 宕机导致数据丢失。 3.ISR (In-Sync Replicas) 机制的保障 同步副本集合(ISR)是衡量同步状态的核心机制: Leader 维护 ISR:Leader 副本负责维护 ISR 列表。ISR 列表包括 Leader 自身和所有与 Leader 保持“同步”的 Follower 副本。 同步判断标准: -Follower 必须在配置的时间阈值(replica.lag.time.max.ms)内持续向 Leader 发送拉取请求。 -Follower 的 LEO 必须与 Leader 的 LEO 保持在一个可接受的范围内。 副本移出:如果 Follower 无法满足上述条件(如网络延迟过高、宕机),它会被 Leader 移出 ISR。 数据持久性保证:当生产者(Producer)设置为 acks=all 时,Leader 必须等待 ISR 中的所有副本都确认写入了消息,才会返回 ACK 成功。这确保了只要 ISR 中有一个副本存活,数据就不会丢失。 4.主从同步流程简述 Follower 发送 Fetch 请求:Follower 向 Leader 发送 Fetch Request,请求从自己的 LEO 开始的新消息。 Leader 发送消息:Leader 从自己的日志中读取从 Follower LEO 开始的消息,并返回给 Follower。 Follower 写入并更新 LEO:Follower 接收到消息后,将其追加写入到自己的本地日志中,并更新自己的 LEO。 Leader 更新 HW:Leader 收到 Follower 的成功响应后,会检查 所有 ISR 副本的 LEO,并更新 HW 为所有副本 LEO 的最小值。
Type Assertion 类型断言
在 Go 语言中,类型断言(Type Assertion) 是一种用于从接口值中提取其底层具体类型的操作。它是 Go 实现多态和类型安全的重要机制之一。 一、基本语法 value, ok := interfaceVar.(ConcreteType) interfaceVar:一个接口类型的变量 ConcreteType:你期望它实际存储的具体类型(如 int, string, MyStruct 等) value:如果断言成功,就是该类型的值 ok:布尔值,表示断言是否成功 二、为什么需要类型断言? Go 的接口(interface)可以存储任何类型的值,但当你想使用这个值的具体方法或字段时,就必须知道它的真实类型。 var i interface{} = “hello” // 我知道它是 string,但接口本身不能直接调用 len() s := i.(string) // 类型断言:断言 i 是 string fmt.Println(len(s)) // 现在可以了 三、两种写法 安全断言(推荐) —— 带 ok 判断 s, ok := i.(string) if ok { fmt.Println("字符串长度:", len(s)) } else { fmt.Println("i 不是一个字符串") } 优点:不会 panic,适合不确定类型时使用。 直接断言 —— 不检查 ok s := i.(string) // 如果 i 不是 string,会 panic! 面临的风险:如果类型不匹配,程序会崩溃(panic)。 仅在你100%确定类型时使用。 ...
了解分布式锁吗?
分布式锁是分布式系统从“松散运行”走向“协作、可靠运行”的关键桥梁。 1.什么是分布式锁? 分布式锁是用于在分布式系统中协调多个进程或线程访问共享资源的一种机制,确保在任何时刻只有一个客户端能够操作特定的资源,从而保证数据的一致性。 2.分布式锁的基本要素 无论是基于 Redis、ZooKeeper 还是数据库实现,一个可靠的分布式锁必须满足三个要素: *加锁(Lock):在资源上设置一个锁的标记。 *设置过期时间(TTL):防止客户端宕机导致锁无法释放,造成死锁。这是分布式锁与本地锁(如 Java 的 ReentrantLock)最核心的区别。 *释放锁(Unlock):客户端完成操作后,安全地移除锁。 3.分布式锁面临问题和解决 3.1 死锁 如果客户端在获取锁后,由于某种原因(例如,程序崩溃)未能正常释放锁,导致锁一直被占用,其他客户端无法获取锁,造成死锁。解决方案:在加锁的同时设置过期时间,即使客户端未能正常释放锁,锁也会在过期后自动释放。或者使用 Redlock 算法,提高锁的可靠性,防止死锁。 3.2 锁的误删 -如果客户端 A 获取锁后,由于执行时间过长,导致锁过期自动释放。此时,客户端 B 获取了锁。然后,客户端 A 执行完业务逻辑后,尝试释放锁,但实际上释放的是客户端 B 的锁,造成锁的误删除。解决方案:在加锁时,将锁的值设置为一个唯一标识(例如,UUID),在释放锁时,先判断锁的值是否与自己的唯一标识相等,如果相等,则释放锁;否则,不释放锁。此过程要保证原子性,可以使用 Lua 脚本实现。 -在网络分区的情况下,可能会导致多个进程同时认为自己持有锁。解决方案:在获取锁时生成一个唯一的 UUID,并将其存储在锁的 Key 中。在释放锁时,先检查当前存储的 UUID 是否与自己的 UUID 匹配,只有匹配时才释放锁。 3.3 锁的续期 如果客户端在加锁后,执行时间超过了锁的过期时间,导致锁被自动释放。此时,其他客户端可能会获取锁,造成并发问题。解决方案:客户端在获取锁后,启动一个后台线程,定期检查锁的剩余时间,如果剩余时间小于一定阈值,则使用 EXPIRE 命令续期锁的过期时间。此流程可以自己实现,也可以使用开源框架,例如Redisson 框架不仅提供了自动续期的功能,还可以简化分布式锁的实现。 3.4 锁的竞争 在高并发场景下,多个进程可能会同时竞争锁,导致锁的获取失败率较高。解决方案:可以使用随机退避重试策略,在获取锁失败后,随机等待一段时间后再次重试。 3.5 锁的重入性 如果同一个进程多次尝试获取锁,可能会导致锁的获取失败。解决方案:在锁的 Key 中存储一个计数器,表示当前进程获取锁的次数。每次获取锁时增加计数器,释放锁时减少计数器,只有计数器为 0 时才删除锁的 Key。 3.6 锁的公平性 多个进程同时请求锁时,可能会出现“饥饿”现象,某些进程长时间无法获取锁。解决方案:可以使用 Redis 的 List 数据结构实现排队机制,确保请求锁的进程按照顺序获取锁。或者使用成熟的分布式锁实现库,如 Redisson,它提供了公平锁和可重入锁等功能。
你了解内存管理吗?
栈分配和堆分配确实都发生在“对象分配器”的分配阶段, 但它们走的是不同的路径: 栈分配走的是编译期静态分配; 堆分配走的是运行时对象分配器(runtime.mallocgc)。 1.程序启动阶段 Go 运行时启动时(runtime 初始化),会: 向操作系统申请一大块虚拟内存(称为 arena); 由 页分配器(page allocator) 管理这块内存; 构建堆内存管理结构(mheap、mcentral、mcache)。 这部分只是“预留”内存,真正的对象分配还没发生。 2.用户程序触发分配(对象分配阶段) 当用户代码中创建变量时,比如: x := MyStruct{} 编译器会在编译阶段决定这个对象是: 分配在栈上(stack allocation) 还是分配在堆上(heap allocation) 这个决策是通过 逃逸分析(Escape Analysis) 完成的。 3.栈分配的过程 如果编译器认为对象只在当前函数作用域内使用,不会被外部引用: 这个对象会直接分配在栈上; 不会调用运行时的内存分配器; 栈内存是随函数调用帧自动增长/释放的; GC 不需要扫描或回收它。 > 关键:栈分配是编译期确定的,性能最好。 4.堆分配的过程 如果对象被闭包引用、返回地址或传递给其他 goroutine,则会发生逃逸: 编译器在生成代码时,会调用运行时的分配器 runtime.mallocgc; mallocgc 会从当前 P 的 mcache 尝试获取一个合适的 span; 若 mcache 缓存不足,就从 mcentral → mheap 逐层申请; 分配完成后,GC 会在堆上追踪这个对象。 >关键:堆分配是运行时动态完成的,涉及 GC 管理。 5.回收阶段 当对象不再被引用时,GC 会标记并清除; 被清除的内存重新回收到 mcache / mcentral / mheap; 长期未使用的页可能由scavenger(拾荒器)归还给 OS。 6.对比栈和堆分配 类型 分配阶段 分配位置 分配速度 是否由 GC 管理 是否逃逸 栈分配 编译期(静态) 每个 goroutine 的调用栈 极快 否 否 堆分配 运行时(动态) 运行时堆(mheap) 慢 是 是
如何保证 Redis 与数据库的数据一致性?
1.为什么会不一致 ? Redis 是缓存层,数据库是持久层。 二者数据可能不一致的原因包括: 更新数据库成功,但更新缓存失败; 缓存提前过期; 并发写操作覆盖(顺序问题); 异步更新延迟。 2.更新策略 2.1 Cache Aside(旁路缓存) 读操作 1. 读缓存 2. 如果缓存不存在 ,再读数据库 3. 将数据写入缓存(设置过期时间) 写操作: 先更新数据库,再删除缓存 缺点:删除缓存可能失败;删除顺序不当会不一致 2.2 Read/Write Through(读写穿透) 应用不直接访问DB,所有读写都经由缓存代理完成 缺点:实现复杂,性能略低 2.3 Write Behind(异步写回) 只写缓存,由缓存异步刷回数据库 缺点:容易丢数据 3.处理并发问题 3.1延迟双删策略(Double Delete) 1. 更新数据库; 2. 删除缓存; 3. 延迟 500ms 再删一次缓存。 //可以应对并发中缓存被“脏写”回的情况。 3.2异步消息队列(MQ) 数据更新时发送 MQ 消息,异步同步缓存状态。 3.3分布式锁 保证更新操作串行执行,避免交叉覆盖。
什么是“重锁” Heavy Lock?
1.什么是“重锁”(Heavy Lock) 在 Go 性能调优或并发编程中,我们常说的 “重锁”(heavy lock)不是官方术语,而是一个工程上的概念,指的是:锁竞争严重、临界区较大、持锁时间较长的互斥锁(sync.Mutex)。 1.多个 goroutine 同时频繁地去争夺同一把锁; 2.加锁的代码块中做了比较“重”的操作(比如 I/O、JSON 编码、数据库操作); 导致 goroutine 阻塞、上下文切换频繁,最终造成性能瓶颈。 2.为什么会出现“重锁”问题 1.临界区太大(锁保护的范围过广); 2.频繁写操作导致锁争用; 3.使用全局变量或共享状态; 4.没有分片(sharding)或局部化锁机制; 5.锁中包含耗时操作(例如网络请求、磁盘 I/O)。 var mu sync.Mutex var cache = make(map[string]string) func Set(k, v string) { mu.Lock() defer mu.Unlock() cache[k] = v } #当高并发调用 Set() 时,所有 goroutine 都在争抢同一把 mu,这就形成“重锁”。 3.优化思路与替代方案 3.1 使用 sync.Map 适用于读多写少的场景: var m sync.Map m.Store("a", 1) v, _ := m.Load("a") #sync.Map 内部采用分片和原子操作,避免了全局锁竞争。 3.2 使用原子操作(sync/atomic) 适用于简单的计数、标志位等操作: var count int64 atomic.AddInt64(&count, 1) #无锁化操作,性能更高,且不阻塞其他 goroutine。 3.3 优化锁粒度(细化锁) 将一把全局锁拆分成多把局部锁: ...
什么是零拷贝?
零拷贝(Zero-Copy)是一种计算机操作技术,主要应用于高性能网络和文件 I/O 领域。它的核心目标是减少 CPU 在传输数据时进行不必要的内存数据拷贝,以及减少用户空间和内核空间之间的上下文切换次数。 1.核心原理 ? 在传统的 I/O 操作中,数据通常需要经历四次拷贝才能完成传输(例如将文件通过网络发送给客户端): 第一次拷贝: 数据从磁盘读取到操作系统内核的缓冲区(通常是 Page Cache)。 第二次拷贝: 数据从内核缓冲区拷贝到应用程序的用户缓冲区。 第三次拷贝: 数据从用户缓冲区拷贝回内核的 Socket 缓冲区。 第四次拷贝: 数据从 Socket 缓冲区拷贝到网络接口卡(NIC)的缓冲区,最终发送。 零拷贝技术通过特定的系统调用和硬件支持,消除了步骤 2 和 3 的 CPU 拷贝。 2.常见的零拷贝实现方式 2.1 sendfile 这是最常见的零拷贝实现,例如 Apache Kafka 和 Nginx 等 Web 服务器广泛使用它来高效传输文件数据。 实现机制: 它将数据从一个文件描述符直接传输到另一个文件描述符(例如从磁盘文件 FD 到网络 Socket FD)。 消除拷贝: sendfile 允许数据在内核缓冲区和 Socket 缓冲区之间直接传输,跳过了用户缓冲区,从而消除了两次 CPU 拷贝。 2.2 内存映射文件 (mmap) 内存映射文件技术通过 mmap 系统调用将文件内容直接映射到进程的虚拟地址空间。 实现: 应用程序通过指针直接读写映射的内存地址,而这个地址对应的物理内存正是内核缓冲区。 消除拷贝: 它消除了数据从内核缓冲区拷贝到用户缓冲区的步骤,因为内核缓冲区和用户空间共享了同一块物理内存。 3.零拷贝的优势 降低 CPU 开销: 减少了 CPU 进行数据拷贝的工作量。 减少延迟: 数据传输路径更短。 提高吞吐量: 特别适用于高并发、I/O 密集型的场景,如文件服务器、Web 服务器和消息中间件(如 Kafka)。
垃圾回收机制 Garbage Collection
1.垃圾回收的认识 1.1垃圾回收是什么,有什么作用 GC,全称 Garbage Collection,即垃圾回收,是一种自动内存管理的机制。 当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请 时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而 负责垃圾回收的程序组件,即为垃圾回收器。 垃圾回收其实是一个完美的“Simplicity is Complicated”的例子。一方面,程序员受益于 GC,也不再需要对内存进行手动的申请和释放操作,GC 在程序运行时自动释放残留的内存。另一 方面,GC 对程序员几乎不可见,仅在程序需要进行特殊优化时,通过提供可调控的 API,对 GC 的运行时机、运行开销进行把控的时候才得以现身。 通常,垃圾回收器的执行过程被划分为两个半独立的组件: 1)赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户 态的代码仅仅只修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上 进行操作。 2)回收器(Collector):负责执行垃圾回收的代码。 1.2常见的垃圾回收的实现方式有哪些,Go使用的是什么 所有的 GC 算法其存在形式可以归结为追踪(Tracing)和引用计数(Reference Counting)这 两种形式的混合运用。 (1)追踪式 GC 从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的 对象,从而回收所有可回收的对象。Go、 Java、V8 对 JavaScript 的实现等均为追踪式 GC。 (2)引用计数式 GC 每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较 多,在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式 GC。 比较常见的 GC 实现方式包括: 1)追踪式,分为多种不同类型,例如: 标记清扫:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象。 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内 存上。 2)增量式:将标记与清扫的过程分批执行,每次执行很小的部分,从而增量推进垃圾回收,达到 近似实时、几乎无停顿的效果。 3)增量整理:在增量式的基础上,增加对对象的整理过程。 4)分代式:将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于 某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不 长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。
你会使用chan吗?
chan 是 Go 语言并发编程中最核心的概念之一,它是 Channel(通道) 类型的关键字缩写。 Channel 的设计理念源于通信顺序进程(CSP, Communicating Sequential Processes),它提供了一种安全、同步的方式,让不同的 Goroutine(并发执行的“工人”)之间可以进行通信和数据交换。 1.什么是 chan (通道)? Channel 可以被理解为一个管道或队列,它具有以下核心特性: 类型安全: Channel 只能传输它在创建时指定的特定类型的数据。 例如:chan int 只能传输 int 整数。 同步机制: Channel 默认会阻塞发送和接收操作,直到另一端准备好。 并发安全: Go 运行时保证了对 Channel 的发送和接收操作是线程安全的,无需额外的锁(sync.Mutex)。 2.chan 怎么使用? 使用 Channel 主要分为三个步骤:创建、发送、接收。 2.1 创建 Channel 使用 make 函数创建 Channel。 //无缓冲通道 (Unbuffered) ch := make(chan Type) //容量为 0。发送和接收操作必须同时准备好,否则先执行的操作会一直阻塞,直到另一个操作发生。用于严格的同步。 ch := make(chan Type, N) //有缓冲通道 (Buffered) //容量为 N。通道可以存储 N个元素。只有当通道满了(发送)或空了(接收)时,操作才会阻塞。用于解耦和提高吞吐量。 dataCh := make(chan string) // 无缓冲,用于同步信号 taskCh := make(chan int, 10) // 有缓冲,容量为 10,用于传输任务 类型 语法 目的 切片 make([]Type, length, capacity) 分配底层数组,设置切片的长度和容量。 映射 make(map[KeyType]ValueType, capacity) 分配和初始化哈希表结构。 通道 make(chan Type, capacity) 创建通道并设置其缓冲大小。 2.2 发送数据 使用箭头操作符 <- 将数据发送到 Channel。 ...