头图

今天分享的面经是小米的一面,之前还没有分享过相关的,来看看难度如何。

内容我都整理好了:

  1. 自我介绍+项目问题

    2. 讲讲GOLANG的CSP和三色标记法

    CSP 是一种并发编程模型,而三色标记法是一种垃圾回收算法。

Golang 的 CSP 模型

Go 语言的创建者们受到 CSP 模型的影响,在设计 Go 时引入了 goroutines 和 channels 来简化并发编程。CSP 模型强调通过消息传递来实现进程间的通信,而不是共享内存。在 Go 中:

  • Goroutines:是轻量级的线程,由 Go 运行时调度。程序员可以轻松地启动成千上万个 goroutines。
  • Channels:是用于 goroutine 之间通信的管道。它们提供了一种类型安全的方式来进行同步和通信,避免了传统多线程编程中常见的竞态条件等问题。

使用 CSP 模型,开发者可以通过简单的语法和结构化的方式来编写高效的并发程序,这大大降低了并发编程的复杂性

Golang 的三色标记法

三色标记法是一种用于垃圾回收(Garbage Collection, GC)的技术,它将堆中的对象分为三种颜色:白色、灰色和黑色,以追踪哪些对象是可达的,哪些是不可达的(即垃圾),从而进行回收。

  • 白色对象:表示这些对象尚未被垃圾回收器访问到,被认为是潜在的垃圾对象。
  • 灰色对象:表示这些对象已被垃圾回收器访问到,但是还需要进一步处理其引用的对象。
  • 黑色对象:表示这些对象已经被完全扫描过,确定为存活对象。

三色标记法的过程大致分为以下几个阶段:

  1. Mark Setup (标记准备) :开启写屏障等准备工作。
  2. Marking (标记) :从根集合开始,递归地将可达对象标记为灰色或黑色。
  3. Mark Termination (标记结束) :关闭写屏障,并计算下一次清理的目标。
  4. Sweeping (清理) :回收所有白色的不可达对象。

为了确保三色标记的正确性,Golang 使用了插入屏障删除屏障等机制来维护所谓的“三色不变式”,防止在并发环境中出现对象丢失的问题。

3. 为什么说索引会加速mysql的查询?背后原理是什么?

索引在MySQL中可以显著加速查询的速度,其背后的原理主要依赖于它如何优化数据的访问路径。

索引的工作原理

  1. 减少I/O操作:当没有索引时,MySQL需要扫描整个表(全表扫描)来找到符合条件的数据行,这可能涉及到大量的磁盘读取操作。而有了索引之后,MySQL可以通过索引来定位数据的位置,减少了不必要的磁盘I/O次数。
  2. 二分查找法:对于B-Tree或B+Tree类型的索引,它们允许使用二分查找算法,将每次查找的时间复杂度降低至O(log n),即对数级别。这意味着随着数据量的增长,查找所需的时间不会成比例增加。
  3. 范围查询的支持:某些类型的索引(如B-Tree/B+Tree)不仅支持等值匹配,还能够高效处理范围查询(例如><BETWEEN等)。这是因为这些索引按照键值有序排列,使得范围内的记录可以连续地存放在一起。
  4. 排序与分组优化:如果查询包含ORDER BYGROUP BY子句,并且所涉及的列已经建立了适当的索引,则MySQL可以直接利用索引顺序返回结果集,避免了额外的排序步骤。
  5. 覆盖索引:如果一个索引包含了查询所需的所有字段(即所谓的“覆盖索引”),那么MySQL可以直接从索引中获取数据,而不需要回表查找实际的数据行,进一步提高了性能。
  6. 最左前缀原则:对于复合索引(多列索引),MySQL会根据最左前缀原则来决定是否可以使用该索引。例如,如果你有一个(col1, col2)的复合索引,那么查询条件中只要包含了col1就可以用到这个索引;但如果只提供了col2作为查询条件,则无法使用此索引。
  7. 减少锁争用:通过减少需要扫描的数据量,索引还可以间接减少并发查询之间的锁冲突,提高系统的整体吞吐量。

4. 讲一讲聚簇索引和非聚簇索引与回表查询的联系

聚簇索引和非聚簇索引是数据库中两种不同类型的索引,它们在数据存储方式以及查询性能上有着重要的区别。

回表查询是指当通过非聚簇索引查找数据时,如果索引中不包含查询所需的所有列,则需要再次访问实际的数据行来获取剩余的信息。

聚簇索引

  • 数据存储:在一个表上只能有一个聚簇索引,因为聚簇索引决定了数据行在磁盘上的物理存储顺序。这意味着表中的记录按照聚簇索引键的排序进行物理存放。
  • 查询效率:对于基于聚簇索引键的查询,可以直接定位到相应的数据行,无需额外的I/O操作。因此,对于等值匹配、范围扫描等操作,聚簇索引通常能提供更高的查询性能。
  • 回表查询:由于聚簇索引包含了完整的行数据,所以在大多数情况下不需要回表查询,除非查询条件涉及到了不在聚簇索引中的列。

非聚簇索引

  • 数据存储:非聚簇索引是独立于数据行存储的,它只包含索引列和指向对应数据行的指针(通常是主键或聚簇索引键)。一个表可以有多个非聚簇索引。
  • 查询效率:非聚簇索引能够加速对特定字段的查询,特别是当这些字段不是聚簇索引的一部分时。然而,当查询涉及到非索引列时,就可能需要进行回表查询。
  • 回表查询:当使用非聚簇索引来查找数据时,如果查询所需的全部列没有被包含在该索引内,MySQL必须先从非聚簇索引中找到对应的聚簇索引键(或行指针),然后再根据这个键去访问实际的数据行以获取其他信息。这种两次访问的过程就是所谓的“回表”。

回表查询的影响

  • 性能影响:回表查询增加了额外的I/O开销,因为它要求数据库引擎执行两次查找:一次是在非聚簇索引中查找,另一次是从表中读取完整行数据。这会导致查询速度变慢,尤其是在高并发环境下。
  • 优化策略

    • 覆盖索引:尽量创建包含所有查询所需字段的复合索引,使得查询可以在索引层完成而不需要回表。
    • 选择合适的索引列:确保经常用于过滤、排序或分组的列被包含在索引中,以减少回表查询的可能性。
    • 评估索引成本:考虑到维护索引所带来的插入、更新和删除操作的成本,合理权衡索引的数量和类型。

5. MYSLQ有哪些锁

MySQL 中主要的锁类型包括:

  • 表级锁(Table-level Locks):锁定整个表,分为读锁和写锁。读锁允许多个事务同时持有,但不允许写入;写锁则是排他的,确保只有一个事务可以写入。
  • 行级锁(Row-level Locks):仅锁定被操作的数据行,适用于InnoDB存储引擎,提供更高的并发性。
  • 页级锁(Page-level Locks):锁定数据页,介于表级锁和行级锁之间,较少使用,例如在BDB(Berkeley DB)存储引擎中。
  • 意向锁(Intention Locks):表示事务想要在表中的某些行上获取锁,用于处理多粒度锁定问题。
  • 元数据锁(Metadata Lock, MDL):控制对表结构的访问,防止在查询过程中表结构被修改。
  • 间隙锁(Gap Locks)临键锁(Next-Key Locks)插入意向锁(Insert Intention Locks):这些是InnoDB特有的锁机制,主要用于防止幻读和其他并发问题。

6. mysql事务隔离级别

上一篇面经有个一模一样的问题,想知道详细答案的可以看我上一篇分享的内容。

7. redis实现分布式锁用哪些命令?

在 Redis 中实现分布式锁,通常会使用以下命令。这些命令能够确保锁的原子性和排他性,从而保证在分布式环境中只有一个客户端能获得锁。以下是常用的命令及其简要说明:

  1. SETNX (SET if Not eXists)

    • 用于尝试设置一个键值对,只有当键不存在时才会成功。这可以用来模拟加锁的行为。
    • 命令格式:SETNX key value
    • 如果返回1表示成功获取锁;如果返回0表示已经有其他客户端持有该锁。
  2. EXPIRE 或者 SET ... EX

    • 设置或更新键的有效期(TTL, Time To Live),以防止死锁。可以在 SETNX 成功之后立即调用 EXPIRE 来设置过期时间,或者直接使用 SET key value EX seconds 在一个原子操作中完成这两步。
  3. GETSET

    • 获取当前键的值并设置新值。虽然这不是创建锁的标准方法,但在某些场景下可用于锁的续期或释放。
    • 命令格式:GETSET key value
  4. DELUNLOCK Script

    • 释放锁。重要的是,在删除键之前应该检查这个锁是否是由当前客户端持有的(例如通过比较存储在键中的值)。为了安全地执行这个操作,通常会使用 Lua 脚本来确保这一过程是原子性的。
    • 使用 Lua 脚本的例子如下:

      EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock_key client_value
  5. EVAL / EVALSHA

    • 执行 Lua 脚本,常用于实现更复杂的锁逻辑,比如加锁、解锁和续期等操作。Lua 脚本提供了一种原子化的方式来进行多步骤的操作,确保了在高并发环境下的正确性。

Redlock 算法

对于更高的一致性和可靠性需求,Redis 还提供了 Redlock 算法,它是一个基于多个独立 Redis 实例的分布式锁算法。Redlock 的思想是在多个 Redis 实例上同时尝试获取锁,并根据多数原则决定最终是否成功获取锁。这增加了系统的容错能力。

8. GOLANG的map该如何去解决并发安全问题?

在 Go 语言中,内置的 map 并不是线程安全的数据结构,即它不支持多个 goroutine 同时进行读写操作。如果多个 goroutine 同时对同一个 map 进行写入或更新,可能会导致竞争条件(race condition),进而引发程序崩溃或不可预测的行为。

为了确保 map 在并发环境下的安全性,可以采取以下几种方法:

1. 使用 sync.Map

Go 标准库提供了 sync.Map,这是一个专门为并发访问设计的映射类型。它内部实现了锁机制来保证并发安全,适用于那些需要频繁读取但偶尔才更新的情况。sync.Map 提供了基本的键值对存储功能,并且比普通 map 更加高效地处理并发场景。

2. 使用互斥锁 (Mutex)

对于更复杂的逻辑或者你需要控制多个资源之间的同步时,可以使用 sync.Mutex 或者 sync.RWMutex 来保护 map 的访问。这种方式允许你在代码中显式地定义哪些部分是临界区(critical section),从而避免竞态条件。

  • sync.Mutex:提供了一个简单的互斥锁,一次只允许一个 goroutine 访问被锁定的代码段。
  • sync.RWMutex:提供了读写锁,允许多个读者同时读取数据,但在有写者时会阻止所有其他读写操作。

3. 使用 Channel 和 Select

如果你的应用场景允许,还可以通过 channel 和 select 结构来协调不同 goroutine 对共享资源的访问。这种方法虽然不如前两种直接,但在某些特定情况下可能更为合适。

4. 封装成原子操作

对于简单的计数器或者其他可以表达为原子操作的需求,可以考虑使用 atomic 包提供的函数,尽管这并不直接适用于 map 类型。

9. sync.map的实现原理了解吗?

sync.Map 是 Go 标准库提供的一个并发安全的映射类型,其设计目的是为了在高并发环境下提供高效的读写操作。它内部实现了一些优化措施来减少锁争用,并且针对不同的访问模式进行了专门的设计。下面是 sync.Map 的一些关键实现原理:

1. 内部结构

sync.Map 的核心是由多个字段组成的结构体,其中包括:

  • mu :互斥锁(Mutex),用于保护对整个 sync.Map 的修改。
  • readswrites :两个缓存层,分别用于存储读取和写入的数据项。这两个字段都是无锁的数据结构,旨在提高并发读取时的性能。
  • dirty :一个未同步的 map (map[interface{}]*entry),用来存放最近更新或新插入的键值对。dirty 中的元素最终会被迁移到 reads 中。
  • misses :计数器,记录自上次清理以来未命中次数。当未命中的次数达到一定阈值时,触发一次迁移操作。

2. 读路径

对于读操作,sync.Map 首先尝试从 reads 缓存中查找数据。如果找不到,则会检查 dirty 地图。如果仍然找不到并且存在未命中情况,那么会在持有全局锁的情况下进行一次完整的查找。为了避免频繁的全表扫描,sync.Map 采用了一种懒惰的方式处理未命中——只有在累积了一定量的未命中之后才会执行全面搜索并更新 reads 缓存。

3. 写路径

写操作总是通过获取全局锁来进行,以确保线程安全。每次写入后,相应的键值对会被添加到 dirty 地图中。随着写操作的增加,dirty 地图逐渐变大。当 dirty 地图变得足够大或者发生了多次未命中时,sync.Map 会将 dirty 地图的内容复制到一个新的 reads 缓存中,同时清空 dirty 地图,以此来保证后续读操作能够快速定位到最新的数据。

4. 清理与迁移

sync.Map 有一个内置的机制来定期清理过期的数据以及将 dirty 地图中的最新数据迁移到 reads 缓存中。这个过程是自动完成的,不需要开发者手动干预。迁移操作会在适当的时候触发,例如当检测到较多的未命中时。

5. 空间换时间

sync.Map 采用了空间换时间的策略,即牺牲一定的内存占用来换取更好的并发性能。比如,它允许 dirty 地图和 reads 缓存之间存在一定的时间差,这期间可能会有重复的数据存在,但这样可以显著减少全局锁的使用频率,从而提高了整体效率。

10. 讲讲map的扩容机制

map 中的元素数量增加到一定程度时,它会触发扩容操作以适应更多的键值对存储需求。

初始分配

  • 当你创建一个新的 map 时,Go 运行时会为其分配一个初始的桶(bucket)数组。每个桶可以容纳若干个键值对(通常是8个)。这个初始大小是根据估计的使用情况来决定的,但如果不确定具体的大小,Go 会选择一个较小的默认值。

桶与溢出桶

  • 每个 map 内部由一系列的桶组成,每个桶负责存储一定范围内的键值对。如果一个桶装满了(即达到了最大容量),而又有新的键值对要插入,则该桶会产生一个或多个溢出桶(overflow buckets),这些溢出桶链接在一起形成链表。

扩容条件

  • 负载因子:当 map 的负载因子(即所有桶中存储的键值对总数除以桶的数量)超过某个阈值时,就会触发扩容。通常情况下,当负载因子接近1或者更大时,意味着大部分桶都已经填满,此时进行扩容是有益的。
  • 溢出桶过多:如果某些桶产生了过多的溢出桶,即使整体负载因子还未达到阈值,也可能触发扩容。这是因为过多的溢出桶会导致性能下降,特别是在遍历 map 或者查找特定键时。

扩容过程

  • 复制数据:在扩容过程中,map 会创建一个新的、更大的桶数组,并将现有数据重新分布到新数组中。这包括计算每个键对应的哈希值并确定其在新数组中的位置。为了保证扩容期间的线程安全,Go 在扩容时会对 map 加锁。
  • 两倍增长:每次扩容后,map 的容量通常会翻倍。例如,如果原来的桶数组大小为8,扩容后的大小将是16,以此类推。这种指数级的增长有助于减少频繁的扩容操作。
  • 渐进式迁移:从 Go 1.12 版本开始,引入了渐进式迁移策略。这意味着并不是一次性完成所有数据的迁移,而是随着新键值对的插入逐步完成。这样可以在一定程度上缓解扩容带来的性能冲击。

    11. 遇到panic异常怎么解决?怎么去定位Panic?

    在 Go 语言中,panic 是一种非正常程序终止的方式,通常用于处理不应该发生的情况。当一个 panic 被触发时,程序会立即停止当前的执行流程,并开始回溯调用栈,直到遇到 recover 语句或者程序退出。为了有效地解决和定位 panic 异常,你可以采取以下几个步骤:

解决 Panic

  1. 使用 recover 捕获 panic

    • 在可能引发 panic 的代码段外围包裹一层 deferrecover,以便能够在不期望的情况下捕获异常并优雅地处理它。

      func safeCall() {
        defer func() {
            if r := recover(); r != nil {
                fmt.Println("Recovered from panic:", r)
            }
        }()
        // 可能引发 panic 的代码
      }
  2. 避免不必要的 panic

    • 检查代码逻辑,尽量减少可能导致 panic 的操作,比如数组越界、nil 指针解引用等。通过提前检查条件或使用更安全的方法来替代。
  3. 确保资源正确释放

    • 如果 panic 发生在持有锁或其他资源的情况下,确保这些资源能够被正确释放,以防止资源泄露。可以使用 defer 来保证资源的释放。
  4. 编写健壮的错误处理机制

    • 不要依赖 panic 来进行常规错误处理;相反,应该设计良好的错误返回路径,并在必要时向上传播错误信息。

定位 Panic

  1. 启用调试模式

    • 使用 -race 标志编译和运行你的程序,可以帮助检测并发相关的竞争条件,这可能是导致 panic 的原因之一。
  2. 查看堆栈跟踪信息

    • panic 发生时,Go 会打印出详细的堆栈跟踪(stack trace),包括函数调用链和每层调用的位置。仔细阅读这些信息有助于快速定位问题所在。
  3. 日志记录

    • 在关键位置添加详细的日志输出,特别是围绕那些容易出现问题的地方。这不仅有助于理解程序的执行流,还可以为后续分析提供线索。
  4. 单元测试与集成测试

    • 编写全面的测试用例覆盖各种边界情况,确保所有代码路径都能正常工作。对于已知会导致 panic 的场景,可以在测试中模拟并验证是否得到了适当的处理。
  5. 工具辅助

    • 利用静态分析工具如 golangci-lint 或者动态分析工具如 Delve 调试器,它们可以帮你发现潜在的问题点,甚至直接指向引起 panic 的具体行号。
  6. 二分法排查

    • 如果你无法立即找到问题的原因,可以尝试移除部分代码或将代码分割成更小的部分逐步测试,以此缩小可疑范围。
  7. 环境一致性

    • 确保开发、测试和生产环境尽可能一致,因为某些 panic 可能是由于环境差异引起的。

12. 举例docker的使用场景

Docker 是一种容器化平台,它允许开发者将应用程序及其依赖打包到一个独立的、轻量级的容器中,从而确保应用程序在任何环境中都能一致地运行。以下是 Docker 的一些典型使用场景:

1. 开发环境一致性

  • 问题:不同开发者的机器配置各异,导致“在我的机器上能正常工作”的问题频繁出现。
  • 解决方案:通过 Docker,可以创建统一的开发环境镜像,确保每个开发者使用的工具和依赖版本完全相同。这不仅提高了团队协作效率,还减少了因环境差异带来的调试时间。

2. 持续集成与持续部署(CI/CD)

  • 问题:自动化测试和部署过程中需要频繁切换不同的环境设置。
  • 解决方案:Docker 容器可以在 CI/CD 流水线中快速启动和销毁,提供一致且隔离的测试环境。每次构建都可以从相同的基线开始,保证了结果的可重复性和可靠性。

3. 微服务架构

  • 问题:随着系统规模扩大,管理多个相互依赖的服务变得复杂。
  • 解决方案:每个微服务可以被打包成独立的 Docker 容器,简化了服务之间的部署、扩展和维护。借助 Docker Compose 或 Kubernetes 等工具,还可以轻松定义和管理多容器应用。

4. 应用程序隔离

  • 问题:在同一台物理服务器上运行多个应用程序时,可能会发生资源竞争或冲突。
  • 解决方案:Docker 提供了进程级别的隔离,使得不同应用可以在各自的容器中安全地运行,彼此之间不会互相干扰。同时,Docker 还支持对 CPU、内存等资源进行细粒度控制,确保公平分配。

除此之外还有其他的用法,我就不一一举例了。

代码题:

leetcode 3218.切蛋糕的最小总开销 I

力扣上面有详细的答案,我这里就不多解释了。

欢迎关注 ❤

我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。

没准能让你能刷到自己意向公司的最新面试题呢。

感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:sf面试群。


王中阳讲编程
805 声望297 粉丝