我为什么越来越少使用 .NET

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

我刚把一个 ASP.NET Core 6 项目迁进 Linux Docker。它需要连接 SQL Server 2008 R2 和 SQL Server 2005。Node.js 数据同步程序在同一台机器上一直运行正常,.NET 应用却先报 OpenSSL unsupported protocol,然后卡在 Post-Login 超时。连接字符串、证书信任、驱动版本、OpenSSL 安全等级都查了一轮,最后靠反射 Microsoft.Data.SqlClient 的私有字段,让 TDS PreLogin 发送 ENCRYPT_NOT_SUP 才启动成功。

完整协议过程和反射代码单独记录在Linux Docker 连接旧版 SQL Server 的 PreLogin 排障中。这里只讨论这类排障为什么改变了我对平台维护成本的判断。

问题就在这里:一次普通的数据库连接,最后要求业务开发者理解 OpenSSL、TDS、Managed SNI、Native SNI 和驱动内部枚举。开发者并不是在实现数据库中间件,只是想让迁移前正常工作的系统在 Linux 上继续运行。

我不反对安全默认值

微软的 SQL Server TLS 1.2 支持说明列出了各版本所需的补丁:SQL Server 2005 不支持 TLS 1.2,SQL Server 2008 R2 也需要安装到特定补丁版本才有 TLS 1.2。现代 Linux 默认拒绝 TLS 1.0 和旧密码套件,完全有理由。

Microsoft.Data.SqlClient 的驱动支持周期与兼容表也体现了它面向较新 SQL Server 版本的边界。它默认要求更安全的连接,我并不反对。新项目就该使用可信证书和现代 TLS。

真正的问题是,兼容路径几乎不存在于公共接口里。系统已经明确写了 Encrypt=False,驱动仍然在登录阶段进入临时 TLS 协商;失败后给出的错误一会儿像证书问题,一会儿像网络超时。Microsoft.Data.SqlClient 5.2.3 的 TdsParser 源码内部明明存在 NOT_SUP 分支,却没有一个受支持的公共开关可以选择它。

安全默认值可以严格,但不能把“无法升级旧实例的用户”直接丢进私有源码里。

“升级数据库”不是一句有成本意识的话

社区里最容易出现的答案是升级 SQL Server。

我同意升级方向,但它解决不了这次发布窗口里的兼容问题。

升级一个 NuGet 包可能只要半小时。升级一个运行十几年的数据库实例,需要盘点存储过程、驱动、操作系统、授权、备份、停机窗口和所有依赖它的旧客户端。出了问题还要能够回滚。很多时候,维护应用的人甚至没有数据库升级权限。

把数据库升级当成唯一答案,相当于让提出驱动兼容问题的人先完成一个基础设施迁移项目。现实中的软件维护不是这样运转的。

数据库升级应该作为独立的基础设施项目推进,而不是被塞进一次客户端发布,成为应用能否启动的临时前置条件。

两个 SqlClient 是谁都不轻松的历史包袱

项目里同时出现过:

System.Data.SqlClient
Microsoft.Data.SqlClient

这不是开发者故意重复引入依赖。微软介绍 Microsoft.Data.SqlClient 新命名空间的文章说明,System.Data.SqlClient 原来属于 .NET Framework,后来为了独立发布新功能而建立了新包,并允许两个命名空间并存。

从平台演进看,这个决定有技术理由。从维护者视角看,它留下了长期混乱:EF Core 可能使用新的驱动,旧 Dapper 仓储仍然引用旧驱动,工具项目又显式安装了另一版包。同一份连接字符串,在不同代码路径上可能遇到不同的默认加密行为和不同 SNI 实现。

“以后只用 Microsoft.Data.SqlClient”是清楚的方向,可统一之前,开发者仍要追踪每条数据库调用链,并承担驱动迁移工作。

跨平台经常停在“可以运行”

ASP.NET Core 确实能在 Linux 上运行。Docker 镜像也很成熟。

但 Windows 上的 SqlClient 通常使用 Native SNI,Unix 上默认使用 Managed SNI。只要行为在协议边界上出现差异,“Windows 能连接”就不再是有效证据。你得知道当前加载的是哪套 SNI,TLS 由 Schannel 还是 OpenSSL 提供,容器基础镜像采用什么安全策略。

这不是抽象的跨平台问题。它会直接决定数据库能不能登录。

如果跨平台只做到“同一份程序集可以在 Linux 上启动”,还不够。数据库驱动、TLS 后端和网络实现同样属于运行边界。.NET 的主体已经跨平台,这些边缘部分却仍然保留着很深的 Windows 历史,而遗留项目最容易碰到它们。

错误信息没有帮我缩小范围

这次先后看到:

pre-login handshake failed
unsupported protocol
post-login timeout
Unknown error 258

这些信息每一条都是真的,组合起来却没有说明客户端究竟向服务端声明了 OFFON 还是 NOT_SUP

Post-Login 等了约 14 秒,于是最自然的误判是连接超时太短。把 15 秒改成 30 秒,只会多等 15 秒。

异常消息不必自动给出修复代码,但数据库驱动至少应该能在诊断日志里打印 PreLogin 的加密选项、实际 SNI 实现和 TLS 后端。现在这些信息要靠读源码、看堆栈,再用反射验证。定位成本和问题本身完全不成比例。

默认值越来越偏向平台理想状态

这个项目迁入容器后,空闲时的工作集一度超过 450 MiB。第一眼很像业务缓存或内存泄漏。

dotnet-countersdotnet-gcdump 做完对照,完整 GC 后真正存活的托管对象只有约 50.9 MiB。.NET GC 配置文档说明了 Server GC、并发 GC 和保留内存等开关。这里的高工作集主要来自 Server GC、启动临时分配、GC 保留段、程序集加载和 JIT;在 16 个逻辑 CPU 的机器上,Server GC 会创建多组 heap 和专用线程。

这些指标的范围和复现实验见从 Working Set、GC Heap 到内存泄漏定位

这个管理后台只有几个业务人员零星使用。切到 Workstation GC,并设置 DOTNET_GCConserveMemory=5 后,空闲工作集降到约 205 至 218 MiB。20 次连续请求都正常。

Server GC 面向吞吐量有道理。可 ASP.NET Core 的“服务器应用”不全是高并发平台。一个低流量后台为了合理的空闲内存,还要先学习 GC heap 数量、Committed Bytes 和工作集之间的区别。

类似的摩擦越来越多:监控包弃用、运行时进入 EOL、驱动默认值变化,每一项单独看都有理由,叠在一个遗留业务系统上就是持续迁移。

连“验证一下”都很重

C# 的类型检查发生在编译过程中,dotnet build 因而也是主要验证入口。它支持增量构建,依赖已经恢复时也可以使用 --no-restore;这里让我困扰的不是每次都必然重新下载包,而是旧解决方案的验证边界很难缩小。

这个解决方案包含很多项目和生成步骤。一次很小的修改也常会触发较大的依赖图,首次恢复或生成代码时尤其慢。团队要么等待完整构建,要么开始跳过验证,这才是我遇到的实际问题。

TypeScript 的 tsc --noEmit 把类型验证表达得很清楚,构建产物和类型检查可以是两个明确动作。dotnet build 当然能完成检查,但命令语义和执行成本都更重。

Node.js 为什么显得更容易控制

同一个项目里,Node.js 数据同步程序使用 mssqltedious,显式设置 encrypt: false 后一直正常连接这些旧实例。配置能看懂,容器发布也简单。它成了这次排障最有价值的对照组。

这不代表 Node.js 天生更可靠。tedious 也有 OpenSSL 与 SQL Server 2008 R2 的兼容 issue,Node 版本升级同样可能带来 TLS 变化。换语言不会让 SQL Server 2005 变安全。

真正形成差异的是可控感。遇到问题时,Node.js 项目通常更容易确认当前用了哪个包、哪些连接选项、最后发出了什么行为。即使要读源码,JavaScript 依赖也直接随项目存在,不需要先分辨运行时内置组件、NuGet 包、平台原生库和托管实现。

当维护时间有限时,这种可控感比基准测试里的吞吐数字更重要。

换语言不是这篇文章的结论

把十几年的系统整体改写成 Node.js,同样要重新验证业务规则、数据一致性和异常行为。换一种语言不会自动消除遗留系统的复杂度,也不会让 SQL Server 2005 获得现代 TLS。

Node.js 在这次故障中的意义只是提供了一个对照:同一台 Linux 主机、同一批数据库、同样关闭连接加密,一个驱动能够工作,另一个驱动必须修改私有字段。这说明问题不只是“数据库太旧”,也包括客户端是否提供了可以理解和控制的兼容路径。

.NET 与 .NET Core 的支持策略明确划分了各版本的支持周期。平台停止支持旧软件可以理解,但支持策略不能代替诊断能力。驱动可以拒绝不安全的协议,也应该明确打印拒绝发生在哪个阶段、使用了哪套网络实现,以及客户端最终声明了什么加密能力。如果内部已经存在兼容分支,却没有公共开关,只留下证书错误和超时,维护者只能用远高于问题本身的成本定位。

这些经历让我减少了在遗留业务中继续引入 .NET 的意愿。驱动分裂、平台实现差异、偏向吞吐量的默认策略和较大的构建边界都能解释,但维护者仍要逐项付出时间。.NET LTS 的三年支持期并不神秘;对于计划运行十年以上、升级窗口又很少的系统,它仍意味着需要提前安排持续迁移。这是我的使用场景和取舍,不是对所有 .NET 项目的结论。