大数据块(BLOBs)与流(Stream)操作性能规范
背景
编程过程中,常会遇到图片、音视频、日志文件等较大数据的处理,比如拷贝、读取、写入等。如果使用简单的文件、网络、数据库API将这些对象做整块的处理,意味着整个处理过程的每一个环节都需要分配和被处理对象一样大的内存块,造成处理过程的长时间读写准备以及内存的浪费与分配失败问题(如果一个对象超过2G,基本就会受到一些限制而直接报错)。
概念:流
流是一组连续的数据,具有开头和结尾,并且使用游标指示当前位置。
什么时候要用流呢?举个例子,污水处理厂,污水处理厂到底要处理多少污水?设计的时候是不知道的,也许在这个厂子关闭的那一天可以统计出来。在设计污水处理过程的时候,其实是不用考虑污水处理的总量的,污水处理厂只处理当前流到处理池中的污水,处理好了的水,流出处理池,它不再管理,还没流到处理池中来的污水,处理过程也不关心。污水处理厂在设计的时候只需根据估算的污水流量,选择合理大小的处理池就行了。
这样的场景很多,比如文件拷贝程序,Windows提供这个功能的时候,并不知道你要拷的是个多大的文件,比如视频播放程序,QQ影音并不知道你要放的是高清的还是10m的小视频,再比如打电话的时候,电信并不知道你要打多久,这个声音信息到底会是多大。
那好了,所有的这些情况,都把它设计成一种连续数据的流来处理就好了。
概念:Buffer
流数据通常需要一个Buffer,把当前待处理的数据放在Buffer中,处理程序只操作Buffer中的数据,流也提供Read和Write API来供Buffer接收与发送流中的数据。
设计规范
凡是碰到处理较大数据块(BLOBs),或者碰到根本就无法预知数据总量到底有多少的情况,就应该考虑流的模式,而且在整个处理过程中的每个环节都应该是这种模式,比如读一个文件,存到数据库中,那整个过程都应该是流的模式,读一部分文件,将这部分写到数据库中,再从文件中读下一部分数据到Buffer,再将这部分写到数据库中,循环直到整个文件处理完。不能说,只是读文件是一块一块读,读出来都拼到一个大内存块中,再写到数据库中去,那还是没用,还是需要分配大块内存来容纳整个对象。
标准写法
以下是最熟的拷贝文件的C# DEMO,基本过程就是从源文件中不断的读一小块数据到Buffer中,再把Buffer中的数据写到目标地址,再读下一块,再写……
public static void CopyFile(string srcPath, string descPath)
{
//此去略去地址检查等代码
int bufferSize = 1024*1024;//1m的缓存大小
byte[] buffer = new byte[bufferSize];
int readCount = 0;//读到的数据
using (var inputStream = File.OpenRead(srcPath))
using (var outputStream = File.Create(descPath))
{
//bufferSize是一次读多少数据
//readCount是实际读到了多少数据
while ((readCount =
inputStream.Read(buffer, 0, bufferSize)) > 0)
{
//因为最后一次读,buffer里的数据是不满的
//所以只需写readCount个数据就可以了
outputStream.Write(buffer, 0, readCount);
}
}
}
通过使用流的方式,最算拷贝20G的文件,这段程序理论上也只需使用1M的内存。
ADO.NET操作二进制大数据
有些人不太敢用数据库存大文件,因为经验上觉得大数据在数据库中存取,很慢,而且易出错。这种情况有可能是因为使用的方式和API不太对造成的。
假设我们现在用一个表在SQLSvr中存资源文件,表定义如下:
CREATE TABLE [Files](
[ID] [int] IDENTITY(1,1) NOT NULL,
[Name] [varchar](50) NOT NULL,
[ContentType] [varchar](20) NULL,
[ContentLength] [int] NULL,
[Data] [image] NULL,
CONSTRAINT [PK_Files] PRIMARY KEY CLUSTERED ([ID] ASC) ON [PRIMARY]
)
其中,Data列是Image(SQLSvr2005+建议换成varbinary(max),一般需求下二种其实差不多)
往SQLSvr写入大数据
那在ASP.NET中实现的文件上传代码应该是这样的:
if (this.FileUpload1.HasFile)
{
string name = FileUpload1.PostedFile.FileName;
string contentType = FileUpload1.PostedFile.ContentType;
int contentLength = FileUpload1.PostedFile.ContentLength;
//注意先给Data列插入一个0(0x0)值
string cmdText = "insert into Files ( Name, ContentType, ContentLength, Data)"
+ "values (@name, @contentType, @contentLength, 0x0);"
+ "select @identity = SCOPE_IDENTITY();"
+ "select @pointer = TEXTPTR(Data) FROM Files WHERE ID = @identity";
SqlCommand cmd = new SqlCommand(cmdText);
cmd.Parameters.Add("@name", SqlDbType.VarChar, 50).Value = name;
cmd.Parameters.Add("@contentType", SqlDbType.VarChar, 20).Value = contentType;
cmd.Parameters.Add("@contentLength", SqlDbType.Int).Value = contentLength;
//当前插入数据行的ID
SqlParameter paraId = cmd.Parameters.Add("@identity", SqlDbType.Int);
paraId.Direction = ParameterDirection.Output;
//TEXTPTR函数(sqlsvr 2000+)返回16位二进制表示的单元格指针
SqlParameter outParaPtr = cmd.Parameters.Add("@pointer", SqlDbType.Binary,16);
outParaPtr.Direction = ParameterDirection.Output;
string connStr =
"Data Source=.;Initial Catalog=Resources;Integrated Security=True";
using (SqlConnection conn = new SqlConnection(connStr))
{
cmd.Connection = conn;
conn.Open();
cmd.ExecuteNonQuery();
int fileId = (int)paraId.Value;
//MSDN建议Buffer为8040的倍数
int bufferSize = 8040;
int offset = 0;
byte[] pointer = (byte[])outParaPtr.Value;
//通过UPDATETEXT命令获得单元格指针
SqlCommand cmdAppendFile = new SqlCommand(
"UPDATETEXT Files.Data @pointer @offset 0 @bytes", conn);
SqlParameter paraPtr = cmdAppendFile.Parameters.Add(
"@pointer", SqlDbType.Binary,16);
paraPtr.Value = pointer;
SqlParameter paraData = cmdAppendFile.Parameters.Add(
"@bytes", SqlDbType.VarBinary, bufferSize);
SqlParameter paraOffset = cmdAppendFile.Parameters.Add(
"@offset", SqlDbType.Int);
paraOffset.Value = offset;
byte[] buffer = new byte[bufferSize];
int readCount = 0;
//通过流的方式将上传内容逐块写到数据库
while ((readCount = FileUpload1.PostedFile.InputStream
.Read(buffer, 0, bufferSize)) > 0)
{
byte[] data = buffer;
if(readCount < bufferSize)
{
data = new byte[readCount];
Array.Copy(buffer,data,readCount);
}
//将新读入Buffer的数据作为参数传入数据库
paraData.Value = data;
cmdAppendFile.ExecuteNonQuery();
//更新指针的偏移量
offset += readCount;
paraOffset.Value = offset;
}
}
}
这里使用了UPDATETEXT命令,请参考http://msdn.microsoft.com/zh-cn/library/ms189466.aspx
从SQLSvr读取大数据
默认情况下,DataReader 在整个数据行可用时立即以行的形式加载传入数据。可以将 SequentialAccess 传递到 ExecuteReader 方法来修改 DataReader 的默认行为,使其按照顺序在接收到数据时立即将其加载,而不是加载数据行。 这是加载 BLOB 或其他大数据结构的理想方案。 请注意,该行为可能会因数据源的不同而不同。 例如,从 Microsoft Access 中返回 BLOB 会将整个 BLOB 加载到内存中,而不是按照顺序在接收到数据时立即将其加载。
以下示例使用ASP.NET的一般处理程序将写入数据库中的数据再读出来并发送到Browser,注意,请不要使用Page来做这样的事情,Page有大量的页面生命周期事件要处理,而且产生额外的页面内容输出到响应流中。
public class ResourceHandler : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
int id = int.Parse(context.Request.Params["id"]);
string connStr =
"Data Source=.;Initial Catalog=Resources;Integrated Security=True";
string cmdText =
"SELECT ID,Name,ContentType,ContentLength,Data FROM Files WHERE ID = @id";
SqlCommand cmd = new SqlCommand(cmdText);
cmd.Parameters.Add("@id", SqlDbType.Int).Value = id;
using (SqlConnection conn = new SqlConnection(connStr))
{
conn.Open();
cmd.Connection = conn;
//这里使用SeqentialAccess是关键,
//这样才能和DataReader.GetBytes配合实现流处理模式
SqlDataReader reader =
cmd.ExecuteReader(System.Data.CommandBehavior.SequentialAccess);
if (reader.Read())
{
//根据SeqentialAccess的要求,
//必须按取Select顺序先读掉第一列数据
reader.GetInt32(0);
string fileName = reader.GetString(1);
string contentType = reader.GetString(2);
int contentLength = reader.GetInt32(3);
//设置必要的Http-Response头
context.Response.ContentType = contentType;
context.Response.StatusCode = 200;
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int readCount = 0;
long offset = 0;
while ((readCount =
(int)reader.GetBytes(4, offset, buffer, 0, bufferSize)) > 0)
{
context.Response.OutputStream.Write(buffer, 0, readCount);
offset += readCount;
}
return;
}
}
//没读到数据返回404错误
context.Response.StatusCode = 404;
}
public bool IsReusable
{
get
{
return true;
}
}
}
第二种从SQLSvr2000+中读取大文件的方式
跟UPDATETEXT/WRITETEXT配对的命令其实是READTEXT,所以我们还可以用READTEXT命令,以指针的方式,从数据库中以流的方式读取大对象(这段代码是从网上拷下来的伪码,我补全了一下,并做测试):
int BUFFER_LENGTH = 32768; // chunk size
// Obtain a pointer to the BLOB using TEXTPTR.
SqlConnection conn = new SqlConnection(/*constr*/);
SqlCommand cmdGetPointer = new SqlCommand(
"SELECT @Pointer=TEXTPTR(Picture), @Length=DataLength(Picture)" +
"FROM Categories WHERE CategoryName='Test'", conn);
// Set up the parameters.
SqlParameter pointerOutParam =
cmdGetPointer.Parameters.Add("@Pointer", SqlDbType.VarBinary, 100);
pointerOutParam.Direction = ParameterDirection.Output;
SqlParameter lengthOutParam = cmdGetPointer.Parameters.Add(
"@Length", SqlDbType.Int);
lengthOutParam.Direction = ParameterDirection.Output;
cmdGetPointer.ExecuteNonQuery();
byte[] pointer = (byte[])pointerOutParam.Value;
int length = (int)lengthOutParam.Value;
// Run the query.
// Set up the READTEXT command to read the BLOB by passing the following
// parameters: @Pointer – pointer to blob, @Offset – number of bytes to
// skip before starting the read, @Size – number of bytes to read.
SqlCommand cmdReadBinary = new SqlCommand(
"READTEXT Categories.Picture @Pointer @Offset @Size HOLDLOCK", conn);
// Set up the parameters for the command.
SqlParameter paraPtr = cmdReadBinary.Parameters.Add("@Pointer", SqlDbType.Binary, 16);
paraPtr.Value = pointer;
SqlParameter paraOffset =
cmdReadBinary.Parameters.Add("@Offset", SqlDbType.Binary, 16);
int offset = 0;
paraOffset.Value = offset;
SqlParameter SizeParam = cmdReadBinary.Parameters.Add("@Size", SqlDbType.Int);
SizeParam.Value = BUFFER_LENGTH;
SqlDataReader dr;
Byte[] Buffer = new Byte[BUFFER_LENGTH];
// Read buffer full of data.
do
{
// Add code for calculating the buffer size - may be less than
// BUFFER LENGTH for the last block.
dr = cmdReadBinary.ExecuteReader(CommandBehavior.SingleResult);
dr.Read();
int PictureCol = 0;
int readCount = (int)dr.GetBytes(PictureCol, 0, Buffer, 0, BUFFER_LENGTH);
offset += readCount;
paraOffset.Value = offset;
} while (offset < length);