微服务与跨服务通信(对照 Spring Cloud)
这一课你将能做到
- 把判题平台按「题库 / 判题 / 用户」拆出服务边界,说清拆与不拆的判断标准
- 用 gRPC 定义判题服务内部接口,用类型化 HttpClient + 标准弹性策略调用下游
- 用 YARP 搭 API 网关,配健康检查,对照 Spring Cloud Gateway/Nacos/OpenFeign 完成映射
为什么要学
单体够用前不要拆微服务。「拆」的时机一到,需要回答四个问题:服务间怎么调用(同步 REST/gRPC)、怎么扛失败(超时/重试/熔断)、流量怎么进来(网关)、实例怎么找到彼此(服务发现)。Spring Cloud 全家桶(Gateway/OpenFeign/Resilience4j/Nacos)在 .NET 侧对应:YARP/HttpClientFactory+Resilience/gRPC/K8s DNS——理念相同,组件更「原生」。
人话:微服务的本质不是技术栈,是「团队与部署的边界」。判题要独立伸缩(CPU 密集且要沙箱安全)、题库读多写少、用户服务全局共享——这三块天然是三个部署单元。
先建立图像
┌───────────────┐
浏览器 ──HTTPS──→ │ YARP 网关 │ /api/problems/* → 题库服务
│ (路由/认证/限流) │ /api/submissions/* → 提交服务
└───────┬───────┘
│ gRPC(内部, 低延迟强类型)
┌───────────────────┼────────────────────┐
题库服务 提交服务 判题服务(扩缩容)
(EF+MySQL) (API+队列发布) (沙箱, BackgroundService)
| Spring Cloud | ASP.NET Core 生态 | 职责 |
|---|
| Spring Cloud Gateway | YARP(基于 .NET 的反向代理) | 网关/路由/限流 |
| OpenFeign | 类型化 HttpClient(IHttpClientFactory) | 声明式客户端 |
| Resilience4j | Microsoft.Extensions.Http.Resilience / Polly | 重试/熔断/超时 |
| Eureka / Nacos | K8s DNS / Consul | 服务发现 |
| Spring Boot Actuator | 健康检查中间件 + AspNetCore.HealthChecks | 健康探测 |
跟着做
步骤 1:gRPC 定义判题接口(内部通信首选)
judge.proto(共享给服务端与客户端):
syntax = "proto3";
option csharp_namespace = "Oj.Contracts";
service Judge {
rpc Submit (JudgeRequest) returns (JudgeReply);
}
message JudgeRequest {
int64 submission_id = 1;
string language = 2;
string code = 3;
int32 time_limit_ms = 4;
}
message JudgeReply {
string status = 1;
int32 time_ms = 2;
int32 memory_kb = 3;
}
服务端实现:
dotnet add package Grpc.AspNetCore
public class JudgeGrpcService(OjDbContext db) : Judge.JudgeBase
{
public override async Task<JudgeReply> Submit(JudgeRequest req, ServerCallContext ctx)
{
var result = await SandboxRunner.RunAsync(req.Code, req.Language, req.TimeLimitMs, ctx.CancellationToken);
return new JudgeReply { Status = result.Status, TimeMs = result.TimeMs, MemoryKb = result.MemoryKb };
}
}
// Program.cs
builder.Services.AddGrpc();
app.MapGrpcService<JudgeGrpcService>();
dotnet add package Grpc.Net.ClientFactory
builder.Services
.AddGrpcClient<Judge.JudgeClient>(o => o.Address = new Uri("http://judge-service:5100"))
.AddStandardResilienceHandler(); // 挂弹性策略
var reply = await judgeClient.SubmitAsync(new JudgeRequest
{
SubmissionId = id, Language = "cpp", Code = code, TimeLimitMs = 2000
});
步骤 2:REST 调用(对外/低频场景,对应 OpenFeign)
public record UserInfoDto(Guid Id, string DisplayName);
public class UserApiClient(HttpClient http) // 类型化客户端 ≙ Feign 接口
{
public async Task<UserInfoDto?> GetAsync(Guid id, CancellationToken ct) =>
await http.GetFromJsonAsync<UserInfoDto>($"api/users/{id}", ct);
}
builder.Services.AddHttpClient<UserApiClient>(c => c.BaseAddress = new Uri("http://user-service:5200"))
.AddStandardResilienceHandler();
IHttpClientFactory 解决两件事与 Java 相同:避免 socket 耗尽、集中配置弹性策略。
步骤 3:弹性策略(.NET 8 内置 resilience ≙ Resilience4j)
builder.Services.AddHttpClient<UserApiClient>(c => c.BaseAddress = new Uri("http://user-service:5200"))
.AddResilienceHandler("user-pipeline", b =>
{
b.AddTimeout(TimeSpan.FromSeconds(3)); // 总超时
b.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true, // 抖动防重试风暴
});
b.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5, // 50% 失败率熔断
MinimumThroughput = 20,
BreakDuration = TimeSpan.FromSeconds(30),
});
});
.AddStandardResilienceHandler() 是这四件(超时/重试/熔断/舱壁)的官方推荐预设,非定制场景直接用。
步骤 4:YARP 网关
dotnet add package Yarp.ReverseProxy
{
"ReverseProxy": {
"Routes": {
"problems": {
"ClusterId": "problem-cluster",
"Match": { "Path": "/api/problems/{**catch-all}" }
},
"submissions": {
"ClusterId": "submission-cluster",
"Match": { "Path": "/api/submissions/{**catch-all}" },
"AuthorizationPolicy": "Authenticated"
}
},
"Clusters": {
"problem-cluster": {
"Destinations": {
"d1": { "Address": "http://problem-service:5001/" }
},
"HealthCheck": { "Active": { "Enabled": true, "Path": "/healthz" } }
}
}
}
}
builder.Services.AddReverseProxy().LoadFromConfig();
app.UseAuthentication();
app.UseAuthorization();
app.MapReverseProxy(); // 网关还兼任 JWT 校验与全局限流(AddRateLimiter)
负载均衡、主动健康检查、灰度分流都是配置项(Route + Cluster + Transforms)。
步骤 5:健康检查(K8s 探针的数据源)
dotnet add package AspNetCore.HealthChecks.NpgSql
dotnet add package AspNetCore.HealthChecks.Redis
builder.Services.AddHealthChecks()
.AddNpgSql(connString, name: "db")
.AddRedis(redisConn, name: "redis");
app.MapHealthChecks("/healthz/live"); // 存活:进程在就行
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("ready") // 就绪:依赖可达
});
K8s 里 livenessProbe → /healthz/live、readinessProbe → /healthz/ready,与 actuator 的 /actuator/health/liveness|readiness 同一设计。
步骤 6:服务发现(K8s 环境最简)
// K8s 内:服务名即 DNS,无需注册中心
// http://judge-service.default.svc.cluster.local:5100
// 本地开发:appsettings 按环境覆盖 BaseAddress
讲清楚要点
- 拆分边界按业务能力与伸缩特征,不按技术分层:题库(读多缓存重)/提交(写多事务重)/判题(CPU 密集 + 安全隔离)各自可独立部署扩缩。早期先「服务内模块化」,流量与团队到位再物理拆——同 Spring modular monolith 主张。
- 内部通信 gRPC、外部 REST:gRPC 基于 HTTP/2 + Protobuf,低延迟、契约强类型,适合服务间高频调用;对浏览器/第三方仍暴露 REST + JSON。Java 侧 grpc-spring-boot-starter 同角色。
- 每一次跨网络调用都要假设会失败:超时(比下游更早放弃)→ 重试(只重试幂等请求,带抖动)→ 熔断(阻止雪崩式重试)→ 降级(兜底响应)。顺序不能反:没有超时的重试是灾难放大器。
- 数据一致性放弃强一致:跨服务不搞分布式事务,用 Saga(判题失败发补偿事件回滚提交状态)或事件驱动最终一致。与 Spring 社区 Saga/Outbox 实践同源。
你可能会懵的地方
-
服务发现用不用 Nacos/Eureka?
K8s 部署不需要:Service 名就是稳定 DNS,探针做摘流。虚拟机/混合部署才需要 Consul。Spring 的 Nacos 兼管配置中心,.NET 侧用环境变量 + 挂载文件。
-
gRPC 和消息队列怎么分工?
同步问答(判题要立刻拿返回值)走 gRPC;异步事实广播(通知多订阅方)走 MQ。把需要「等回复」的流程塞进消息队列是常见设计事故。
-
每个服务要独立的数据库吗?
是。共享库会让服务因表结构耦合,发布互相牵制。跨服务取数走 API/gRPC 或订阅事件落本地副本——Java 微服务训诫相同。
-
链路追踪怎么做?
OpenTelemetry + OTLP 导出器一行接入,trace id 自动跨 gRPC/HTTP/MQ 传播,后端接 Jaeger/Tempo——对应 Spring 的 Micrometer Tracing/SkyWalking。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 重试不带超时/不带幂等判断 | 慢下游 + 重复副作用,雪上加霜 | 先超时、再重试(幂等接口)、带 jitter |
| 网关里写业务逻辑 | 网关变成单体,发布风险扩散 | 网关只做路由与横切,聚合进 BFF/服务 |
| 两个服务直连对方数据库 | 隐式耦合,schema 一改全线崩 | API/事件交互,数据自治 |
| 内部调用不做熔断 | 一个服务抖动拖垮整条调用链 | StandardResilienceHandler 全局默认挂载 |
卡点急救
- gRPC 客户端报 HTTP/2 不支持:gRPC 必须 HTTP/2。K8s 内部直连没问题;经 Nginx 要开
grpc_pass 与 h2c。
- proto 改了客户端字段对不上:Protobuf 兼容规则——字段号不可复用/修改,只新增;两端用同一 Contracts 包发版,弃用字段标
reserved。
- YARP 路由不生效:检查
MapReverseProxy() 是否在 UseAuthentication/UseAuthorization 之后;路径转换 {**catch-all} 写法。
面试常问
-
单体什么时候该拆微服务?判题平台怎么拆?
信号:模块部署节奏冲突(判题要频繁发布/扩容)、资源特征差异、团队并行度受限。判题平台拆题库、提交、判题三服务 + 网关。反信号:只有性能诉求没组织诉求时,单体 + 模块化更划算。
-
gRPC 相比 REST/JSON 的取舍?
得:Protobuf 小而快、HTTP/2 多路复用、契约生成强类型双端代码、双向流。失:浏览器直连受限(需 gRPC-Web)、调试成本高。实践:内部服务间 gRPC,对外 API 仍 REST。
-
跨服务的数据一致性怎么设计?
放弃 2PC,走 Saga/最终一致:判题失败发补偿事件回滚提交状态;关键链路用 Outbox 保证「本地事务 + 消息投递」原子;对账兜底。每步补偿操作的设计是 Saga 落地核心。
自测三问
- 网关与服务的职责边界怎么划?
- 标准弹性管线的四个策略和挂载顺序?
- 为什么「重试只用于幂等操作」?给出两个非幂等例子。
小结一句话
微服务 = 边界判断(该不该拆)+ 通信选型(gRPC 内 / REST 外 / MQ 异步)+ 弹性四件套(超时/重试/熔断/降级)+ 网关与发现;.NET 侧组件名与 Spring Cloud 不同,决策框架逐字相同。
延伸