Savior:低层设计
I’m ready to translate the article for you, but I need the full text of the content you’d like translated. Could you please paste the article (excluding the source link you already provided) so I can translate it while preserving the formatting and code blocks?
Introduction
我重新回到绘图板上进行面试准备,并提升我的问题解决能力。软件开发正处于一个奇怪的阶段。两周前,当我看到我的朋友在做低层设计时,我认为在当前阶段这毫无意义。结果我大错特错。
我的朋友开始向我询问问题解决和批判性思维。我意识到,我现在只专注于抽象和学习可能在新工作中永远用不到的新工具,正在削弱我的技能。为此我做了一些研究,发现相当多的开发者抱怨 IDE 中的即时建议和 AI 工具削弱了他们的问题解决能力。
我观看了 NeetCode 关于当前面试风格的视频,发现情况并没有太大变化——问题解决能力仍然是最强的技能组合。这也是我创建新仓库 grinding‑go 的原因。
我和朋友开始相互面试,进行低层设计和系统设计。我注意到自己的批判性思维变得迟钝。在第一次面试时,我语无伦次,细节也说不清,因为我的想法还不够成熟。因此,我为这阶段的编码技能制定了以下策略:
- 裸手进行低层设计。
- 请 LLM 查找有关该设计的文章。
- 让 LLM 提出苏格拉底式的后续问题,以提升我的问题解决能力。
于是,我重新回到绘图板上,对那些被抽象化、只剩下大词警报的问题进行低层设计。我再次意识到,算法和计算思维永远不会消失;它们隐藏在大型代码库的抽象之下,如果我不保持技能的更新,就会遇到很大困难。
我用 Go 语言创建了 grinding‑go 项目,因为我喜欢它的简洁和高速。我开始阅读 100 Go Mistakes 的概要,并通过配套网站阅读该仓库中的代码片段。读完那本书后,我可以肯定地说,我不再把 Go 当作其他语言来使用,因为它有自己的风格——尤其是在错误处理和并发方面。
核心结构 – LRU 缓存
在核心结构中,我使用了一个包含链表和互斥锁(用于线程安全和并发)的 LRUCache 结构体:
type LRUCache struct {
mu sync.RWMutex
capacity int
cache map[int]*Node
list *LinkedList
}
主要目的是利用 map 实现快速查找,而在更改最近最少使用项的位置时使用链表,从而避免 O(n) 的操作。向 LRU 缓存中添加新条目如下:
if len(lru.cache) > lru.capacity {
tailKey, err := lru.list.removeTail()
// ...
}
当容量超出限制时,我们移除链表的尾部节点,这是一项 O(1) 的操作。
清理已删除的节点
在解决此问题时,我忘记将已删除元素在链表中的指针设为 nil。在 Go 中这并不像 C/C++ 那样会产生“悬空指针”,但仍然很重要,因为被删除的节点可能仍持有对其他节点的引用,导致垃圾回收器无法释放本应回收的内存。如果意外遍历到这些陈旧的引用,还可能引发逻辑错误:
// 清除悬空指针
n.prev = nil
n.next = nil
经验教训
- 处理链表元素的邻居关系容易出错;我曾丢失引用。
- 我把所有代码写在一个文件中,这让我想起先在单文件中实现问题,再进行重构的做法——这是我第一次从 ThePrimeAgen 那里听说的。
- LRU 缓存在生产环境中随处可见。有人说刷这些题已经“过时”,其实并不正确。在生产系统和大型代码库中,同样的模式和数据结构会被大量抽象使用,如果不了解底层细节,后续的工作会更加困难。
Source: …
速率限制器 – 令牌桶
当我开始这个问题时,最初考虑的是滑动窗口技术,但我已经在其他项目中使用过它,想尝试 令牌桶。在观看了一个视频后,我实现了一个组合了 TokenBucket 的 RateLimiter 结构体:
type RateLimiter struct {
buckets map[string]*TokenBucket // maps a user/key to its bucket
mu sync.Mutex // protects concurrent map access
rate float64 // default rate for new buckets
maxTokens float64 // default burst size for new buckets
}
TokenBucket 结构体也有自己的互斥锁,因为它必须并发处理其操作。Allow 方法控制客户端在特定时间段内可以发起多少请求:
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
tb.lastSeen = now
newTokens := now.Sub(tb.lastRefill).Seconds() * tb.refillRate
tb.tokens += newTokens
if tb.tokens > tb.maxTokens {
tb.tokens = tb.maxTokens
}
tb.lastRefill = now
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}
实现完后我感到满意,但 Socratic 提问揭示了一个缺失的环节:清理空闲的桶。如果有数百万唯一键访问限制器,这些桶会永远占用内存。我添加了一个 cleanUpLoop,定期驱逐陈旧条目:
func (rl *RateLimiter) cleanUpLoop(interval time.Duration, ttl time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
for key, bucket := range rl.buckets {
if time.Since(bucket.lastSeen) > ttl {
delete(rl.buckets, key)
}
}
rl.mu.Unlock()
}
}
速率限制器清理循环(替代实现)
func (rl *RateLimiter) cleanUpLoop(interval, maxIdle time.Duration) {
ticker := time.NewTicker(interval)
for range ticker.C {
rl.mu.Lock()
for key, tb := range rl.buckets {
if time.Since(tb.lastSeen) > maxIdle {
delete(rl.buckets, key)
}
}
rl.mu.Unlock()
}
}
令牌桶 vs. 滑动窗口
| 功能 | 令牌桶 | 滑动窗口 |
|---|---|---|
| 补充方式 | 令牌以固定速率补充;每个请求消耗一个令牌。 | 在移动时间窗口内计数请求。 |
| 突发处理 | 允许高达桶大小的突发——适用于支付或 webhook 等 API。 | 提供更平滑、更严格的速率控制;大规模突发不可实现。 |
| 内存使用 | 低——仅需一个计数器和时间戳。 | 较高——必须为子窗口存储时间戳或计数器。 |
Pub/Sub 在系统设计面试中的应用
正如你可能知道的,Pub/Sub 在讨论微服务架构时是一个常见的话题。它的异步特性和解耦服务的能力,使其成为处理大量 REST‑API 工作负载且保持高性能的可靠选择。
我的个人学习策略是 先编写一个朴素实现,然后在构建过程中进行研究和改进。我发现 Hussein Nasser 关于该主题的视频非常有帮助——强烈推荐给那些更倾向于概念而非工具的人。
核心类型
| 结构体 | 职责 |
|---|---|
| Topic | 对相关消息进行分组。 |
| Subscriber | 接收消息。 |
| Broker | 了解哪些订阅者关注哪些主题,并将消息路由给他们。 |
| Message | 保存实际数据。 |
Broker 充当中间人;它的方法委托给 Topic 方法,并直接返回它们的错误。
取消订阅实现
当我第一次编写 Unsubscribe 时,我在整个操作期间都持有 broker 的互斥锁,这其实没有必要,因为锁只在进行主题查找时才需要。在执行主题层面的工作时仍然保持锁,会阻塞所有其他 broker 方法。
已修正版本:
func (b *Broker) Unsubscribe(topicName string, sub *Subscriber) error {
b.mu.Lock()
t, ok := b.topics[topicName]
b.mu.Unlock() // release broker lock before touching the topic
if !ok {
return ErrTopicNotFound
}
return t.RemoveSubscriber(sub.id)
}
Broadcast – Avoiding the Closure Bug
在 Broadcast 方法中,我确保将订阅者作为参数传递给 goroutine。如果不这样做,循环变量可能会被错误捕获,导致每个 goroutine 都引用 最后 一个订阅者。
自 Go 1.22 起,循环变量在每次迭代中都有自己的作用域,这消除了此特定 bug,但显式传递变量仍是为了清晰和向后兼容的良好实践。
for _, sub := range t.subscribers {
// Pass sub as a parameter to avoid closure capture issues
go func(s *Subscriber) {
select {
case s.ch <- msg:
default:
}
}(sub)
}
您可以在官方 Go 博客关于循环变量作用域的文章中了解更多。
个人反思
- 我在自己的项目上打开了一个 PR 并请朋友审阅。他的反馈提醒我,工具就是工具——tools。重点应放在架构上,而不是华丽的术语。
- 我计划继续这个系列:每写完三篇低层设计的文章后,我会分享我的朴素方案并讨论权衡。
- 我和朋友正忙于找工作,但我们也在做一个叫 verdis 的项目。在之前的博客中,我提到了我们的第一期播客(用冰箱摄像头录制)。第二期已经好很多了!
- 我在我的“公式”中加入了新技巧:开源贡献 + 低层设计问题解决。如果你在我的方案中发现任何问题,欢迎提出改进建议。如果你有有趣的技术挑战,请在 grinding‑go 仓库中创建 issue。