一:背景
1. 讲故事
好久都没写文章了,看现在各个社区大多都是AI写的文章,劣币驱除良币,文字这块算是沦陷了,没多少 passion to continue,但遇到一些经典的还是会人肉堆一堆,这篇我们就来分析一个内存暴涨的例子,这是一个朋友在微信上找到我的,有一个linux上的.NET程序,内存在一直暴涨,发现非托管内存占用不少,让我帮忙看下咋回事。
二:内存暴涨分析
1. 为什么会暴涨
既然是Linux上的dump,用传统的 !address -summary 就不靠谱了,这里就需要用 !maddress 命令去看看,截图如下:
C#
0:000> !maddress -summary
+-------------------------------------------------------------------------+
| Memory Type | Count | Size | Size (bytes) |
+-------------------------------------------------------------------------+
| GCHeap | 24 | 1.19gb | 1,282,744,320 |
| PAGE_READWRITE | 173 | 1016.34mb | 1,065,705,472 |
| Stack | 86 | 693.90mb | 727,609,344 |
| Image | 1,129 | 147.84mb | 155,026,432 |
| HighFrequencyHeap | 411 | 25.66mb | 26,906,624 |
| LowFrequencyHeap | 272 | 18.70mb | 19,607,552 |
| LoaderCodeHeap | 13 | 17.00mb | 17,825,792 |
| HostCodeHeap | 10 | 1.16mb | 1,220,608 |
| ResolveHeap | 1 | 348.00kb | 356,352 |
| PAGE_READONLY | 113 | 239.00kb | 244,736 |
| DispatchHeap | 1 | 196.00kb | 200,704 |
| IndirectionCellHeap | 3 | 152.00kb | 155,648 |
| LookupHeap | 3 | 144.00kb | 147,456 |
| StubHeap | 3 | 140.00kb | 143,360 |
| PAGE_EXECUTE_WRITECOPY | 5 | 116.00kb | 118,784 |
| CacheEntryHeap | 2 | 100.00kb | 102,400 |
| PAGE_EXECUTE_READ | 2 | 8.00kb | 8,192 |
+-------------------------------------------------------------------------+
| [TOTAL] | 2,251 | 3.07gb | 3,298,123,776 |
+-------------------------------------------------------------------------+
从卦中可以看到,程序总计吃了 3.07G,其中 GCHeap 和 PAGE_READWRITE 吃的差不多,看起来不大乐观,要先追踪 PAGE_READWRITE 的调用栈,在 linux 上不是那么容易的。
2. 从托管堆入手
接下来怎么办呢?先死马当做活马医,因为毕竟是托管程序,很多非托管内存的root都和托管堆对象有关,本着这个思想,先用 !dumpheap -stat 观察下托管堆看看。
C#
0:000> !dumpheap -stat
Statistics:
MT Count TotalSize Class Name
...
7f59a044dc88 3,765 331,320 System.Data.SqlClient.SNI.SNIMarsConnection
7f59a02a5e88 11,295 903,600 System.Collections.Generic.Dictionary<System.Data.SqlClient.SNI.SNIPacket, System.Data.SqlClient.SNI.SNIPacket>
7f59a02a2238 11,295 4,608,360 System.Data.SqlClient.SNI.TdsParserStateObjectManaged
...
7f59a029f7b8 3,765 7,801,080 System.Data.SqlClient.SessionStateRecord[]
7f5999a9a740 3,882 7,817,152 System.Byte[][]
7f59a044b738 139,544 25,676,096 System.Data.SqlClient._SqlMetaData
7f599b869e78 145,256 27,889,152 xxx.SaleDeptInfo
7f59993fd2e0 2,119,130 100,150,180 System.String
556ba0740670 8,748 341,803,536 Free
7f59999cd8b8 103,573 412,502,480 System.Byte[]
Total 3,599,633 objects, 998,240,010 bytes
仔细观察卦中的数据,很容易发现 SqlClient 相关的对象的数量有点多,尤其是 SNIMarsConnection 高达 3765 个,这个是不正常的,你可以简单理解底层开了 3765 个 connection 链接,每个链接都会吃一点非托管资源,所以非托管内存就这样上去了。
3. SNIMarsConnection 是啥
Mars 全称 multiple active result sets,主要是解决 command 下的多 reader 问题,这里大家可以问下大模型,具体就不说了,接下来就从 SNIMarsConnection 入手,看看它的root情况。
C#
0:000> !dumpheap -mt 7f59a044dc88
Address MT Size
...
7f5804d31938 7f59a044dc88 88
7f5804d6c950 7f59a044dc88 88
7f5804d99930 7f59a044dc88 88
7f5804dcf330 7f59a044dc88 88
Statistics:
MT Count TotalSize Class Name
7f59a044dc88 3,765 331,320 System.Data.SqlClient.SNI.SNIMarsConnection
Total 3,765 objects, 331,320 bytes
0:000> !gcroot 7f5804dcf330
HandleTable:
00007f5a113510f8 (strong handle)
-> 7f5943fff018 System.Object[]
-> 7f5684029f90 System.Data.SqlClient.SNI.SNIMarsManager (static variable: System.Data.SqlClient.SNI.SNILoadHandle.SingletonInstance)
-> 7f5684029fa8 System.Collections.Concurrent.ConcurrentDictionary<System.Data.SqlClient.SNI.SNIHandle, System.Data.SqlClient.SNI.SNIMarsConnection>
-> 7f558679f550 System.Collections.Concurrent.ConcurrentDictionary<System.Data.SqlClient.SNI.SNIHandle, System.Data.SqlClient.SNI.SNIMarsConnection>+Tables
-> 7f558678cd38 System.Collections.Concurrent.ConcurrentDictionary<System.Data.SqlClient.SNI.SNIHandle, System.Data.SqlClient.SNI.SNIMarsConnection>+Node[]
-> 7f5804dcf480 System.Collections.Concurrent.ConcurrentDictionary<System.Data.SqlClient.SNI.SNIHandle, System.Data.SqlClient.SNI.SNIMarsConnection>+Node
-> 7f5804dcf330 System.Data.SqlClient.SNI.SNIMarsConnection
Found 1 unique roots.
0:000> !do 7f5684029f90
Name: System.Data.SqlClient.SNI.SNIMarsManager
MethodTable: 00007f59a044da00
EEClass: 00007f59a043ecc8
Tracked Type: false
Size: 24(0x18) bytes
File: /xxx/System.Data.SqlClient.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007f59a044dd18 400067e 8 ....Data.SqlClient]] 0 instance 00007f5684029fa8 _connections
00007f59a044da00 400067d 3f0 ...NI.SNIMarsManager 0 static 00007f5684029f90 Singleton
0:000> !ext dcd 00007f5684029fa8
System.Collections.Concurrent.ConcurrentDictionary<System.Data.SqlClient.SNI.SNIHandle, System.Data.SqlClient.SNI.SNIMarsConnection>
-----
Key: dumpobj 00007f5587a88430
Value: dumpobj 00007f5686cb0988
---------------------------------------------
3765 items
0:000> !objsize 7f5684029f90 -summary
Objects which 7f5684029f90 (System.Data.SqlClient.SNI.SNIMarsManager) transitively keep alive:
...
Total 943,803 objects, 431,256,544 bytes
从卦中看,原来这 3765 个 connection 都是被 SNIMarsManager 所持有,接下来就是扒它的源码,截图如下:

源码是有了,但貌似useless,接下来怎么办呢?全网求助啦。
4. 网络求助
很快就找到了一篇文章:https://joshthecoder.com/2021/10/26/preventable-mars-connection-leaks.html ,作者很详细的讲解了 MARS 的来龙去脉,主要原因就是用户开了 MultipleActiveResultSets=True 特性之后,后续使用 connection 套件的时候没有及时的 dispose/close 引发的问题,感兴趣的朋友可以去看看。
为了方便验证,我写了一个小脚本,去看看 connectionstring 是不是带有 MultipleActiveResultSets=True,这里也给大家安全提醒,如果这些信息丢给大模型,可能就是数据出境了,风险你懂的。。。
js
function invokeScript() {
var output = exec("!dumpheap -mt 7f599fe3c538").Skip(1);
for (var line of output) {
if (!line) break;
var addr = line.split(' ')[0].trim();
var connection = exec("du /c100 poi("+addr+"+0x38)+0xc").First();
log("addr="+addr+" connection="+connection);
}
}

所以这个问题的quick fix也很简单,去掉 MultipleActiveResultSets=True 就可以了。
这个方案可以这么 quick fix,但如果要治根的话需要优化代码,但这个改动不是那么快速,后来又想想感觉微软的底层做的也不是那么好,应该要有类似的timer机制来自动化压缩和增长,奔着这个思路在网上找找,还真给找到了,参考:https://github.com/dotnet/runtime/issues/22949

从卦上可以清晰的看到,升级下 SQLClient 的版本也是可以的。
到这里所有的来龙去脉都搞清楚了,做好两件事情即可。
MultipleActiveResultSets=True可以快速应急。- 升级
SQLClient版本治根,当然也可以自己优化代码。
三:总结
这次生产事故本质上来说是微软官方库的bug导致的问题,有时候追到这里也是挺无奈的。
