.NET 6 gRPC如何根据客户端初始选择持久绑定对应数据库连接
.NET 6 gRPC 多同构数据库路由最佳实践
整套方案无第三方依赖,完全适配.NET 6原生gRPC、WinForms客户端的技术栈,侵入性极低,性能足够应对绝大多数场景。
核心思路
通过客户端固定唯一标识做身份锚定,用gRPC双向拦截器做无侵入的上下文透传,服务端维护客户端与目标数据库的映射关系,动态给EF Core/数据库连接分配对应连接串,全程不需要修改现有proto契约和业务逻辑代码。
客户端实现步骤
- 首次启动初始化逻辑:用户在下拉框选完目标数据库后,生成一个全局唯一的
Guid作为当前客户端实例的固定ID,和选中的数据库标识(比如枚举值1/2/3对应三个库)一起存在本地持久化存储里,WinForms直接用内置的Properties.Settings存就行,也可以存在用户目录下的json配置文件,下次启动直接读取,不再弹出选择框。如果用户后续需要切换数据库,提供设置入口重新选择后,重置本地存的客户端ID和数据库标识即可。 - 统一gRPC客户端拦截器配置:不用给每个gRPC接口加传参,写一个全局客户端拦截器,所有发出的请求自动在Metadata头里加两个字段:
x-client-instance-id(本地存的固定客户端ID)、x-target-db(本地存的目标数据库标识,等服务端完成映射注册后,后续请求可以不再带这个字段,减少无效传输)。 - 客户端gRPC配置的时候直接把这个拦截器注入到Channel里,全局生效,所有业务接口不用改任何调用代码。
服务端实现步骤
- 前置配置:把三个同构库的连接字符串统一存在
appsettings.json里,自定义数据库类型枚举对应三个库,写好根据枚举值取对应连接串的静态方法;EF Core配置用AddDbContextFactory注册DbContext,不要在启动时写死连接字符串,留动态传入连接串的入口。 - 映射存储:单实例部署直接用.NET自带的并发安全字典
ConcurrentDictionary<string, DbType>存储客户端ID和目标数据库的映射关系,key是客户端传上来的实例ID,value是对应数据库枚举;如果是多实例集群部署,把这个存储换成Redis等分布式缓存即可,映射缓存TTL设为30天,每次客户端请求过来自动续期。 - 服务端gRPC拦截器:写一个全局服务端拦截器,拦截所有进来的gRPC请求,逻辑如下:
- 先从请求头取
x-client-instance-id,取不到直接返回未认证错误,避免非法请求打到业务层。 - 查映射存储里有没有这个客户端ID对应的数据库记录:如果有,直接把对应的数据库类型存到当前请求的
HttpContext.Items中,传递给后续链路。 - 如果没有映射记录,说明是客户端首次请求,从请求头取
x-target-db字段校验合法性,校验通过后把客户端ID和数据库的映射写入存储,再把数据库类型存入HttpContext.Items。
- 先从请求头取
- 动态数据库连接:在DbContext的
OnConfiguring方法里,从当前请求上下文的HttpContext.Items中取出目标数据库类型,拿到对应连接字符串配置数据库提供程序(比如SqlServer/MySql)即可,业务层代码完全感知不到多数据库的存在,和之前单库写法完全一致。
关键避坑点
- 不要用客户端IP作为身份标识:NAT网络下多台客户端可能共用同一个出口IP,会导致数据库路由串库,用本地持久化存储的固定Guid做标识是成本最低、最稳定的方案。
- 不要把数据库选择参数加到每个proto接口的请求体里:后续接口迭代维护成本极高,用gRPC请求头透传是无侵入的标准做法。
- 不要每次请求都让客户端传目标数据库标识:既增加传输成本,也容易被恶意篡改请求头越权访问其他库的数据,服务端存映射之后只认首次注册的绑定关系即可。
- 定期清理过期映射:给映射存储加简单的清理逻辑,比如超过30天没有请求的客户端映射记录定期删除,避免内存占用持续增长。
核心代码片段
客户端全局拦截器
public class ClientRouteInterceptor : Interceptor { private readonly string _clientId; private readonly string _dbType; public ClientRouteInterceptor(string clientId, string dbType) { _clientId = clientId; _dbType = dbType; } public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>( TRequest request, ClientInterceptorContext<TRequest, TResponse> context, AsyncUnaryCallContinuation<TRequest, TResponse> continuation) { var meta = new Metadata { { "x-client-instance-id", _clientId }, { "x-target-db", _dbType } }; var newCtx = new ClientInterceptorContext<TRequest, TResponse>( context.Method, context.Host, context.Options.WithHeaders(meta)); return base.AsyncUnaryCall(request, newCtx, continuation); } }
服务端路由拦截器
public class ServerRouteInterceptor : Interceptor { private readonly ConcurrentDictionary<string, DbType> _routeMap = new(); public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>( TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation) { var httpCtx = context.GetHttpContext(); var clientId = context.RequestHeaders.GetValue("x-client-instance-id"); if (string.IsNullOrWhiteSpace(clientId)) throw new RpcException(new Status(StatusCode.Unauthenticated, "无效客户端请求")); if (!_routeMap.TryGetValue(clientId, out var targetDb)) { var dbVal = context.RequestHeaders.GetValue("x-target-db"); if (!Enum.TryParse<DbType>(dbVal, out targetDb)) throw new RpcException(new Status(StatusCode.InvalidArgument, "目标数据库参数非法")); _routeMap.TryAdd(clientId, targetDb); } httpCtx.Items["TargetDb"] = targetDb; return await continuation(request, context); } } public enum DbType { DbA = 1, DbB = 2, DbC = 3 }
内容的提问来源于stack exchange,提问作者sharri
相关产品推荐
相关产品推荐

