海量数据分页优化建议与通用分页控件的使用

 

 

 

一、        问题现象

1.        数据源使用不当带来的性能问题

在对各种数据源缺乏了解的情况下出现了对某种数据源的滥用带来了极大的性能问题。如在很多项目在看到这样一种情况,给DataGrid指定了一个包括所有符合条件的记录的DataSet作为数据源,而在每一次翻页之后却再次从数据库读取所有符合条件的记录进行再次绑定,期间无论从对数据库的访问次数还在服务器资源的占用都造成了很大的浪费。

2.        优秀分页算法无法得到重用

有些优秀的分页算法在性能方面极佳,但得不到普遍使用;而有些算法只是实现了分页功能,在性能上极其低下,无法适应应用程序到后大批量数据的分页。

3.        重复开发

在几乎所有的项目中都会涉及到分页,每个项目都在分页这块化了大量精力,而每个项目在分页方面的各自为政造成了在样式上的不统一及性能各异、维护代价高等问题。

二、        问题分析与解决方案

1.        .NET中两种数据访问模式的认识

                         i.              DataSet是“断开”模式的一种应用,为的是减少与数据库的交互次数来提高性能,所以给DataGrid等控件指定的数据源为DataSet(一次性读取所有符合条件的记录)时,应该只有一次,在翻页过程中都应重复利用先前读取的DataSet(适合记录量比较少的数据库)

                       ii.              在实际的应用中发现,使用DataSet(一次性读取所有符合条件的记录)在内存占用方面消耗很大,特别是达到十万记录以上时,服务基本上已经无法响应。所以更好的解决方案应该是每翻一页都从数据库取出所需的最小记录数,而当这样应用时,我们为DataGrid等控件指定数据源时应该使用SqlDataReader,因为它在数据读取与内存占用方面都优于DataSet

2.        Sql语句与表的优化

                        i.              Sql语句的优化

Sql语句方面,网上有很多解决方案,基本上大同小异。一般都是用Top先取出前1000条,然后用不同的方案把前990条舍去,有用ID进行排除的,有用临时表进行排除的等。

在众多方法,以用ID排除法性能最高,但问题时在实际应用中很少会以ID进行排序。

 

                      ii.              表的优化

在海量数据中,索引对性能的影响是巨大的,应该对常用的Sql语句进行分析,应对表建立相应的索引。

创建索引的几个建议:

1)、索引要能够大大减少语句的logical reads,这是看一个索引好不好的关键。

2)、设法避免Table scan Index scan

3)、根据语句的执行计划来判断应该对什么表创建什么索引。

4)、为了利用索引,不要对where语句中的字段直接使用各种函数或表达式,要尽量把函数或表达式放在操作符的右边。要尽量不使用前置百分号。

5)、在使用OR时两边的字段都要有必要的索引

6)、什么时候应该使用聚集索引,什么时候使用非聚集索引取决于应用程序的访问方式。(建议在关键的字段上使用聚集索引)

 

                    iii.              我的Sql解决方案

因为Top在各Sql指令中性能是最好的,所以我用Top设计了这样一种算法:

第一步:根据用户指定的排序字段,再加上ID排序,用Top取出前1000条。

第二步:用与第一步完全相反的排序方式,用Top取出前10条。

第三步:对第二步的结果根据用户指定的方式进行排序。

这种简单的算法经测试,虽然没有ID排除性能高,但优于其它排除法,在十万条记录中取任一页都在2秒左右,在五十万条记录中取任一页,一般不会超过56秒左右。超过50万的记录并没有测试,但从前面测试过程中的变化来看,就算达到百万级的记录,速度也不会有太大的降低,当然这仍然依赖于服务器性能也使用者机器的性能。

 

                     iv.              一个性能比较好的分页存储过程

这是一个比较通用的存储过程,使用临时表策略。个人比较推荐我的算法,不过Sql语句或存储过程根据相应情况要用户自己写了!

CREATE procedure GetNews

      (@pagesize int,

              @pageindex int,

              @docount bit)

              as

              set nocount on

              if(@docount=1)

              select count(id) from news

              else

              begin

              declare @indextable table(id int identity(1,1),nid int)

              declare @PageLowerBound int

              declare @PageUpperBound int

              set @PageLowerBound=(@pageindex-1)*@pagesize

              set @PageUpperBound=@PageLowerBound+@pagesize

              set rowcount @PageUpperBound

              insert into @indextable(nid) select id from news order by addtime desc

              select O.id,O.source,O.title,O.addtime from news O,@indextable t where O.id=t.nid

              and t.id>@PageLowerBound and t.id<=@PageUpperBound order by t.id

              end

              set nocount off

       GO

 

3.        通用控件的使用说明   

                        i.              控件说明

Pager控件为.ascx格式用户自定义控件(未封装成dll文件),针对DataGrid控件进行分页(暂未考虑DataListRepater等,如果有需要,可在现有的基础上进行扩展),控件中的所有子控件(上一页下一页按钮、选页下拉列表框等)按照公司要求的顺序排列,未设任何样式,在使用时可使用项目中的统一样式,以保持风格一致。

                      ii.              控件的使用

1.         把控件文件包含至项目当中,根据需要,可对命名空间等进行修改。

2.         把控件拖入使用的页面,此页面包含需要分页的DataGrid,并且禁用DataGrid控件自带的分页功能。如图:

3.         页面的后台CS代码往往不会自动添加对自定义控件的引用,自行添加控件成员,成员变量名与前台页面上拖入的控件名一致,使得后台代码对控件进行控制。如:

protected Pager PagerTest;//此名称与前台控件名保持一致。

4.         控件提供了一个读取数据源的委托,由用户自行编写数据源读取方法。此委托使用了两个int型参数,一个是当前页码(指要显示的页面),另一个是每页显示的记录数。

委托:

/// <summary>

/// 获取数据源方法的委托,方法由用户定义,方法签名与此委托一致,通过此委托使控件知道如何读取数据。

/// </summary>

public delegate object GetDataSourceMethod(int currentPageNum,int pageSize);

相应方法示例:

/// <summary>

/// 获取数据源方法,不管方法的两个参数有没有被使用,都得写上,保持与委托的一致性。

/// </summary>

/// <param name="pageNum">要显示的页码</param>

/// <param name="pageSize">每页显示的记录数</param>

/// <returns></returns>

private object GetDataSet(int pageNum,int pageSize)

{

      DataSet ds = null;

      try

      {

           ds = new CECompareLogic().SelectAll();

          

           ds.Tables[0].Columns.Add("No");

           for(int i=0;i<ds.Tables[0].Rows.Count;i++)

           {

               ds.Tables[0].Rows[i]["No"]=(i+1).ToString();

           }

      }

      catch(SqlException ex)

      {

           this.lblMess.Text=ex.Message;

      }

      return ds;

         }

5.        在页面的Page_Load方法中写入初始代码。注意:一定要保证前两行代码在每次页面刷新时都被执行到,即直接放在Page_Load方法体内,如果放在if(!this.IsPostBack)的判断下执行将导致刷新页面发生Null异常,因为为了提高性能,这两个控件属性没有保存,所以每次刷新后都必须重新赋值。

private void Page_Load(object sender, System.EventArgs e)

{

     //与分页控件相对应的DataGrid

     this.PagerTest.CurrentDataGrid = this.DataGrid1;

     //设置获取数据源的方法,new的是控件中定义好的委托,参数是用户自定义读取数据的方法名

     this.PagerTest.DataSourceMethod = new GetDataSourceMethod(GetDataSet);

     if(!this.IsPostBack)

     {

         //设置数据记录总数,如果是一次性读取所有记录的方式,则无需设置

         this.PagerTest.ItemCount = 10;

         //设置是否一次性读取所有记录,影响控件读取数据的方式。

         //true:为一次读取

         //false:为每页分别读取所需记录

         this.PagerTest.IsAllItem = false;

     }

}

6.         当在某些情况下(比如页面的查询按钮),需要控件重新处理,则只需调用控件的SetControlForNewSeach方法即可。

例:

private void Button1_Click(object sender, System.EventArgs e)

{

      this.PagerControl.ItemCount = this.GetAllItemCount();

      this.PagerControl.SetControlForNewSeach();

}

7.         总的来说:用户定义好读取数据的方法给控件,及通知控件对应哪个DataGrid,必要时设置好需要显示的记录总行数就行了,其余控件都会自行处理,使用起来还是相当简单方便的。

                    iii.              读取数据源的三种方式

1.         第一种方式也是最常用的方式,就是读取所有要显示的记录(DataSet)作为数据源给DataGrid。此种方式的优点是使用简单,但也有两个缺点,一是很多刚写程序的程序员经常在每次翻页后都重新到数据库进行读取操作,造成大量资源浪费,其实只需读取一次后保存下来就可以了;二是把所有要显示的数据保存下来比较占用资源,所以只适合小量数据使用。在此控件中使用此种方式的情况下只要把控件的IsAllItem属性设为true,控件会自动避免第一种情况,第二种情况则由用户自行判断,建议500条以上绝不要使用此种方式。

2.         第二种方式即每次读取相应记录放在DataReader中作为数据源,抛开数据读取算法不讲,DataReaderDataSet占的资源少,并且这种方式无需保存下来,所以只要数据读取算法合理,这种方式的性能是最高的。但缺点是DataReader内的数据无法修改,缺少了灵活性,比如要为所有记录加个序号,DataReader便做不到,所以只适用于读出数据直接显示的需求。注:使用ExecuteReader方法时一定要以System.Data.CommandBehavior.CloseConnection做参数(作用是指在DataReader对象被关闭时连接同时也被关闭),并在方法体内不要关闭连接,否则会出现“阅读器关闭时 FieldCount 的尝试无效”异常。

例:

/// <summary>

/// 读取数据源为DataReader的方法

/// </summary>

/// <param name="pageNum">要显示的页码</param>

/// <param name="pageSize">每页显示的记录数</param>

/// <returns></returns>

private object GetDataReader(int pageNum,int pageSize)

{

      IDataReader dr=null;

      try

      {

           SqlConnection conn = new SqlConnection(ConfigurationSettings.AppSettings["DbConnectString"]);

           SqlCommand cmd = new SqlCommand("select * from (select top " + pageSize.ToString() + " * from (select top "+(pageNum * pageSize).ToString()+" * from CECompare where 1=1 order by ChineseName asc) as temp1 order by ChineseName desc ) as temp2 order by ChineseName asc",conn);

           conn.Open();

           //一定要加上System.Data.CommandBehavior.CloseConnection作参数,并不要在此关闭连接,否则将会出现阅读器关闭时 FieldCount 的尝试无效异常,

         dr = cmd.ExecuteReader(System.Data.CommandBehavior.CloseConnection);

         //此处没有关闭数据库连接

      }

      catch (SqlException ex)

      {

           Response.Write(ex.Message);

      }

      return dr;

         }

3.         第三种方式虽然也用DataSet作数据源,但跟第二种方式一样都是每次读取需要的记录,且无需把结果保存下来,又可以灵活修改数据,所以在性能与易操作性方面都不错,应该是实际应用中最常用的一种操作,至于DataSet哪来,那就根据用户自己的定义了。

 

在此,只是和同行共同探讨,抛砖引玉....

 

  

posted on 2008-08-21 10:40  http  阅读(534)  评论(1)    收藏  举报

导航