Savior:低层设计

发布: (2026年2月13日 GMT+8 15:04)
12 分钟阅读
原文: Dev.to

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 的原因。

我和朋友开始相互面试,进行低层设计和系统设计。我注意到自己的批判性思维变得迟钝。在第一次面试时,我语无伦次,细节也说不清,因为我的想法还不够成熟。因此,我为这阶段的编码技能制定了以下策略:

  1. 裸手进行低层设计。
  2. 请 LLM 查找有关该设计的文章。
  3. 让 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:

速率限制器 – 令牌桶

当我开始这个问题时,最初考虑的是滑动窗口技术,但我已经在其他项目中使用过它,想尝试 令牌桶。在观看了一个视频后,我实现了一个组合了 TokenBucketRateLimiter 结构体:

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。
0 浏览
Back to Blog

相关文章

阅读更多 »