图解学习网站:
大家好,我是小林。
合合信息这家公司估计很多同学没听过,公司主要是在上海,专注于大数据商业应用方向的,C 端合 B 端方向的业务都有涉及。
虽然公司规模不算特别大,目前是千人级别的公司规模,但是薪资开的其实不低,甚至能达到大厂的水平,正好最近看到合合信息秋招正式批开始了,想冲的同学可以做好准备了。
比如去年有同学拿到了合合信息offer,总包勉勉强强也过了40w,这薪资势水平对标大厂的了,整体还是满意的。
不过我感觉合合信息面试要求还是蛮高的,学历一块也比较严格,看到能面合合信息的同学,学历这一块都不差的,前几天发了一篇文章「」,我感觉合合信息也算是卡学历范畴的公司。
而且合合信息面试也不简单,面试强度跟大厂基本差不多,一场面试问的问题不会少,基本是40-60分钟的面试强度。
合合信息公司后端大部分是用Go语言做的,我从合合信息官方招聘网站里截了一张后端研发招聘信息图片如下,可以看到写着「Go语言」优先。
所以这次就来看看合合信息 Go 开发的校招面经,重点拷打了Redis、网络、Golang这些技术点。
虽然拷打的技术点不多,但是每一个技术方向都进行了深挖,还是有点难度的。
合合信息(一面)1. redis了解到什么程度?
Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存,消息队列、分布式锁等场景。
缓存: Redis最常见的用途就是作为缓存系统。通过将热门数据存储在内存中,可以极大地提高访问速度,减轻数据库负载,这对于需要快速响应时间的应用程序非常重要。
排行榜: Redis的有序集合结构非常适合用于实现排行榜和排名系统,可以方便地进行数据排序和排名。
分布式锁: Redis的特性可以用来实现分布式锁,确保多个进程或服务之间的数据操作的原子性和一致性。
计数器由于Redis的原子操作和高性能,它非常适合用于实现计数器和统计数据的存储,如网站访问量统计、点赞数统计等。
消息队列: Redis的发布订阅功能使其成为一个轻量级的消息队列,它可以用来实现发布和订阅模式,以便实时处理消息。
Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
随着 Redis 版本的更新,后面又支持了四种数据类型:BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。Redis 五种数据类型的应用场景:
String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
Hash 类型:缓存对象、购物车等。
Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
Zset 类型:排序场景,比如排行榜、电话和姓名排序等。
Redis 后续版本又支持四种数据类型,它们的应用场景如下:
BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。
Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。
但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:
Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。
之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。
4. 你觉得为什么改成多线程?
虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解Redis 有多线程同时执行命令。
Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。
Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。
//读请求也使用io多线程
io-threads-do-reads yes
同时, Redis.conf 配置文件中提供了 IO 多线程个数的配置项。
// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4
关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。
因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会额外创建 6 个线程(这里的线程数不包括主线程):
Redis-server : Redis的主线程,主要负责执行命令;
bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。
常见的有主从复制,就是一个主节点带多个从节点,主节点负责写操作,从节点同步主节点的数据并处理读请求,这样能分担压力,也能做数据备份。
然后是哨兵模式,它其实是在主从复制基础上增加了哨兵进程,这些哨兵会监控主从节点的状态。要是主节点挂了,哨兵们会通过投票选举一个从节点升级成新主节点,自动完成故障转移,不用人工干预,这样就提高了系统的可用性。
再说说集群模式,这是为了应对大规模数据和高并发的。集群把数据分片存储在多个节点上,每个节点负责一部分哈希槽,槽的分配和迁移都是自动的。集群里每个节点会和其他节点通信,了解整个集群的状态,而且每个节点都有从节点做备份,就算某个主节点挂了,对应的从节点能顶上,保证集群正常工作。
简单说,主从是基础,哨兵解决了主从的自动故障转移,集群则通过分片扩展了存储能力和并发处理能力,各有各的适用场景,得根据实际需求来选。
6. kafka了解嘛?es?常见的开源组件?
我对 Kafka、ES 这些开源组件都有一定了解,平时工作中也常会接触到。
Kafka 算是消息队列里的佼佼者,主要用来处理高吞吐的实时数据流,比如日志收集、事件驱动架构里的消息传递。它的核心是分区和副本机制,分区能让数据并行处理,副本则保证了数据不丢,所以能扛住每秒几十万的消息量,很多大厂用它做数据管道或者实时分析的基础。
ES 也就是 Elasticsearch,是个分布式搜索引擎,基于 Lucene 做的,最擅长全文检索和日志分析。它把数据存在分片里,分片还能有副本,既能扩容量又能保证高可用。平时我们在系统里做模糊搜索、按条件过滤大量数据,用 ES 就很合适,比如电商的商品搜索、日志平台的查询,响应速度很快,还支持各种复杂的聚合分析。
除了这两个,常见的开源组件还有不少。比如 Redis,作为缓存和中间件,能做分布式锁、计数器,支持多种数据结构,性能特别好。数据库方面,MySQL 是关系型里的代表,PostgreSQL 功能更全,适合复杂业务;非关系型的像 MongoDB,存文档数据很方便,适合内容管理类场景。
7. tcp连接常见状态?
比如刚开始建立连接时的 SYN_SENT,就是客户端发了 SYN 包在等服务器回应;服务器收到后会进入 SYN_RCVD 状态,准备发 SYN+ACK。连接成功建立后,双方就处于 ESTABLISHED 状态,这时候就能正常传数据了。
断开连接的时候状态更多,像客户端主动关闭会发 FIN 包,之后进入 FIN_WAIT_1;收到服务器的 ACK 后到 FIN_WAIT_2,等服务器发 FIN。服务器这边呢,收到 FIN 后先到 CLOSE_WAIT,处理完数据再发 FIN,进入 LAST_ACK,等客户端的 ACK。最后客户端收到 FIN 和 ACK 后,会进入 TIME_WAIT 状态,这个状态要等一段时间确保没残留数据,之后才到 CLOSED。另外还有个 LISTEN,是服务器监听端口时的状态。这些状态都是为了保证 TCP 连接的可靠建立和关闭。
8. 为什么三次握手,不是四次握手?
TCP 三次握手的设计主要是为了高效、可靠地建立连接,避免资源浪费,同时解决网络环境中的一些特殊问题。我们可以从三个方面来理解为什么是三次握手,而不是四次。
首先,三次握手是保证双方初始序列号(ISN)同步的最小次数。TCP 连接的两端都需要各自生成一个初始序列号,用于标识传输的数据段。客户端发送 SYN 包,携带自己的 ISN,服务器收到后,不仅要确认客户端的 ISN(通过 ACK 包),还要同时发送自己的 ISN(通过 SYN 包)。如果把这两步分开,变成先 ACK 客户端的 SYN,再单独发送自己的 SYN,就会多一次往返,变成四次握手。但实际上这两个操作可以合并,服务器用一个 SYN+ACK 包就完成了,这样就节省了一次网络往返。
其次,三次握手可以有效防止历史连接的初始化。在网络环境中,数据包可能会延迟、重复甚至乱序。如果采用四次握手,假设客户端发送的第一个 SYN 包因为网络延迟,在连接关闭后才到达服务器,服务器如果直接回应 ACK 和 SYN,就可能建立一个错误的连接。而三次握手通过第三次 ACK 确认,客户端可以判断这个 SYN 是不是历史连接的残留,如果是,客户端会发送 RST 包终止连接。四次握手反而可能因为步骤更多,增加这种误判的风险。
最后,从性能和资源利用角度看,三次握手已经足够。TCP 连接的建立需要消耗服务器资源,如果每次连接都多一次往返,会增加网络延迟和服务器负担。三次握手在保证可靠性的前提下,尽可能减少了通信次数。比如在高并发场景下,大量客户端同时连接服务器,四次握手会让服务器处理更多的数据包,增加 CPU 和内存消耗,甚至可能导致资源耗尽。
TCP 挥手需要四次是因为它要确保双方都能可靠地关闭连接,这和 TCP 的全双工特性有关。想象一下,客户端和服务器就像两个正在通话的人,都可以随时说话和听对方说话。当客户端想结束通话时,它会先告诉服务器 “我说完了”(FIN 包),这是第一次挥手。服务器收到后,得先回应 “我知道你说完了”(ACK 包),这是第二次挥手。但此时服务器可能还有话没说完,所以它会继续把剩下的内容说完,等说完了,再告诉客户端 “我也说完了”(FIN 包),这是第三次挥手。客户端收到后,最后回应 “好的,知道了”(ACK 包),这是第四次挥手。
如果把这四次简化成三次,就可能出现数据丢失的情况。比如服务器收到客户端的 FIN 后,直接发 FIN+ACK,万一客户端没收到这个包,服务器就以为客户端已经知道自己说完了,但实际上客户端还在等服务器的 FIN,这样就会导致连接无法正常关闭。所以四次挥手是为了保证双方都能明确知道对方已经没有数据要发送了,从而安全地关闭连接。
10. golang强类型,弱类型?
Golang是一门强类型语言,这意味着在编译阶段,每个变量和表达式的类型都必须明确,并且类型之间的转换需要显式进行。
这种设计带来了很多好处,比如代码的安全性更高,编译器可以提前发现类型不匹配的错误,减少运行时崩溃的风险。例如,你不能直接把一个整数赋值给一个字符串类型的变量,必须通过像strconv.Itoa这样的函数进行转换。而且,Golang的类型系统非常严格,即使两个类型的底层结构相同,但如果它们是不同的命名类型,也不能直接相互赋值。 强类型的特性也让Golang代码更加清晰和可维护。因为类型信息明确,开发者可以更容易理解代码的意图,IDE也能提供更准确的代码补全和提示。
不过,这并不意味着Golang的类型系统很死板,它也提供了一些灵活的机制,比如接口和类型断言,让开发者在保持类型安全的同时,还能实现多态和动态类型检查。
11. go内置的数据类型哪些是值传递,哪些是引用传递?
Golang里所有的内置类型传递都是值传递,不过有些类型表现得像引用传递,这其实是底层实现造成的假象。像int、float、bool、string这些基本类型,传递时会复制整个值,函数内部对参数的修改不会影响原始数据。结构体也是值传递,哪怕结构体很大,传递时也会完整复制一份。
而slice、map、channel和指针,虽然看起来像引用传递,但本质上还是值传递。以slice为例,它的底层是一个包含指针、长度和容量的结构体,传递slice时,复制的是这个结构体,而不是底层数组。所以函数内部可以通过这个复制的结构体修改底层数组的数据,但如果对slice本身进行扩容等操作,原始slice是不受影响的。
map和channel也是类似,传递的是指向底层数据结构的指针的复制,因此可以修改原始数据。指针就更明显了,传递的是地址的复制,通过这个复制的地址依然能修改原始数据。
所以,Golang里没有真正的引用传递,只有值传递。那些看起来像引用传递的类型,是因为它们的底层实现包含了指针,复制的是指针的值,从而可以间接修改原始数据。
12. golang怎么解决同步互斥问题?
Golang 解决同步互斥问题主要依赖语言内置的并发原语和设计哲学。
最直接的方式是使用sync包中的互斥锁(Mutex和RWMutex),通过Lock和Unlock方法保护临界区,确保同一时间只有一个 goroutine 访问共享资源。读写锁则允许并发读,提高性能。对于更复杂的场景,可以用sync.WaitGroup等待一组 goroutine 完成,或用sync.Once实现单例模式的安全初始化。
Golang 还倡导 “以通信共享内存” 而非 “以共享内存通信”,推荐使用通道(channel)实现同步。通过make(chan int, bufferSize)创建带缓冲或无缓冲的通道,利用其阻塞特性协调 goroutine 执行顺序。例如,无缓冲通道的发送和接收操作会同步阻塞,自然形成执行屏障。
此外,context包可用于控制并发超时、取消操作,避免资源泄漏。对于原子操作,sync/atomic包提供了底层的原子加减法等功能,适用于计数器等简单场景。
Golang 使用 goroutine 作为基本并发单元,每个 goroutine 初始仅需 2KB 栈空间,由 Go 运行时(runtime)的调度器(GPM 模型)管理,可轻松创建上百万个。调度器将多个 goroutine 映射到少量 OS 线程上,通过协作式和抢占式调度平衡负载。
CSP 模型通过 channel 实现 goroutine 间的通信和同步。channel 作为类型安全的管道,提供内置的同步机制:无缓冲 channel 的发送和接收操作会阻塞,直到配对操作就绪;带缓冲 channel 在容量满 / 空时才阻塞。这种阻塞特性自然实现了 goroutine 间的执行顺序控制。
sync 包提供传统并发原语:Mutex 和 RWMutex 保护临界区,WaitGroup 等待多个 goroutine 完成,Once 确保代码只执行一次。原子操作(sync/atomic)则通过 CPU 指令保证变量修改的原子性。context 包用于传递请求范围的数据、取消信号和截止时间,控制多级 goroutine 的生命周期。
14. golang的map并发读写问题?
Golang 里的 map 可不是线程安全的,要是多个 goroutine 同时对它进行读写操作,很容易出问题,甚至可能直接让程序崩溃。这是因为 map 的底层实现没做同步处理,并发修改时会导致哈希表结构被破坏。
那怎么解决呢?最常用的就是加锁,比如用 sync.Mutex 或者 sync.RWMutex。读多写少的场景用读写锁更合适,能允许多个读操作同时进行,写的时候再加独占锁,效率会高一些。
另外,也可以用 sync.Map,这是 Go 1.9 之后标准库提供的线程安全 map,它内部通过分离读写映射和原子操作来实现并发安全,适合读多写少的场景。不过它的性能在某些情况下可能不如加锁的普通 map,所以得根据实际情况选。
总之,普通 map 不能直接并发读写,必须自己加锁保护,或者用 sync.Map,不然程序很容易出问题。
15. sync.Map如何解决并发问题?
sync.Map 解决并发问题的思路挺巧妙的,它内部用了两个 map,一个叫 readOnly,一个是 dirty。读操作主要跟 readOnly 打交道,这部分是原子操作,不用加锁,所以读的时候效率很高。如果在 readOnly 里没找到数据,才会去 dirty 里找,这时候就得加锁了。
写操作的时候呢,会先检查 key 是不是已经在 readOnly 里了,如果是,就用原子操作标记一下,然后往 dirty 里写。如果 key 本来就在 dirty 里,直接加锁修改就行。另外,它还有个 misses 计数器,当读操作在 readOnly 没找到,去 dirty 找的次数够多了,就会把 dirty 提升成新的 readOnly,这样后续读操作又能直接用原子操作了。
这种设计特别适合读多写少的场景,因为大部分读操作不用加锁,能并发进行,写操作虽然有锁,但通过两个 map 的切换减少了锁的竞争,所以整体并发性能还不错。
16. 项目
项目中最有挑战的地方是什么?如果解决的?