← 2026-10-12 📂 All Days 2026-10-15 →

🏗️ 系统设计 / System Design — Synthesis: Multi-Tenancy 架构模式对比 / Multi-Tenancy Architecture Patterns

📊 NeetCode: 107/150 · SysDesign: 60/60 ✅ · Behavioral: 60/60 ✅ · Python Craft: 50/50 ✅ · AI: 30/30 ✅
🔥 Day 138 · Expert phase · 综合复盘模式 / Synthesis mode

场景 / Scenario

想象你在设计一个 B2B SaaS 产品(比如 Notion、Linear、Datadog):你的用户是公司,每家公司有自己的员工、数据、配置。关键问题:所有租户共享一套基础设施,还是各自隔离?

Imagine you're building a B2B SaaS product (Notion, Linear, Datadog): each customer is a company with isolated users, data, and config. The question: share infra across tenants, or isolate per tenant?

这是一个跨越数据库、缓存、队列、权限、合规的综合决策,不是一个"选 A 或 B"的问题。


三种主流模式对比

Pattern A: Shared Everything (池化 / Pool) ┌──────────────────────────────────────────────┐ │ App Servers (shared) │ │ ↓ tenant_id in every query │ │ ┌────────────────────────────────────────┐ │ │ │ Single DB: │ │ │ │ users (tenant_id, user_id, ...) │ │ │ │ docs (tenant_id, doc_id, ...) │ │ │ └────────────────────────────────────────┘ │ └──────────────────────────────────────────────┘ ✅ Cheapest · ❌ Noisy-neighbor · ❌ Hard compliance Pattern B: Shared App, Isolated DB (桥接 / Bridge) ┌──────────────────────────────────────────────┐ │ App Servers (shared) │ │ ↓ Router: tenant → shard │ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │ DB1 │ │ DB2 │ │ DB3 │ │ DBn │ │ │ └─────┘ └─────┘ └─────┘ └─────┘ │ │ (tenant per DB, or sharded by tenant_id) │ └──────────────────────────────────────────────┘ ✅ Blast-radius bounded · ✅ Per-tenant backup ❌ Schema migrations fan out Pattern C: Silo (独栋 / Dedicated) ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Tenant A │ │ Tenant B │ │ Tenant C │ │ App + DB │ │ App + DB │ │ App + DB │ │ + Cache │ │ + Cache │ │ + Cache │ └─────────────┘ └─────────────┘ └─────────────┘ ✅ Strongest isolation / compliance (HIPAA, SOC2 strict) ❌ Ops cost O(N tenants) · Deploys are chaos

关键权衡 / Key Tradeoffs

维度PoolBridgeSilo
Cost / tenant💰 lowest💰💰 medium💰💰💰 highest
Noisy neighbor⚠️ high⚠️ medium✅ none
Blast radius全部挂one shardone tenant
Compliance难中等容易
Schema change1 次N shardsN stacks
典型客户SMBMid-marketEnterprise
**现实架构往往是混合的:** 免费/小客户 → Pool;中型客户 → 共享 Bridge shard;大企业客户 → Silo。Snowflake、Databricks、Confluent Cloud 都是这个套路。

Real stacks are hybrid: free/SMB on Pool, mid-market on Bridge shards, enterprise on Silo. Snowflake, Databricks, Confluent Cloud all do this.


为什么这样设计?

  1. Row-Level Security 不够。 WHERE tenant_id = ? 一旦漏写就是数据泄露。生产系统应该在连接池层或 ORM 中间件层强制注入(如 Postgres RLS、SQLAlchemy session-level filter)。
  2. 缓存键必须带 tenant_id。 cache.get("user:123") 会跨租户串数据,正确写法 cache.get(f"t:{tenant_id}:user:123")。
  3. 队列任务要携带 tenant 上下文。 Celery/SQS 任务反序列化后要能恢复 tenant,否则后台任务串数据比前台更难查。
  4. Noisy neighbor 用 token bucket per tenant 限流(见 Day 17/67 的 Rate Limiter)。
  5. 可观测性要按 tenant 切片:Datadog tags, Prometheus labels — 否则一个租户出问题你看不出。

❌ Common mistakes

  1. 把 tenant_id 当"加一个列"的事情。 它应该是索引的最左前缀,否则大租户的查询会扫全表。
  2. 假设 Pool → Silo 迁移简单。 实际上数据搬迁 + 外键 + 跨租户引用(分析、admin)会反咬。设计 day 1 就要想迁移路径。
  3. 把 "tenant" 和 "organization" 和 "workspace" 混淆。 Slack 一个用户在多个 workspace。模型应该是 User ↔ Membership ↔ Tenant,不是 User → tenant_id。
  4. 忽略"god mode"用户。 内部 support/admin 需要跨租户访问 — 必须审计日志 + 强制理由字段。

📚 References

  • AWS SaaS Factory — Multi-tenant SaaS architectures: https://aws.amazon.com/partners/programs/saas-factory/tenant-isolation/
  • Microsoft — Multitenant architecture patterns: https://learn.microsoft.com/en-us/azure/architecture/guide/multitenant/overview
  • PostgreSQL Row Level Security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html

🧒 ELI5

想象一座公寓楼。Pool = 所有人睡一张大床(便宜但乱)。Bridge = 每人一间房共享厨房(平衡)。Silo = 每人一栋别墅(贵但安全)。SaaS 公司通常"小客户住宿舍、大客户住别墅"。

💻 算法 / Algorithms — #139 Word Break (Medium) — 1-D Dynamic Programming

🧩 一维动态规划 (10/12) — building on the template from Day 128 (Climbing Stairs)

📊 NeetCode: 107/150 · Pattern progress: 10/12 in 1-D DP block
🔗 LeetCode: https://leetcode.com/problems/word-break/ 🟡 Medium
📹 NeetCode: https://neetcode.io/solutions/word-break

这题和模板的变化 / What's different from the template

前面 9 题 (#70, #198, #322, #152, …) 的 dp[i] 都是数值(步数、金额、乘积、方案数)。今天:

Previous 9 problems used dp[i] as a number (steps, amount, product, count). Today:

项之前今天 (#139)
dp[i] 的含义到位置 i 的最优值布尔:前 i 个字符能否被切分
转移dp[i] = min/max(dp[j] + cost)dp[i] = any(dp[j] and s[j:i] in words)
子问题枚举固定几个(i-1, i-2, i-coin)扫描所有切点 j < i
**核心升级:** `dp[i]` 可以是**任意类型**(bool, set, list),不止数字。能否切分 → bool。能切分成几种 → int。所有切分方案 → list(这就是 #140 Word Break II)。

现实类比 / Real-world analogy

你在读一段没有空格的日文/中文:"applepenapple"。手里有词典 {apple, pen}。能不能从左到右贪心地找词?不行 — "aalanda" 可能切成 "aa / lan / da" 或 "aal / an / da",贪心会漏情况。所以要 DP:"前 i 个字符能否切分" 只依赖于 "是否存在切点 j 使得前 j 个能切分 且 s[j:i] 是词"。


问题 / Problem

Given s and a wordDict, return True if s can be segmented into a space-separated sequence of dictionary words.

s = "leetcode", wordDict = ["leet","code"] → True ("leet code") s = "applepenapple", wordDict = ["apple","pen"] → True s = "catsandog", wordDict = ["cats","dog","sand","and","cat"] → False

映射到模板 / Map to template

def wordBreak(s: str, wordDict: list[str]) -> bool:
    word_set = set(wordDict)              # O(1) lookup
    max_len = max(map(len, wordDict))     # prune inner loop
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True                          # BASE CASE: empty prefix is "segmentable"

    for i in range(1, n + 1):
        # TRANSITION: try every split point j; only check j within max_len
        for j in range(max(0, i - max_len), i):
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                break                     # one witness is enough
    return dp[n]

Trace: s = "leetcode", dict = {leet, code}

i=0: dp[0]=True (base) i=1..3: no word matches s[0:1..3] → dp=F i=4: j=0, s[0:4]="leet" ∈ dict, dp[0]=T → dp[4]=True ✅ i=5..7: try all j; need dp[j]=T and s[j:i]∈dict → none i=8: j=4, s[4:8]="code" ∈ dict, dp[4]=T → dp[8]=True ✅ return dp[8] = True

Complexity

  • Time: O(n² · k) where k = avg word length (for s[j:i] hash). With max_len prune → O(n · max_len · k).
  • Space: O(n) for dp + O(W) for word set.
  • 可以用 Trie 优化到 O(n²) 常数更小 — 见 #140.

举一反三 / Variations in the same block

问题dp[i] 含义关键区别
#139 Word Break (今天)能否切分 (bool)任意切点 j, 任意词
#140 Word Break II所有切分方案 (list)要用 DFS + memo,不是纯 DP
#322 Coin Change最少硬币数 (int)固定硬币集 → 固定几个 j
#300 LIS最长递增子序列长度内循环看 所有 j < i,但条件是 nums[j] < nums[i]

共同点: 内层循环扫描所有合法前缀 j < i,不是固定 i-1、i-2。这是 "1-D DP 带全扫描" 的子类。LIS、Word Break、Perfect Squares (#279) 都属于这一类。


📚 References

  • NeetCode Word Break: https://neetcode.io/solutions/word-break
  • LeetCode editorial: https://leetcode.com/problems/word-break/editorial/
  • CP-Algorithms — DP intro: https://cp-algorithms.com/dynamic_programming/intro-to-dp.html

🧒 ELI5

一长串没空格的字符,手里有词典。从前往后一步步问:"到目前为止能切好吗?"— 只要存在一个"回头看"的位置,使那里之前切得好 AND 中间那段是词典里的词,就 OK。

🗣️ 软技能 / Soft Skills — Synthesis: 怎么拒绝一个"VP 的好主意" / Saying No to a VP's Favorite Idea

📊 Behavioral: 60/60 ✅ — 综合情景题 / Synthesis-level scenario
Senior/Staff level · 面向 L6+ 面试

场景 / Scenario

一位 VP 在 all-hands 上宣布了一个听起来很酷但工程上很糟糕的想法(比如 "我们下季度把所有后端服务迁到 Rust",或 "给所有 feature 加 AI")。他没有和你的团队讨论过,但消息已经传开,产品经理开始规划,招聘已经启动。你怎么办?

A VP announces at all-hands a cool-sounding but technically bad idea (e.g., "migrate everything to Rust next quarter", "AI-ify every feature"). No consultation. PMs are planning, hiring is spinning up. What do you do?

Why this matters: 这是 Staff/Principal 面试里最常出现的"真实情景"— 既不能默默执行(你是技术 leader),也不能公开反对(会被看成不合作)。考察的是向上影响力 + 政治智慧 + 技术判断三者的交叉。


STAR 回答模板 / STAR breakdown

S (Situation): 一个具体的、有数字的情境。

"Q3 we had an all-hands where our SVP of Eng announced we'd standardize on gRPC across all 40+ services within 6 months. My team owned 8 of the services closest to the user-facing APIs where REST was deeply embedded in 3rd-party integrations."

T (Task): 你的角色和"冲突"是什么。

"I agreed with the direction but the timeline and scope would've blown up ongoing roadmap commitments and broken partner contracts. I needed to influence a reset — without making the SVP lose face publicly."

A (Action): 三步法 — Steelman, Data, Alternative.

  1. Steelman first. 私下 1:1 和 SVP:先承认他想解决的真问题(schema drift, polyglot clients 混乱)— 让他知道你听懂了。
  2. Data, not opinion. 用数据取代"我觉得":

- 列出 8 个服务的 external integration partners (28 家) 和合同条款。

- 迁移成本估算:3 engineers × 4 months = 12 person-months, slipping 2 roadmap items.

- Counter-proposal: 新服务 gRPC-first; 老服务只在重大重构窗口切换; 边界加 REST↔gRPC gateway (grpc-gateway).

  1. Give the leader a win. 把 counter-proposal 包装成"扩展他的愿景"而不是"推翻":"Your north star is right; here's how we get there without a cliff."

R (Result): 具体可量化的结果。

"SVP accepted the phased plan at the next leadership meeting. We hit 100% new-service compliance in Q4 and migrated 6 of 8 legacy services by H2 next year. Zero partner escalations. The grpc-gateway pattern became a company-wide standard."

❌ Bad vs ✅ Good

❌ Bad:

"The VP's idea was dumb and I told him it wouldn't work. He wouldn't listen so I just did the minimum."

Why bad: performative disagreement, no empathy for the problem the VP was solving, no data, no alternative, bad outcome.

❌ Also bad (over-correction):

"The VP decided it, so we executed it. We missed our roadmap but the migration happened."

Why bad: no technical leadership. Staff+ engineers are expected to push back with signal.

✅ Good:

"I started by understanding what pain the VP was really solving — schema drift and polyglot client confusion. Then I built a one-pager comparing the all-in migration (12 person-months, 28 partner contracts to renegotiate) with a phased approach (new services gRPC-first, legacy via a gateway). Shared it with my manager first, then with the VP in a 1:1 before escalating. The VP appreciated the data and we aligned on the phased plan."

Senior/Staff 面试加分点 / What interviewers want to hear

  • "Disagree and commit" 的成熟版本: 先 disagree 用数据,决定后 commit — 不是沉默服从。
  • 向上管理 (managing up): 保护 leader 的面子是你的工作。公开反对会让他们记住你是威胁而不是盟友。
  • 写出来 (write it down): 一页 doc > 20 分钟会议。文字留痕、便于传播、迫使你思考清晰。
  • 影响范围证据: "I didn't just change my team's direction; the pattern was adopted org-wide."
  • 心理安全: 承认你最初也紧张提这件事 — 面试官喜欢 vulnerability + agency 的组合。

别踩这个坑 / Pitfalls

  1. "我就是对的": 哪怕你技术上对,你的工作是让组织做对的决定,不是证明自己聪明。
  2. 在 Slack 公开频道开炮:这是政治自杀。
  3. 去找 VP 的老板越级:极少数情况可以,但先尝试直接路径 2-3 轮。
  4. 挡在那里不动:Staff+ 的失败模式是"永远在找 trade-off 的理由不做事"— 你必须给出可执行的替代方案。

🔑 Key Takeaways

  1. Steelman → Data → Alternative 三步走。
  2. 永远给 leader 一个 "面子出口"(face-saving ramp)。
  3. 一页 doc 是你的武器。
  4. Agency with humility — 不卑不亢。

📚 References

  • Will Larson — An Elegant Puzzle: Systems of Engineering Management: https://press.stripe.com/an-elegant-puzzle
  • Amazon Leadership Principles — "Have Backbone; Disagree and Commit": https://www.amazon.jobs/content/en/our-workplace/leadership-principles
  • "Managing Up" (HBR): https://hbr.org/2015/01/managing-up

🧒 ELI5

老板想做一件事,看起来会撞墙。你不要大吼"别做!",也不要闭眼跟着撞。你悄悄画一张地图,告诉他:"你想去的地方对,但有一条绕路更安全。"— 他开心,你安全,大家到站。

🤖 AI — Synthesis: KV Cache 深潜与可运行实现 / Deep Dive on KV-Cache with Runnable Code

📊 AI concepts: 30/30 ✅ — 综合深潜 / Synthesis dive
延伸 Day 92 (Inference Optimization) 和 Day 95 (Context Windows)

为什么 LLM 推理慢的根源:Prefill vs Decode

LLM 推理有两个截然不同的阶段:

  • Prefill (预填): 一次性处理整个 prompt — 计算密集 (compute-bound),GPU 跑满。
  • Decode (生成): 一次吐一个 token — 内存带宽密集 (memory-bound),GPU 大部分时间在搬 weights。

Prefill is compute-bound (GPU math units saturated). Decode is memory-bound (GPU idling, DRAM thrashing). KV cache is the single most important optimization for decode.

没有 KV 缓存:生成第 N 个 token 要重新算前 N-1 个 token 的 attention — 复杂度 O(N²) 累计。

有 KV 缓存:每个 token 的 K、V 向量算一次,存起来,后续 token 直接查 — 复杂度 O(N) 累计,但内存吃紧。

Without KV cache: regenerating K,V for every prior token → O(N²) cumulative. With cache: compute once, reuse → O(N) time, but memory explodes.

KV 缓存到底存什么?

每层 attention 有 Q (query), K (key), V (value) 三个矩阵。生成第 t 个 token 时:

  • Q_t 要用当前 token 的 hidden state 新算一个。
  • K, V 需要前面所有 token 的 — 这些是不变的历史,缓存起来。

每层 KV cache 的大小 = 2 × batch × seq_len × num_heads × head_dim × bytes_per_elem。

Llama-3-8B 举例 (32 layers, 8 KV heads, 128 head_dim, fp16):

每 token 每层 = 2 × 1 × 1 × 8 × 128 × 2 bytes = 4 KB
全层每 token  = 4 KB × 32 = 128 KB
8K context    = 128 KB × 8192 ≈ 1 GB per request

这就是为什么 LLM 服务要用 PagedAttention (vLLM) / FlashAttention — KV 缓存的内存管理决定了吞吐量。


一个最小的 KV-cache 实现 (可运行)

pip install torch
# save as kv_cache_demo.py
import torch, time
import torch.nn.functional as F

torch.manual_seed(0)
D, H, HEAD_DIM = 128, 4, 32  # model dim, heads, head dim
assert D == H * HEAD_DIM

# One attention layer's K,V projections (toy)
Wq = torch.randn(D, D); Wk = torch.randn(D, D); Wv = torch.randn(D, D)

def attention_naive(tokens: torch.Tensor) -> torch.Tensor:
    """Recompute full attention every step — the slow way."""
    # tokens: [T, D]
    Q = (tokens @ Wq).view(-1, H, HEAD_DIM).transpose(0, 1)  # [H,T,d]
    K = (tokens @ Wk).view(-1, H, HEAD_DIM).transpose(0, 1)
    V = (tokens @ Wv).view(-1, H, HEAD_DIM).transpose(0, 1)
    scores = Q @ K.transpose(-2, -1) / HEAD_DIM**0.5
    mask = torch.triu(torch.ones_like(scores), diagonal=1).bool()
    scores = scores.masked_fill(mask, float("-inf"))
    return (F.softmax(scores, dim=-1) @ V).transpose(0, 1).reshape(-1, D)[-1]

class KVCache:
    def __init__(self): self.K = None; self.V = None
    def step(self, token_vec: torch.Tensor) -> torch.Tensor:
        q = (token_vec @ Wq).view(H, HEAD_DIM)            # [H, d]
        k = (token_vec @ Wk).view(H, HEAD_DIM)
        v = (token_vec @ Wv).view(H, HEAD_DIM)
        self.K = k.unsqueeze(1) if self.K is None else torch.cat([self.K, k.unsqueeze(1)], dim=1)
        self.V = v.unsqueeze(1) if self.V is None else torch.cat([self.V, v.unsqueeze(1)], dim=1)
        scores = (q.unsqueeze(1) @ self.K.transpose(-2, -1)).squeeze(1) / HEAD_DIM**0.5  # [H,T]
        return (F.softmax(scores, dim=-1).unsqueeze(1) @ self.V).squeeze(1).reshape(-1)

# Bench: generate 200 tokens
seq = [torch.randn(D) for _ in range(200)]

t0 = time.perf_counter()
for i in range(1, 201):
    _ = attention_naive(torch.stack(seq[:i]))
print(f"naive  O(N^2): {time.perf_counter()-t0:.3f}s")

t0 = time.perf_counter()
cache = KVCache()
for tok in seq:
    _ = cache.step(tok)
print(f"kv-cache O(N): {time.perf_counter()-t0:.3f}s")

典型输出:

naive O(N^2): 0.42s kv-cache O(N): 0.05s ← ~8-10x faster at seq_len=200, gap grows with length

现实中的 KV-cache 优化栈

技术解决什么代表项目
PagedAttentionKV 碎片化、fragmentationvLLM
GQA / MQA (Grouped/Multi-Query)KV heads 共享减内存Llama 3, Mistral
Sliding Window Attention长 context 砍掉太老的 tokenMistral
KV Quantization (int8/int4)把 fp16 → int8 砍 2-4x 内存TGI, vLLM
Prefix Caching共享的 system prompt 只算一次vLLM, SGLang
Speculative Decoding小模型猜,大模型验证 — 跳步Medusa, EAGLE

生产提示: 服务 LLM 不要自己写 attention — 直接用 vLLM/SGLang/TGI。它们在 KV 管理上 2-3 年领先手搓。你的工作是理解它们的行为(调 --kv-cache-dtype, --enable-prefix-caching)而不是重实现。


举一反三 / Connections to prior days

  • Day 95 Context Windows: 为什么长 context 这么贵?答案就是 KV cache 线性增长。
  • Day 71 LLM 服务系统设计: PagedAttention 的 memory manager 本质上是 Day 56-60 的虚拟内存分页搬到了 GPU。
  • Day 92 Inference Optimization: Speculative decoding 的前提是 KV cache 命中率 — 猜错也不浪费 prefill。
  • Day 29 Quantization: KV int8 和 weight int8 是正交的优化,可以叠加。

📚 References

  • vLLM paper (PagedAttention): https://arxiv.org/abs/2309.06180
  • FlashAttention 2 paper: https://arxiv.org/abs/2307.08691
  • HuggingFace — KV cache illustrated: https://huggingface.co/blog/kv-cache-quantization

🧒 ELI5

读一本很长的书,每读一页你都要从第一页重新读起— 累死人。KV 缓存就是你在旁边放一张"笔记本",之前读过的页只记关键字,新一页只需翻一下笔记本不用从头再读。代价是笔记本会越来越厚(占内存)。

Attention Heatmap

When the model processes it, it attends most strongly to animal.

Theanimaldidntcrossthestreetbecauseitwastootired
it →