01阿里云二面

根据口述整理,补充了面试中适合展开的回答口径。

面试问题#

  1. 视频 Feed 流最核心的设计是什么
  2. 消息队列异常时怎么降级
  3. 消息队列怎么做高可用和持久化
  4. 切片和数组的区别
  5. 缓存击穿和缓存雪崩
  6. Go map 底层如何实现
  7. defer 的实现和返回值修改
  8. Go 协程间通信和 Channel 容量设计
  9. 两个有序数组的中位数
  10. 根据字符串中的数字重新排序

参考答案(AI 生成)#

以下答案由 AI 生成,仅供面试复盘参考。

项目#

视频 Feed 流最核心的设计是什么#

可直接说: 我觉得最核心的是复合游标分页。Feed 流的难点在于数据持续新增、删除、热度变化,同时还要保证翻页稳定、性能稳定。单纯 offset 深分页会随着页码变大越来越慢,也容易在新内容插入后出现重复或跳过;复合游标用排序字段加唯一主键定位下一页,更适合动态 Feed。

典型设计是 (score, id) 或 (created_at, id):

SELECT id, title, score, created_at
FROM videos
WHERE (
  score < ?
  OR (score = ? AND id < ?)
)
ORDER BY score DESC, id DESC
LIMIT ?;

这样设计的重点:

  1. score 或 created_at 决定主要排序。
  2. id 作为唯一排序补充,让同分、同时间下的顺序保持稳定。
  3. cursor 里保存上一页最后一条的排序字段和 id,下一页从这个位置继续查。
  4. 索引匹配排序字段和主键,例如 (score DESC, id DESC) 或 (created_at DESC, id DESC)。

如果 Feed 有个性化推荐分数,可以把推荐结果先写入 Redis ZSet 或临时结果表,再用 (rank_score, video_id) 做复合游标。面试里可以补一句:复合游标解决的是“高并发动态列表下的稳定翻页和深分页性能”。

消息队列异常时怎么降级#

可直接说: MQ 是异步链路,异常时我会按业务重要程度做降级。强依赖结果的核心链路走同步直写或本地消息表兜底;弱依赖链路先落库记录任务状态,再后台补偿;非核心通知类任务可以延迟处理。

在项目里可以这样回答:

  1. 正常路径:业务先写 MySQL,再写 outbox 本地消息表,worker 投递 MQ,消费者异步处理。
  2. MQ 异常:worker 投递失败后更新 retry_count 和 next_retry_at,按指数退避重试。
  3. MQ 长时间异常:熔断 MQ 投递,核心业务改成直写 MySQL 或同步调用关键逻辑,同时保留 outbox 记录,等 MQ 恢复后补偿下游。
  4. Redis 异常:Feed 缓存读取失败时降级读 MySQL,配合本地短 TTL 缓存、限流和 singleflight 合并回源,保护数据库。
  5. 恢复阶段:MQ 或 Redis 恢复后,后台任务按顺序补偿未完成消息,并通过幂等键避免重复生效。

面试官追问自动降级直写时,可以答:核心写链路可以直写权威存储,异步链路保留 outbox 任务用于恢复后补偿。这样用户请求先成功,异步一致性由后台任务继续保证。

消息队列怎么做高可用和持久化#

可直接说: MQ 高可用要同时保证生产端投递、Broker 存储和消费端确认。RabbitMQ 里会开启生产者 confirm、队列持久化、消息持久化、手动 ACK,并使用 quorum queue 或镜像队列提高可用性。

RabbitMQ 关键配置和机制:

  1. 交换机和队列持久化:声明 exchange、queue 时设置 durable=true,Broker 重启后元数据还能恢复。
  2. 消息持久化:发布消息时设置 delivery_mode=2 或 persistent,让消息写入磁盘。
  3. 生产者确认:开启 publisher confirm,Broker 落盘或副本确认后给生产者 ACK,失败时生产者重试或写 outbox 补偿。
  4. 消费者确认:使用 manual ack,业务处理成功后再 ACK;失败时 nack/requeue 或进入死信队列。
  5. 高可用队列:优先考虑 quorum queue,通过 Raft 多副本复制保证主节点故障后可以切换。
  6. 死信和重试:配置 DLX、TTL、最大重试次数,把异常消息转入死信队列做人工排查或补偿。

常见优化参数和思路:

  1. prefetch 控制单个消费者 unacked 消息数,让消息在消费者之间更均衡地分配。
  2. 批量发布和 confirm 批量等待,提高吞吐。
  3. 消息体控制大小,大对象放对象存储,MQ 里只传引用。
  4. 队列按业务拆分,核心队列和低优先级队列隔离。
  5. 配置 lazy queue 或 quorum queue 时结合业务延迟要求,权衡吞吐、落盘和恢复能力。
  6. 监控积压量、publish/ack 速率、磁盘水位、内存水位、消费者数量和死信数量。

这里可以强调:RabbitMQ 的可靠性依赖持久化和确认机制共同完成,业务侧仍然要有 outbox、重试和消费幂等。

八股#

切片和数组的区别#

可直接说: 数组是固定长度的值类型,长度属于类型的一部分;切片是对底层数组的一段引用,包含指针、长度和容量三个字段。

关键点:

  1. 数组长度固定,[3]int 和 [4]int 是不同类型。
  2. 数组赋值会复制整个数组。
  3. 切片长度可变,扩容时可能分配新底层数组。
  4. 切片传参复制的是 slice header,底层数组共享。

缓存击穿和缓存雪崩#

可直接说: 缓存击穿是热点 key 失效后,大量请求同时打到数据库;缓存雪崩是大量 key 同时失效或缓存集群故障,请求大面积打到数据库。

解决思路:

  1. 击穿:热点 key 加互斥锁、singleflight、逻辑过期、后台刷新。
  2. 雪崩:TTL 加随机值、缓存预热、限流降级、多级缓存、Redis 高可用。

Go map 底层如何实现#

可直接说: Go map 底层是哈希表,核心结构是 hmap 和桶 bmap。每个桶最多放 8 个 key/value,并保存 top hash 用于快速比较;哈希冲突时使用溢出桶,数据增长后触发渐进式扩容。

关键点:

  1. 通过 hash 定位 bucket。
  2. bucket 内比较 top hash,再比较 key。
  3. 冲突过多时挂 overflow bucket。
  4. 扩容采用渐进搬迁,读写过程中逐步迁移旧桶数据。

defer 的实现和返回值修改#

可直接说: defer 会把延迟调用记录挂到当前 goroutine 或当前函数调用链上,函数返回前按后进先出的顺序执行。现代 Go 对简单 defer 做了开放编码优化,减少运行时分配和链表操作开销。

返回值执行顺序:

  1. 先给返回值赋值。
  2. 再执行 defer。
  3. 最后真正返回给调用方。

所以命名返回值可以被 defer 修改:

func f() (x int) {
	defer func() {
		x = 2
	}()
	return 1
}

这个函数最终返回 2。匿名返回值场景下,defer 修改局部变量通常只影响该局部变量,返回结果已经在 defer 前完成赋值。

Go 协程间通信和 Channel 容量设计#

可直接说: Go 常用 Channel 做 goroutine 间通信,也会配合 context 做取消、超时和生命周期控制。Channel 容量为 0 是无缓冲通道,发送和接收必须同步配对;容量大于 0 是有缓冲通道,可以临时承接生产和消费的速度差。

容量设计原则:

  1. 用无缓冲 Channel:强调同步交接、事件通知、严格背压,比如任务完成信号。
  2. 用有缓冲 Channel:生产者和消费者速度有波动,需要削峰或提升吞吐。
  3. 容量大小按消费者处理速度、可接受排队延迟、内存占用来估算。
  4. 工程上常从小容量开始,比如 CPU 核数 * 每个 worker 可接受积压数,再结合压测调整。
  5. Channel 适合作为有界队列使用,容量过大会隐藏下游处理慢的问题。

算法#

两个有序数组的中位数#

可直接说: 简单方案是双指针合并到中位数位置,时间复杂度 O(m+n),空间可以做到 O(1);进阶方案是二分切分较短数组,时间复杂度 O(log(min(m,n)))。

双指针思路:

  1. 设总长度为 m+n,只遍历到第 total/2 个位置。
  2. 每次取两个数组当前较小值,记录 prev 和 cur。
  3. 总长度为奇数返回 cur,偶数返回 (prev+cur)/2。

二分思路:

  1. 在较短数组上二分切分点 i。
  2. 另一个数组切分点 j = (m+n+1)/2 - i。
  3. 满足 leftA <= rightB && leftB <= rightA 时找到正确切分。
  4. 奇数返回左半部分最大值,偶数返回左半最大值和右半最小值的平均值。

根据字符串中的数字重新排序#

题目:给定字符串数组,按照字符串中的数字排序,带多个数字时拼接为一个数,无数字视为 0。

示例:

输入:[aab2nn1, dd8jh, n1n, xyz]
数字:[21, 8, 1, 0]
输出:[xyz, n1n, dd8jh, aab2nn1]

思路:

  1. 遍历每个字符串,提取其中所有数字字符并拼接。
  2. 无数字时数值记为 0。
  3. 按提取出的数字升序排序。
  4. 数字相同时按原始顺序稳定排序,避免同 key 顺序抖动。

Go 写法:

type item struct {
	s   string
	num int
	idx int
}

func extractNum(s string) int {
	n := 0
	has := false
	for _, ch := range s {
		if ch >= '0' && ch <= '9' {
			has = true
			n = n*10 + int(ch-'0')
		}
	}
	if !has {
		return 0
	}
	return n
}

func sortByNumber(arr []string) []string {
	items := make([]item, 0, len(arr))
	for i, s := range arr {
		items = append(items, item{s: s, num: extractNum(s), idx: i})
	}
	sort.SliceStable(items, func(i, j int) bool {
		return items[i].num < items[j].num
	})
	ans := make([]string, len(items))
	for i, it := range items {
		ans[i] = it.s
	}
	return ans
}