云设计模式
这一课你将能做到
- 用 Bulkhead(舱壁)为本站读路径与写路径配置资源隔离,说清它如何限制故障爆炸半径
- 设计 Valet Key(代客密钥)流程,让用户头像/题解附件绕过应用服务器直传对象存储
- 用 Claim Check(引用票据)解决 Kafka 大消息问题,用 Anti-Corruption Layer 隔离外部格式
为什么要学
前几课解决「怎么扛量」,这课解决「怎么不因一处损坏而全站陪葬」。云设计模式是一批被反复验证的架构套路,名字来自 Microsoft Azure 架构模式库——不管上不上云,这些模式都适用。
面试里它们是加分项:「你了解哪些架构模式?」「大文件上传怎么设计?」「一个慢依赖怎么防止拖垮整个服务?」——能按名字、问题、解法三段式讲清楚,说明你有体系而非碎片经验。
人话:Bulkhead 是船的水密舱——一个舱进水,船不沉;Valet Key 是代客泊车的钥匙——只给停车(上传)权限,不给后备箱(删除);Claim Check 是寄存行李——排队票上写编号,行李放仓库,别扛着大箱子排队;Anti-Corruption Layer 是翻译官——外部格式再乱,翻译成本公司语言后才进门。
先建立图像
本站的四个问题与对应模式:
| 模式 | 解决的问题 | 本站落地 |
|---|
| Bulkhead | 写判题结果慢,占满连接池,读也死 | 读/写独立连接池 + 判题机独立部署单元 |
| Valet Key | 题解 PDF 50MB,经 Java 中转费带宽 | 签发限时预签名 URL,浏览器直传 MinIO |
| Claim Check | 提交代码 200KB 塞进 Kafka 消息 | 消息只带 submissionId,判题机按 ID 回查 |
| Anti-Corruption Layer | 导入外部题库 JSON 格式千奇百怪 | import 适配层翻译成内部领域模型 |
Bulkhead 的直观图像:
改造前(共享一个连接池 50 连接):
读请求 500 ──┐
├──→ [连接池 50] ──→ MySQL
写判题 500 ──┘ ↑ 写高峰把 50 个连接全占住,读请求饿死
改造后(水密舱隔离):
读请求 ──→ [读池 40] ──→ 从库 ← 写再慢,读照样通
写请求 ──→ [写池 10] ──→ 主库 ← 写最多占 10 个连接,可控
跟着做
步骤 1:Bulkhead——Resilience4j 舱壁隔离
resilience4j:
bulkhead:
instances:
readPath:
max-concurrent-calls: 40 # 读舱壁容量
max-wait-duration: 100ms # 排队 100ms 拿不到就快速失败
writePath:
max-concurrent-calls: 10
max-wait-duration: 50ms
thread-pool-bulkhead:
instances:
judgeCallback: # 判题回调走独立线程池
core-thread-pool-size: 4
max-thread-pool-size: 8
queue-capacity: 100
@Bulkhead(name = "readPath", type = Bulkhead.Type.SEMAPHORE)
public ProblemDTO getProblem(long id) { return cacheService.getProblem(id); }
@Bulkhead(name = "writePath", type = Bulkhead.Type.SEMAPHORE)
public long submit(SubmissionDTO dto) { return submissionService.submit(dto); }
效果验证:压测让写路径变慢(模拟 DB 抖动),观察读路径 P99——隔离后读不受写的影响;写超过 10 并发时快速失败(503 + Retry-After),而不是无限排队拖垮 Tomcat。
步骤 2:Valet Key——预签名 URL 直传对象存储
传统中转:浏览器 → Java → MinIO,50MB 文件占用应用 50MB 内存和双倍带宽。Valet Key 让应用只发「门票」:
// ① API 只签发凭证(毫秒级,不碰文件内容)
@PostMapping("/uploads/presign")
public PresignResult presign(@RequestBody PresignReq req, @AuthenticationPrincipal User user) {
// 对象 key 服务端生成:带上 userId,防止越权覆盖别人文件
String objectKey = "solutions/" + user.getId() + "/" + UUID.randomUUID() + ".pdf";
String url = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket("java-nav-uploads")
.object(objectKey)
.expiry(15, TimeUnit.MINUTES) // 门票 15 分钟有效
.build());
uploadLogMapper.insert(new UploadLog(user.getId(), objectKey, "PENDING"));
return new PresignResult(url, objectKey);
}
// ② 浏览器直传(前端 fetch/oss sdk PUT 该 URL)
// ③ 前端传完回调 API,服务端校验对象存在 + 大小/类型合法后置状态为 UPLOADED
安全要点三条:key 由服务端生成(防路径越权)、凭证限时(15 分钟)、上传后二次校验(大小/类型/病毒扫描)。「发证」和「验货」都在服务端,文件流不过服务器。
步骤 3:Claim Check——大消息放仓库,消息里只带票
// 写入:代码存 MySQL(事实来源),Kafka 只传 ID
kafkaTemplate.send("judge-task", String.valueOf(id),
"{\"submissionId\":" + id + "}"); // 几十字节,Kafka 轻快
// 消费:判题机凭 ID 回查代码
long submissionId = parse(record.value()).getSubmissionId();
Submission s = submissionMapper.selectById(submissionId); // 回查
judgeService.execute(s);
效果对比:单条消息 200KB → 30B,Kafka 每秒传输量降 6000 倍,broker 页缓存压力骤降;顺带保证消费者永远读到最新的代码(判题前回查,不怕消息滞后于代码更新)。判题结果大输出同理——结果存 OSS,消息里带结果 URL。
步骤 4:Anti-Corruption Layer——外部格式进不了核心域
导入 roadmap.sh / 外部题库 JSON 时:
// ACL 层:外部格式 → 内部领域对象,核心域只认自己的模型
public class ExternalProblemImporter {
public Problem convert(ExternalProblemDto ext) {
return Problem.builder()
.title(ext.name) // 外部叫 name
.difficulty(mapDifficulty(ext.level)) // 外部 level 是字符串枚举
.statement(sanitize(ext.descriptionHtml)) // 洗掉外部 HTML 风险
.tests(ext.cases.stream().map(this::toTestCase).toList())
.build();
}
}
外部格式变更(字段改名、结构嵌套变化)只改 ACL 一层,加回归测试锁住转换逻辑——核心域对外部世界「免疫」。
讲清楚要点
- Bulkhead 的本质是「资源按故障域切分」:线程池、连接池、实例池、甚至独立部署单元都是舱壁。切分粒度由「故障影响范围」决定——判题崩溃不该影响看题,所以它们至少是不同池,最好不同机器组。
- Valet Key 的本质是「权限限时下放」:应用从「数据搬运工」变「发证人」,省带宽省内存省代码。所有对象存储(MinIO/S3/OSS/COS)都支持预签名。
- Claim Check 的本质是「载荷与指针分离」:消息系统传小票不传货。附带收益是消息幂等(消息足够小、可重放,消费者按指针取最新值)。
- ACL 的本质是「边界翻译」:外部系统的格式污点(命名混乱、语义不清、频繁变更)止步于适配层。判断要不要 ACL 的问题:「这个外部格式改了,我要改几处代码?」答案是「一处(ACL)」就对了。
- 模式是套路不是信仰:每个模式有代价——Bulkhead 浪费部分资源容量,Valet Key 多一次回执确认,ACL 多一层代码。小系统别为了「模式感」硬上,先有问题再上模式。
你可能会懵的地方
-
舱壁隔离和熔断什么关系?
舱壁是空间隔离(限制并发占用),熔断是时间隔离(连续失败后停止调用一段时间)。常组合:Resilience4j 一个实例可以同时配 bulkhead + circuitBreaker + timeout。舱壁防「慢」,熔断防「死循环重试」。
-
预签名 URL 泄露了怎么办?
泄露的 URL 15 分钟内可用——这是模式固有的权衡。缓解:URL 绑定对象 key(只能传到指定路径)、过期短、上传后服务端校验内容、敏感桶开服务端加密。不把「凭证泄露」当不可能事件设计。
-
判题机回查 DB 会不会把第 6 课的「削峰」优势抵消?
会增加一次 DB 读,但消息变小带来的传输收益远大于它;且回查读的是单行主键(微秒级),还可以给判题机加本地缓存。Claim Check 不是免费的,是算过账的。
-
ACL 会不会让核心域错过外部的新能力?
ACL 只隔离「格式」,不隔离「能力」。外部新增字段想用,就在 ACL 加映射 + 测试,然后领域模型决定是否吸收。防腐防的是「外部内部模型混着写」,不是「闭关锁国」。
-
这些模式和 K8s/云厂商功能是什么关系?
模式不变,实现下沉。K8s 的 Resource Limits 是现成舱壁、云对象存储原生支持预签名、Serverless 队列自带 Claim Check 语义。学模式的目的是认出问题,实现工具随时代换代。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 全站共享一个数据库连接池 | 一处慢查询饿死全站 | 按故障域分池(读/写/回调) |
| 文件上传走应用服务器中转 | 大文件占满带宽与内存 | Valet Key 预签名直传 + 回执校验 |
| 判题代码直接塞 Kafka value | broker 传输/存储/重放压力大 | Claim Check:传 ID,判题机回查 |
| 外部 DTO 直接渗透进业务代码 | 外部一改全站编译报错 | ACL 适配层 + 转换回归测试 |
| 没有验证过隔离就上线 | 舱壁参数拍脑袋,形同虚设 | 压测验证:写慢时读 P99 不受影响 |
卡点急救
- 舱壁满了全是 503:先确认是保护生效还是参数过小——看 Resilience4j 指标的排队拒绝数与下游真实负载对比;若下游实际健康,调大容量;若下游真病了,503 + 前端「稍后重试」就是正确行为,别为了好看调大上限。
- 预签名上传成功但回调丢失:前端可能没调回调。兜底方案:上传日志表 PENDING 状态配定时任务核对 MinIO 对象是否存在(
statObject),存在则补 UPLOADED——服务端校验永远是最终裁决。
- ACL 转换出 bug 导入脏数据:转换层加严格单测(用真实外部样例做 fixtures);导入前先 dry-run 出报告(多少条会转换失败);脏数据靠领域约束(数据库 NOT NULL/枚举校验)挡第二道。
面试常问
-
一个依赖服务变慢,怎么防止它拖垮你的服务?
组合拳按顺序答:① 超时(每个外部调用设合理超时,先止血);② 舱壁(限制对该依赖的最大并发,占满即快速失败);③ 熔断(失败率超阈值直接短路一段时间,快速失败);④ 降级(返回缓存/默认值/排队提示);⑤ 压测验证。这题答「重试」反而扣分——盲目重试是 Retry Storm 的起点(下一课)。
-
设计一个「用户上传 100MB 题解附件」的功能?
Valet Key 全流程:API 签发限时预签名 URL(key 服务端生成带用户标识)→ 浏览器直传对象存储 → 回调/轮询确认 → 服务端校验大小类型与安全 → 落库元数据。补充:分片续传(大文件走 multipart)、内容审核异步化、凭证有效期与防盗链。
-
消息体太大有什么问题,怎么处理?
问题:broker IO/网络压力、页缓存污染、序列化耗时、重放成本。处理:Claim Check——消息只带引用 ID,消费者回查原始数据或对象存储 URL;另可答压缩、独立大消息 topic 分流,但指出 Claim Check 是标准解。
自测三问
- 舱壁、熔断、超时三者分别解决什么?按「发现问题→动作」顺序说出它们如何配合。
- 画出 Valet Key 上传的完整时序(浏览器、API、对象存储三方),标出凭证有效期和校验点。
- 本站哪些消息适合 Claim Check?「排行榜加分事件」需要吗,为什么?
小结一句话
云设计模式把「隔离、委托、指针化、防腐」四类问题命名固化——Bulkhead 限爆炸半径,Valet Key 让大文件不过服务,Claim Check 让大载荷不进消息,ACL 让外部格式进不了核心域。
延伸