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