拼多多服务端开发,我冲了!
文末有面经共享群
今天分享拼多多的服务端开发面经,涉及到的知识点有go语言底层逻辑、MySQL优化策略、Redis缓存淘汰和计网的一些相关知识等等,内容整理如下:
面经详解
自我介绍 项目的各种细节
3. 服务发现一般可以怎么做
基于 DNS 的服务发现:
利用 DNS 系统来存储服务的域名和对应的 IP 地址信息。 例如,当客户端需要访问某个服务时,通过查询 DNS 服务器获取服务的 IP 地址。 优点是广泛使用和易于理解,缺点是更新可能不够及时,且可能存在缓存导致的延迟。
例如使用 ZooKeeper、Consul 或 etcd 等工具作为集中的注册中心。 服务在启动时向注册中心注册自己的信息,包括服务名称、IP 地址、端口等。 客户端从注册中心获取服务的信息。 这种方式具有较高的可控性和灵活性。
客户端直接与服务注册中心交互,获取可用服务的列表。 客户端负责选择要调用的具体服务实例,并处理故障转移和负载均衡。 比如,一个电商网站的客户端直接从注册中心获取支付服务的多个实例信息,并选择其中一个进行调用。
客户端的请求首先到达一个负载均衡器或网关。 负载均衡器或网关从注册中心获取服务实例信息,并将请求转发到合适的服务实例。
服务通过某种机制自动发现其他相关服务,例如通过广播或组播消息。 但这种方式可能会在大规模环境中造成网络拥塞。
如果应用部署在云平台上,如 AWS、Azure 等,可利用云平台提供的原生服务发现机制。
4. InnoDB 引擎的特性
支持事务
事务是数据库操作的基本单位,它是一组数据库操作的集合,要么全部执行成功,要么全部不执行。InnoDB存储引擎支持ACID事务,保证了数据库的可靠性和一致性。在MySQL中,使用START TRANSACTION语句开启一个事务,使用COMMIT语句提交事务,使用ROLLBACK语句回滚事务。
下面是一个使用InnoDB存储引擎的银行账户交易记录的例子:
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;
在这个例子中,我们首先开启一个事务,然后执行两个UPDATE语句,分别更新了账户1的余额减少100元,账户2的余额增加100元。最后,我们提交事务,将两个更新操作作为一个整体进行提交。
如果在执行UPDATE语句的过程中发生了错误,那么事务会被回滚,保证了数据的一致性。
支持行级锁
行级锁是一种并发控制机制,它可以在不同事务之间实现数据的隔离性和一致性。InnoDB存储引擎支持行级锁,能够提高并发性能和可扩展性。
在MySQL中,可以使用SELECT … FOR UPDATE语句来获取行级锁,保证在更新操作时,其他事务不能修改该行数据。
下面是一个使用InnoDB存储引擎的用户列表的例子:
START TRANSACTION;
SELECT * FROM user WHERE id = 1 FOR UPDATE;
-- 对该行进行修改操作
UPDATE user SET name = 'new_name' WHERE id = 1;
COMMIT;
在这个例子中,我们首先开启一个事务,然后使用SELECT … FOR UPDATE语句获取了ID为1的用户数据的行级锁。在获得锁之后,我们对该行数据进行了修改操作,最后提交事务。
如果在获得锁的过程中发生了错误,或者其他事务尝试修改该行数据,那么事务会被回滚,保证了数据的一致性。
支持MVCC
MVCC是一种多版本并发控制机制,它可以在不同事务之间实现数据的隔离性和一致性。InnoDB存储引擎支持MVCC,能够保证不同事务之间的隔离性和一致性。在MySQL中,可以使用READ COMMITTED隔离级别来支持MVCC机制。在READ COMMITTED隔离级别下,每个事务只能看到已提交的数据版本,不能看到其他事务未提交的数据版本。
下面是一个使用InnoDB存储引擎的商品列表的例子:
START TRANSACTION;
SELECT * FROM product WHERE id = 1;
-- 对该行进行修改操作
UPDATE product SET price = 20 WHERE id = 1;
COMMIT;
在这个例子中,我们首先开启一个事务,然后使用SELECT语句获取了ID为1的商品数据。在获取数据的过程中,我们只能看到已提交的数据版本。在获取数据之后,我们对该行数据进行了修改操作,最后提交事务。
如果在获取数据的过程中发生了错误,那么事务会被回滚,保证了数据的隔离性和一致性。
支持外键
在MySQL中,可以使用FOREIGN KEY约束来支持外键约束。在创建表时,可以使用FOREIGN KEY语句定义外键约束,确保在进行数据操作时,关联表之间的数据一致性。
下面是一个使用InnoDB存储引擎的订单列表和商品列表的例子:
CREATE TABLE product (
id INT PRIMARY KEY,
name VARCHAR(255),
price DECIMAL(10, 2)
) ENGINE=InnoDB;
CREATE TABLE order (
id INT PRIMARY KEY,
product_id INT,
quantity INT,
FOREIGN KEY (product_id) REFERENCES product(id)
) ENGINE=InnoDB;
在这个例子中,我们首先创建了商品表product和订单表order。在订单表中,我们定义了一个外键约束,确保product_id列中的数据必须存在于商品表product的id列中。
当我们向订单表中插入数据时,如果插入的product_id不存在于商品表product中,那么会出现外键约束错误,阻止数据的插入操作,确保了数据的一致性。
支持自动增长列
在MySQL中,可以使用AUTO_INCREMENT关键字定义自动增长列。在使用InnoDB存储引擎时,自动增长列的实现方式是在表中创建一个名为AUTO_INCREMENT的隐藏列,该列用于存储下一个自动增长的值。
下面是一个使用InnoDB存储引擎的用户列表的例子:
CREATE TABLE user (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255),
age INT
) ENGINE=InnoDB;
在这个例子中,我们创建了一个用户表user,其中id列使用了AUTO_INCREMENT关键字定义为自动增长列。
当我们向用户表中插入数据时,id列会自动递增,确保每个用户的id都是唯一的。
支持崩溃恢复
InnoDB存储引擎支持崩溃恢复,能够保证数据库在崩溃后可以快速恢复到正常状态。当数据库发生崩溃时,InnoDB存储引擎会自动启动崩溃恢复过程,将未完成的事务进行回滚或者重做,从而保证数据的一致性。
例如,假设数据库中有一个包含订单信息的表。如果数据库崩溃了,那么可能会出现未完成的订单,从而导致数据的不一致性。但是,如果使用InnoDB存储引擎,就能够自动启动崩溃恢复过程,将未完成的订单进行回滚或者重做,从而保证了数据的一致性。
5. MySQL 数据库支持高并发的读写,设计上有哪些可以用的方案?
1、硬件和基础设施优化
使用高性能的服务器硬件,如更快的CPU、更大的内存和高速的存储设备。
确保网络带宽和延迟不会对数据库性能产生负面影响。
使用负载均衡器将请求分发到多个MySQL服务器上,以实现水平扩展。
2、数据库架构优化
读写分离:将读操作和写操作分离到不同的数据库服务器上,以减轻主服务器的负载。可以使用MySQL的主从复制功能实现读写分离。
分库分表:根据业务需求和数据量,将数据分散到多个数据库或多个表中。这有助于减少单一数据库或表的压力,提高并发处理能力。
分布式数据库架构:使用分布式数据库解决方案,如ShardingSphere、Vitess等,实现数据库的水平扩展和负载均衡。
3、SQL和索引优化
优化SQL查询语句,减少不必要的JOIN操作、子查询和复杂的逻辑判断。
合理使用索引,避免全表扫描,提高查询效率。但也要注意不要过度使用索引,以免增加写入操作的开销。
定期对表进行维护,如更新统计信息、重建索引等,以保持数据库的最佳性能。
4、缓存策略
使用缓存技术,如Redis、Memcached等,缓存热点数据和查询结果,减少对数据库的访问次数。
合理使用MySQL的查询缓存功能,但也要注意在数据更新频繁的场景下可能需要禁用查询缓存。
5、连接池管理
使用数据库连接池来管理数据库连接,避免频繁地创建和销毁连接,提高连接的重用性和效率。
合理配置连接池的参数,如最大连接数、空闲连接超时时间等,以适应不同的业务场景。
6、事务和锁策略
合理使用事务和锁机制,确保数据的完整性和一致性。但也要避免长时间持有锁或过度使用锁,以免导致死锁或性能下降。
根据业务需求选择合适的事务隔离级别,如READ COMMITTED、REPEATABLE READ等。
7、监控和调优
使用监控工具(如Prometheus、Grafana、Zabbix等)实时监控MySQL的性能指标,如QPS、TPS、响应时间、连接数等。
根据监控数据进行性能分析和调优,找出性能瓶颈并进行优化。
定期进行数据库审计和性能评估,确保数据库始终保持在最佳状态。
8、其他优化措施
使用MySQL的内置优化工具(如OPTIMIZE TABLE、ANALYZE TABLE等)对表进行优化。
启用MySQL的慢查询日志,分析并优化执行时间较长的查询语句。
定期对数据库进行备份和恢复测试,确保在出现故障时能够快速恢复数据。
6. 了解哪些缓存淘汰策略?
常见的缓存淘汰策略包括以下几种:
1. 最近最少使用(Least Recently Used, LRU)策略:
核心思想:将最近最少使用的数据淘汰,以保留最常用的数据。
实现方式:通过维护一个访问时间的有序链表(或哈希表加双向链表),每次访问数据时,将其移到链表的头部(或更新哈希表中的访问时间)。当缓存容量不足时,淘汰链表尾部的数据(或哈希表中访问时间最久的数据)。
适用场景:适用于数据访问模式具有时间局部性的场景,即最近被访问的数据在未来被再次访问的可能性较高。
2. 最不经常使用(Least Frequently Used, LFU)策略:
核心思想:将访问频率最低的数据淘汰,以保留访问频率较高的数据。
实现方式:通过维护一个访问频率的优先队列(或哈希表加最小堆),每次访问数据时,增加其访问计数(或更新哈希表中的访问频率)。当缓存容量不足时,淘汰访问频率最低的数据。
适用场景:适用于数据访问模式具有频率局部性的场景,即某些数据被频繁访问,而其他数据则很少被访问。
3. 先进先出(First In First Out, FIFO)策略:
核心思想:将最早进入缓存的数据淘汰,以保留最新进入的数据。
实现方式:通过维护一个队列(或链表),新数据添加到队列尾部,当缓存容量不足时,淘汰队列头部的数据。
适用场景:适用于数据访问模式与时间顺序紧密相关的场景,如实时数据流处理。
4. 随机(Random)策略:
核心思想:随机选择一部分数据进行淘汰。
实现方式:通过随机数生成器选择缓存中的数据进行淘汰。
适用场景:在无法准确预测数据访问模式或对数据访问模式不敏感的场景下,可以使用随机淘汰策略。
5. 基于缓存大小的淘汰(Size-based Eviction):
核心思想:当缓存占用内存大小超过预设的容量时,按照某种策略(如LRU、LFU等)淘汰一部分缓存项,以释放空间。
实现方式:结合上述某种淘汰策略,同时考虑缓存项的大小进行淘汰。
6. 基于缓存项的生命周期(Time-to-Live, TTL)淘汰:
核心思想:当缓存项的存活时间超过预设的时间阈值时,自动将其淘汰。
实现方式:为每个缓存项设置存活时间,当时间到达时,将其从缓存中移除。
7. go 的 defer 机制
defer
的实现原理涉及到以下几个关键方面:
数据结构:defer
使用的数据结构是_defer
结构体,定义在src/runtime/runtime2.go
中。该结构体包含了一些重要的字段,如栈指针sp
、程序计数器pc
、函数地址fn
、指向自身结构的指针link
等。
创建和执行:通过源码包src/runtime/panic.go
中的两个方法来实现。
deferproc()
用于创建defer
:在defer
语句的位置被调用,它会将defer
函数存于当前goroutine
的链表中。在 Go 1.13 之前,所有的defer
都在堆上分配,会直接在堆上申请内存,并将_defer
结构体放到当前goroutine
协程的_defer
链表上。申请堆内存时有缓存池设计,每个逻辑处理器有局部缓存池,全局也有一个缓存池。当defer
执行完毕,会放入局部缓存池;局部缓存池容纳足够对象时,会放到全局缓存池;逻辑处理器局部缓存池为空时,会从全局缓存池中取一部分到局部缓存池;未被使用的对象会被垃圾回收。调用时需遍历_defer
链表并将相关参数和函数重新放入栈中,带来额外开销。而 Go 1.13 为解决堆分配效率问题,对于最多只调用一次的defer
采用了在栈上分配的策略,使用deferprocstack()
函数。栈上分配的好处是函数返回后_defer
便释放,无需考虑内存分配的性能开销,只需维护_defer
链表。deferreturn()
用于执行defer
:在return
指令前被调用,它会从链表中取出defer
函数并执行。
简单来说,可以理解为在声明defer
的位置插入了deferproc()
函数,而在函数return
前插入了deferreturn()
函数。
三种机制:
堆上分配(Go 1.13 之前):直接在堆上申请内存来存储 _defer
结构体。栈上分配(Go 1.13 引入):对于满足特定条件(如非循环中、 defer
数量少等)的defer
,使用栈进行分配,性能更好。开放编码(Go 1.14 引入):将延迟调用直接插入到函数返回之前,省去了运行时的 deferproc
或deferprocstack
操作,在运行时的deferreturn
也不会进行尾递归调用,而是直接在一个循环中遍历所有延迟函数执行。使用此机制需满足一些条件,如没有禁用编译器优化、函数内defer
的数量不超过 8 个且返回语句与延迟语句个数的乘积不超过 15、defer
不在循环语句中。此外,该机制还引入了延迟比特deferbit
,用于运行时记录每个defer
是否被执行,其原理是为每个defer
分配 1 个比特,若执行到则设为 1,否则设为 0,在函数返回前通过掩码判断每个位置的比特来决定是否调用相应的延迟函数。
defer
的规则:
延迟函数的参数在 defer
语句出现时就已确定。需注意,对于指针类型参数,规则仍然适用,不过延迟函数的参数是地址值,这种情况下,defer
后面的语句对变量的修改可能会影响延迟函数。延迟函数执行按先进后出顺序,即先出现的 defer
最后执行。延迟函数可能操作主函数的具名返回值。具体来说,函数的 return
语句并非原子级操作,实际执行过程为设置返回值→执行defer
→ret
。对于主函数拥有匿名返回值且返回字面值时,defer
语句不能操作返回值;当主函数拥有匿名返回值且返回变量时,defer
语句可引用返回值,但不会改变返回值;而主函数拥有具名返回值时,defer
语句操作该返回值可能会改变返回结果。
8. go 的 map 是有序还是无序?为什么?
在 Go 语言中,map 是无序的。
这意味着不能依赖遍历 map 时键值对的输出顺序,每次运行程序时,遍历 map 得到的键值对顺序可能不同。
造成 Go 语言中 map 无序的原因主要有以下两点:
遍历 map 的索引起点是随机的:在 for range 遍历 map 时,其开始的位置是通过随机数决定的。具体来说,在内部实现中,通过 fastrand()
函数生成一个随机数,用于选择一个桶位置作为起始点进行遍历迭代,这导致每次遍历的起始位置不固定。map 的本质特性:map 在写入数据时,并不是按照特定顺序将键值对存储到桶(bucket)中。正常写入(非哈希冲突写入)是根据键的哈希值将其散列到某个 bucket 上,而不是按照 buckets 的顺序写入;在哈希冲突写入时,会将具有相同哈希值的键值对写到同一个 bucket 上,具体位置也不确定,甚至可能写到溢出桶中。此外,当 map 进行扩容时(成倍扩容或等量扩容),元素的顺序也可能会发生变化。
如果需要实现 map 遍历时顺序永远一致,可以采用一个折中的方案:预先给 map 的键排序,然后根据排序后的键序列遍历 map。具体实现方式是先将 map 的所有键放入一个切片中,对切片进行排序,再根据排序后的键遍历 map,这样可以保证每次遍历顺序都是一样的。示例代码如下:
package main
import (
"fmt"
"sort"
)
func main() {
var m = make(map[int]string)
m[0] = "zero"
m[1] = "one"
m[2] = "two"
keys := make([]int, 0, len(m)) // 将所有的键放入一个切片中
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys) // 将所有的键进行排序
for i := 0; i < 5; i++ {
for _, key := range keys { // 根据排序后的键遍历 map
fmt.Printf("key=%d, val=%s\n", key, m[key])
}
fmt.Printf("第%d 次遍历完成\n", i+1)
}
}
从输出结果可以看到,每次遍历的顺序都是一致的。但需要注意的是,这种方式会带来额外的性能开销,因为需要对键进行排序操作。在实际使用中,应根据具体需求来决定是否采用这种有序遍历的方式。同时,官方也不建议开发者依赖特定的 map 遍历顺序,以避免出现不可预期的问题。如果 map 的顺序对于程序的正确性至关重要,可能需要重新考虑数据结构的选择。
9. GET、POST 区别
GET 和 POST 是 HTTP 协议中两种常见的请求方法,它们有以下一些区别:
数据传输方式:
GET:将请求参数附加在 URL 之后,以“?”分隔 URL 和传输数据,多个参数用“&”连接,数据在 URL 中可见。 POST:将请求参数放在请求体中,数据不会显示在 URL 中。
GET:数据在 URL 中可见,不太适合传输敏感信息,如密码等,其安全性相对较低。因为 URL 可能会被记录在浏览器历史、服务器日志或其他地方,存在隐私信息泄露的风险。 POST:数据不在 URL 中显示,相对更安全。但如果不使用 HTTPS 加密,数据在传输过程中仍可能被截获。
GET 请求可被缓存,以便下次请求相同数据时可以直接使用缓存结果,提高效率。 POST 请求通常不会被缓存。
GET 请求的 URL 会被浏览器记录在历史记录中,并且可以被收藏为书签,方便下次直接访问。 POST 请求不会保留在浏览器历史记录中,也不能被收藏为书签。
在浏览器中进行后退或刷新操作时,GET 请求一般没有影响,服务器不会重新处理请求。 POST 请求可能会导致数据被重新提交,服务器会再次处理请求。
GET 请求的 URL 长度受到浏览器和服务器的限制,不同的浏览器和服务器限制的长度可能不同,一般建议不超过 2048 个字符。 POST 请求对数据长度没有明确的限制,主要受服务器配置和 HTTP 协议的限制。
GET 只允许 ASCII 字符。 POST 没有限制,也允许二进制数据等。
GET 通常用于从服务器获取数据,例如查询操作。 POST 一般用于向服务器提交数据,例如提交表单、上传文件等。
11. HTTP 是无状态的,如何做到有状态?(cookie session)
HTTP 协议的无状态性意味着每次请求都是独立的,服务器不会记住之前与客户端的交互信息。但通过 Cookie
和 Session
机制,可以实现有状态的通信。
Cookie:
当客户端首次访问服务器时,服务器可以在响应头中添加Set-Cookie字段,向客户端发送一个或多个 Cookie。 例如: Set-Cookie: username=John; Expires=Wed, 09 Jun 2021 10:18:14 GMT
,这里设置了名为username
,值为John
,并指定了过期时间的 Cookie。客户端接收到 Cookie 后,会将其存储在本地。 后续客户端再次向该服务器发送请求时,会在请求头中自动带上之前收到的 Cookie 信息。 例如: Cookie: username=John
,服务器就可以根据这个 Cookie 来识别客户端的身份或获取其他相关状态信息。
Session:
客户端首次访问服务器时,服务器会创建一个唯一的 Session ID。 服务器将这个 Session ID 以 Cookie 的形式发送给客户端(通常名为 JSESSIONID
)。服务器端会使用某种存储方式(如内存、数据库等)来保存与这个 Session ID 相关的状态信息。 当客户端后续发送请求时,携带 Session ID ,服务器通过这个 ID 找到对应的状态数据。
例如,在一个在线学习平台中,通过 Cookie 可以记住用户的偏好设置(如语言选择、字体大小等),而通过 Session 可以记录用户的登录状态、当前学习课程的进度等信息。
Cookie 适用于存储少量、不太敏感的信息,而 Session 更适合存储较多、较为敏感的用户状态数据。但需要注意的是,Cookie 可能被客户端篡改或禁用,而 Session 如果存储在服务器内存中,当并发量较大时可能会消耗较多资源。
11. HTTPS 过程
主要包括以下几个关键步骤:
客户端发起请求:
客户端(如浏览器)向服务器发送一个连接请求,指定要访问的网址,并表明希望建立 HTTPS 连接。
服务器收到请求后,将自己的数字证书发送给客户端。 证书包含了服务器的名称、公钥以及证书颁发机构(CA)的数字签名等信息。
客户端使用内置的受信任的根证书列表来验证服务器证书的合法性。 检查证书是否由可信的 CA 颁发、证书是否过期、域名是否匹配等。
如果证书验证通过,客户端生成一个随机的会话密钥(通常是对称加密密钥)。
客户端使用从服务器证书中获取的公钥对会话密钥进行加密,并将其发送给服务器。
服务器使用自己的私钥解密客户端发送的加密后的会话密钥。
此后,客户端和服务器使用这个会话密钥对传输的数据进行对称加密和解密,实现安全通信。
例如,当您在网上进行在线支付时,HTTPS 确保您输入的信用卡信息在传输过程中是加密的,防止被窃取和篡改。
在整个过程中,非对称加密用于密钥交换,保证会话密钥的安全传递;对称加密用于后续的数据通信,提高加密和解密的效率。
12. 算法题:
LRU LeetCode 62 不同路径
早日上岸!
我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。
没准能让你能刷到自己意向公司的最新面试题呢。
感兴趣的朋友们可以加我微信:wangzhongyang2024,备注:面试群。
点击下方文章,看看他们是怎么找到好工作的!