蓄水池抽样是单次遍历大文件等概率随机抽取k行的唯一方法:用StreamReader逐行读取,前k行入池,第i行(i>k)以k/i概率替换池中随机一行,注意复用Random实例防种子重复。

用 StreamReader 逐行读取 + 蓄水池抽样(Reservoir Sampling)
大文件不能全载入内存,File.ReadAllLines 或 File.ReadLines().ToArray() 会直接 OOM。必须流式处理,而蓄水池抽样是唯一能在单次遍历中等概率随机抽取 k 行的算法——它不依赖文件总行数,也不需要预扫描。
实操建议:
- 用
StreamReader手动逐行读取,避免 LINQ 的隐式缓存(比如File.ReadLines().Skip().Take()在跳过大量行时性能极差) - 初始化一个长度为
k的List<string></string>作为“蓄水池”,前k行无条件加入 - 从第
k+1行开始,对每一行i(索引从 1 开始),以概率k / i决定是否替换蓄水池中某个随机位置的行 - 注意:
i是从 1 开始的行号,不是数组下标;用Random.Next(0, i)判断是否替换,再用Random.Next(0, k)选替换位置
Random 实例复用与线程安全陷阱
在循环里反复 new Random() 会导致大量重复种子(尤其高频率调用时),样本严重偏斜——你可能抽到连续几十行都来自文件开头。
常见错误现象:for (int i = k + 1; i —— 这几乎必然失败。
实操建议:
- 把
Random声明为局部变量,在方法开头 new 一次,全程复用 - 不要用
new Random(Guid.NewGuid().GetHashCode())等“花招”,不如老老实实用默认构造函数(.NET Core 6+ 已修复默认种子碰撞问题) - 如果在多线程环境中并行处理多个文件,确保每个线程有自己的
Random实例;别共享同一个实例(Next()不是线程安全的)
中文路径、BOM、超长行的兼容处理
真实日志或导出文件常含中文路径、UTF-8 BOM、空行、几 MB 的单行 JSON——这些都会让朴素的 ReadLine() 出问题。
使用场景:日志分析、ETL 预览、模型训练前的数据探查。
实操建议:
- 用
new StreamReader(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)显式处理 BOM - 对每行调用
line?.Trim()后判断是否为空,跳过空行(否则空行也会被计入行号i,破坏等概率性) - 加 try-catch 包裹
ReadLine(),捕获OutOfMemoryException(超长行触发)并跳过该行,继续下一行——别让单行失败中断整个采样 - 慎用
Encoding.Default,Windows 上它可能是 GBK,遇到 UTF-8 文件会乱码甚至抛DecoderFallbackException
性能关键点:缓冲区大小与 IO 吞吐
默认 StreamReader 缓冲区是 1024 字节,对平均行长远超 1KB 的文件(如带完整堆栈的日志),频繁触发底层 Read() 调用,IO 开销陡增。
参数差异:new StreamReader(stream, Encoding, true, bufferSize: 65536) 中 bufferSize 设为 64KB,实测在机械盘上提速约 3–5 倍,SSD 上也有 15%+ 提升。
实操建议:
- 缓冲区不必盲目调大,64KB 是实测平衡点;超过 128KB 后收益急剧下降,还可能挤占其他 IO 缓冲
- 不要用
File.OpenText(),它无法指定缓冲区大小,且编码固定为 UTF-8 无 BOM 检测 - 如果文件是压缩格式(如 .gz),别先解压再读——改用
GZipStream包裹FileStream,再传给StreamReader,内存占用几乎不变
蓄水池抽样的核心约束很硬:必须单遍、不能回退、不能预知总行数。所有绕开它的“取随机偏移再 seek”的做法,在文本文件上基本都失效——因为换行符长度不定,Seek() 到任意字节位置大概率落在行中间。这点容易被忽略,但决定了方案生死。


















