Zer0e's Blog

2024面试复盘6

字数统计: 3.3k阅读时长: 11 min
2024/07/22 Share

前言

pdd二面,面试官说他们是单休,可怕。

复盘

项目相关

项目说了蛮久的,可能有二三十分钟。

互斥锁和自旋锁

自旋锁(Spin lock)和互斥锁(Mutex)两者都是为了保证多个线程在访问共享资源时的同步,防止数据竞争和不一致。

互斥锁

  • 线程在尝试获取锁时,如果锁已经被其他线程持有,线程会被阻塞并进入等待队列。
  • 操作系统负责管理线程的阻塞和唤醒。
  • 线程被唤醒后再次尝试获取锁,直到成功为止。
  • 在锁被占用时,线程被阻塞,不会占用CPU资源。
  • 阻塞和唤醒线程有一定的上下文切换开销。

自旋锁

  • 线程在尝试获取锁时,如果锁已经被其他线程持有,线程不会阻塞,而是持续循环检查锁的状态(忙等待)。
  • 自旋过程消耗CPU时间,直到锁被释放。
  • 自旋锁通常由硬件原语(如原子操作)实现,不涉及操作系统的线程管理。
  • 在锁被占用时,线程持续忙等待,占用CPU资源。
  • 避免了线程阻塞和唤醒的上下文切换开销,但可能会导致CPU资源浪费。

AQS和ABA问题

AQS相关白复习了,见这里

ABA问题的解决方法:

  • 版本号机制:在变量前增加一个版本号,每次变量修改时同时更新版本号。CAS操作时同时检查版本号和变量值,确保变量没有被其他线程修改过。
  • 引用计数:某些情况下,可以通过引用计数来跟踪变量的状态变化。

Java中的AtomicStampedReference类和AtomicMarkableReference类提供了解决ABA问题的机制,前者通过增加版本戳来避免ABA问题,后者通过标记位来实现类似效果。

mysql中可以通过版本号机制来解决ABA问题。

线程池的核心参数和拒绝策略

这个之前复盘过,但是拒接策略还是忘了一个。

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数 :

  • keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁。
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :拒绝策略(后面会单独详细介绍一下)。

ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。

ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。

ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。

ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

mysql事务解决了什么问题(没有事务会怎么样)

其实就是ACID(原子性、一致性、隔离性和持久性)。

原子性:确保操作的全有或全无,防止部分操作成功导致的数据不一致。

一致性:确保数据库从一个一致状态转换到另一个一致状态,维持数据的完整性。

隔离性:防止并发事务相互干扰,解决脏读、不可重复读和幻读问题。

持久性:确保已提交的数据永久保存,防止数据丢失。

原子性关注事务的完整性,而一致性关注数据的完整性。

mysql如何调优

这个其实范围比较广,我回答的是从索引和业务层面优化。其实之前也做过复盘,抄过来。

使用explain检查sql。

优化手段:

  1. 避免使用select * ,原因是会消耗更多CPU,增加带宽,无法使用mysql优化器覆盖索引的优化。
  2. 分页优化。使用子查询或内连接,使用子查询的id作为主查询的条件。
1
2
3
SELECT `score`, `name` FROM `cus_order`
WHERE id >= (SELECT id FROM `cus_order` LIMIT 1000000, 1)
LIMIT 10;
  1. 尽量避免多表做 join

  2. 建议不要使用外键与级联

  3. 选择合适的字段类型。某些字符串可以转换成数字类型存储比如可以将 IP 地址转换成整型数据;对于非负型的数据 (如自增 ID,整型 IP,年龄) 来说,要优先使用无符号整型来存储;小数值类型(比如年龄、状态表示如 0/1)优先使用 TINYINT 类型;对于日期类型来说, 一定不要用字符串存储日期。可以考虑 DATETIME、TIMESTAMP 和 数值型时间戳;金额字段用 decimal,避免精度丢失;尽量使用自增 id 作为主键;不建议使用 NULL 作为列默认值;

这里补充下如何正确使用索引

选择合适的字段创建索引

  • 不为 NULL 的字段 :索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。
  • 被频繁查询的字段 :我们创建索引的字段应该是查询操作非常频繁的字段。
  • 被作为条件查询的字段 :被作为 WHERE 条件查询的字段,应该被考虑建立索引。
  • 频繁需要排序的字段 :索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。
  • 被经常频繁用于连接的字段 :经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。

被频繁更新的字段应该慎重建立索引

虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。

尽可能的考虑建立联合索引而不是单列索引

因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。

注意避免冗余索引

冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引。如(name,city )和(name )这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引。

考虑在字符串类型的字段上使用前缀索引代替普通索引

前缀索引仅限于字符串类型,较普通索引会占用更小的空间,所以可以考虑使用前缀索引带替普通索引。

避免索引失效

  • 创建了组合索引,但查询条件未准守最左匹配原则;
  • 在索引列上进行计算、函数、类型转换等操作;
  • 以 % 开头的 LIKE 查询比如 LIKE ‘%abc’;;
  • 查询条件中使用 OR,且 OR 的前后条件中有一个列没有索引,涉及的索引都不会被使用到;
  • IN 的取值范围较大时会导致索引失效,走全表扫描(NOT IN 和 IN 的失效场景相同);
  • 发生隐式转换;

如果有一张表中有一百亿数据怎么处理

分库分表。之前刚讲过。面试的时候回答了手动hash分片,或者用Sharding-JDBC或tidb。

再把之前文章抄过来。

分表算法一般有直接取模,hash取模,一致性hash等方案,前两种方法存在一个问题就是当后期需要扩容的时候,那么由于基数改变,必然涉及到数据的重新迁移。所以一般来说是使用一致性Hash方式。一致性哈希可以按照常用的hash算法来将对应的key哈希到一个具有2^32次方个节点的空间中,形成成一个顺时针首尾相接的闭合的环形。所以当添加一个新的数据库节点时,只有增加服务器的位置和逆时针方向第一个数据库节点之间的键会受影响。虽然数据会收到部分影响,但是会稍微好点。当然前期就应该规划好数据量级,提前做好分库分表。

全局id的生成一般有UUID,雪花算法,当然也有tidb使用的预先分配id方案,例如节点1插入数据id范围为1-3000,节点2为3001-6000以此类推。

市面上主流的分库分表中间件有ShardingSphere,TDDL,Mycat。ShardingSphere用的人可能会多一些。ShardingSphere包括Sharding-JDBC和Sharding-Proxy,JDBC是项目里使用的,Proxy则代理数据库连接,JDBC使用较为简单,只需在项目里引用,并添加多个数据源即可实现分库分表。Proxy 版则可以屏蔽应用层面配置多个数据源,能更好的管理数据库。

缓存和数据库如何保证一致性

这个我回答的还是不错的(自认为),哈哈哈哈。

首先强调了一下一致性是最终一致性,强一致性要如何保证。然后把Cache Aside Pattern说了一下。把之前复盘的抄过来。

缓存不一致业界一般采用的是Cache Aside Pattern, 即旁路缓存方案。这种方案包括读与写两种实践,对于读请求,先读缓存再读DB,如果cache命中,返回数据,未命中则访问DB,并将数据写回缓存。对于写请求,先操作数据库修改,再删除缓存。

为什么要删除缓存?原因是因为如果是写缓存的话,与正常的缓存miss流程相冲突,无法保证时序性。

为什么先操作数据库后删除缓存?其实不管是谁先谁后,都有可能会出现并发问题,但是写数据库的操作会比读数据库的速度慢,因此当A线程读了老数据并写入cache,B线程更改了数据库数据,并删除缓存,假设先删除缓存,那么有可能是删了个寂寞,老数据依旧被A写入缓存。当然这就是一个概率问题。

数据库和缓存是很难做到强一致性的,只能退而求其次追求最终一致性。那么就可以使用双写方案,即常说的延时双删,但是具体延迟多久这个得根据业务确定,取决于读业务所需的时间。

算法题:给定一个包含m*n的元素矩阵 按照顺时针螺旋顺序输出

GPT让我自愧不如。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public static List<Integer> helper(int[][] matrix) {
List<Integer> result = new ArrayList<>();
if (matrix == null || matrix.length == 0) {
return result;
}

int rows = matrix.length;
int cols = matrix[0].length;
int left = 0, right = cols - 1, top = 0, bottom = rows - 1;

while (left <= right && top <= bottom) {
// Traverse from left to right
for (int col = left; col <= right; col++) {
result.add(matrix[top][col]);
}
top++;

// Traverse from top to bottom
for (int row = top; row <= bottom; row++) {
result.add(matrix[row][right]);
}
right--;

if (top <= bottom) {
// Traverse from right to left
for (int col = right; col >= left; col--) {
result.add(matrix[bottom][col]);
}
bottom--;
}

if (left <= right) {
// Traverse from bottom to top
for (int row = bottom; row >= top; row--) {
result.add(matrix[row][left]);
}
left++;
}
}

return result;
}
CATALOG
  1. 1. 前言
  2. 2. 复盘
    1. 2.1. 项目相关
    2. 2.2. 互斥锁和自旋锁
    3. 2.3. AQS和ABA问题
    4. 2.4. 线程池的核心参数和拒绝策略
    5. 2.5. mysql事务解决了什么问题(没有事务会怎么样)
    6. 2.6. mysql如何调优
    7. 2.7. 如果有一张表中有一百亿数据怎么处理
    8. 2.8. 缓存和数据库如何保证一致性
    9. 2.9. 算法题:给定一个包含m*n的元素矩阵 按照顺时针螺旋顺序输出