建立一个 Web 应用,分页浏览功能必不可少。这个问题是数据库处理中十分常见的问题。经典的数据分页方法是:ADO 纪录集分页法,也就是利用ADO自带的分页功能(利用游标)来实现分页。但这种分页方法仅适用于较小数据量的情形,因为游标本身有缺点:游标是存放在内存中,很费内存。游标一建立,就将相关的记录锁住,直到取消游标。游标提供了对特定集合中逐行扫描的手段,一般使用游标来逐行遍历数据,根据取出数据条件的不同进行不同的操作。而对于多表和大表中定义的游标(大的数据集合)循环很容易使程序进入一个漫长的等待甚至死机。
更重要的是,对于非常大的数据模型而言,分页检索时,如果按照传统的每次都加载整个数据源的方法是非常浪费资源的。现在流行的分页方法一般是检索页面大小的块区的数据,而非检索所有的数据,然后单步执行当前行。
最早较好地实现这种根据页面大小和页码来提取数据的方法大概就是“俄罗斯存储过程”。这个存储过程用了游标,由于游标的局限性,所以这个方法并没有得到大家的普遍认可。
后来,网上有人改造了此存储过程,下面的存储过程就是结合我们的办公自动化实例写的分页存储过程:
/* ***********************************************************
* Time: 2011-10-05 20:32:46
*********************************************************** */
CREATE PROCEDURE page_t(
@pagesize INT , -- 页面大小,如每页存储20条记录
@pageindex INT -- 当前页码
)
AS
SET NOCOUNT ON
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 tt
-- where id >dateadd(day,-365,getdate()) order by id desc
SELECT O.id
FROM tt O,
@indextable t
WHERE O.id = t.nid
AND t.id > @PageLowerBound
AND t.id <= @PageUpperBound
ORDER BY
t.id
END
SET NOCOUNT OFF
-- --exec page_t 100,2
从tt表中取出第 n 条到第 m 条的记录:
DECLARE @m INT
SET @m = 10000
set @n = 2
SELECT TOP ( @m - @n + 1 ) * FROM tt
WHERE (id NOT IN ( SELECT TOP ( @n - 1 ) id FROM tt))
id 为tt表的关键字
我当时看到这篇文章的时候,真的是精神为之一振,觉得思路非常得好。这个存储过程也是目前较为流行的一种分页存储过程
/* ***********************************************************
* Time: 2011-10-05 21:12:08
*********************************************************** */
CREATE PROC pagination2(
@SQL NVARCHAR ( 4000 ), -- 不带排序语句的SQL语句
@Page INT , -- 页码
@RecsPerPage INT , -- 每页容纳的记录数
@ID VARCHAR ( 255 ), -- 需要排序的不重复的ID号
@Sort VARCHAR ( 255 ) -- 排序字段及规则
)
AS
DECLARE @Str NVARCHAR ( 4000 )
SET @Str = ' SELECT TOP ' + CAST ( @RecsPerPage AS VARCHAR ( 20 )) + ' * FROM
( ' + @SQL + ' ) T WHERE T. ' + @ID + ' NOT IN (SELECT TOP ' + CAST (( @RecsPerPage * ( @Page - 1 )) AS VARCHAR ( 20 ))
+ ' ' + @ID + ' FROM ( ' + @SQL + ' ) T9 ORDER BY ' + @Sort + ' ) ORDER BY ' +
@Sort
PRINT @Str
EXEC sp_ExecuteSql @Str
GO
-- ---pagination2 'SELECT id FROM tt',2,100,'id','id'
其实,以上语句可以简化为:
SELECT TOP 100 /* 页大小 */ *
ORDER BY ID
FROM TT a
WHERE NOT EXISTS
(
SELECT *
FROM (
SELECT TOP ( 100 /* 页大小 */ * 0 /* 页数 */ ) *
FROM TT
ORDER BY
id
) b
WHERE b.id = a.id
)
ORDER BY id
即,用not exists来代替not in,但我们前面已经谈过了,二者的执行效率实际上是没有区别的。既便如此,用TOP 结合NOT IN的这个方法还是比用游标要来得快一些。
虽然用not exists并不能挽救上个存储过程的效率,但使用SQL SERVER中的TOP关键字却是一个非常明智的选择。因为分页优化的最终目的就是避免产生过大的记录集,而我们在前面也已经提到了TOP的优势,通过TOP 即可实现对数据量的控制。
在分页算法中,影响我们查询速度的关键因素有两点:TOP和NOT IN。TOP可以提高我们的查询速度,而NOT IN会减慢我们的查询速度,所以要提高我们整个分页算法的速度,就要彻底改造NOT IN,同其他方法来替代它。
我们知道,几乎任何字段,我们都可以通过max(字段)或min(字段)来提取某个字段中的最大或最小值,所以如果这个字段不重复,那么就可以利用这些不重复的字段的max或min作为分水岭,使其成为分页算法中分开每页的参照物。在这里,我们可以用操作符“>”或“<”号来完成这个使命,使查询语句符合SARG形式。如:
FROM tt
WHERE id > 200
于是就有了如下分页方案:
select top 页大小 *
from tt
where id >
( select max (id) from
( select top ((页码 - 1 ) * 页大小) id from tt order by id) as T
)
order by id
在选择即不重复值,又容易分辨大小的列时,我们通常会选择主键。下表列出了笔者用有着1000万数据的办公自动化系统中的表,在以id(id是主键,但并不是聚集索引。)为排序列、提取id字段,分别以第1、10、100、500、1000、1万、10万、25万、50万页为例,测试以上三种分页方案的执行速度:(单位:毫秒)
页码 | 方案1 | 方案2 | 方案3 |
1 | 60 | 30 | 76 |
10 | 46 | 16 | 63 |
100 | 1076 | 720 | 130 |
500 | 540 | 12943 | 83 |
1000 | 17110 | 470 | 250 |
10000 | 24796 | 4500 | 140 |
100000 | 38326 | 42283 | 1553 |
250000 | 28140 | 128720 | 2330 |
500000 | 121686 | 127846 | 7168 |
从上表中,我们可以看出,三种存储过程在执行100页以下的分页命令时,都是可以信任的,速度都很好。但第一种方案在执行分页1000页以上后,速度就降了下来。第二种方案大约是在执行分页1万页以上后速度开始降了下来。而第三种方案却始终没有大的降势,后劲仍然很足。
在确定了第三种分页方案后,我们可以据此写一个存储过程。大家知道SQL SERVER的存储过程是事先编译好的SQL语句,它的执行效率要比通过WEB页面传来的SQL语句的执行效率要高。下面的存储过程不仅含有分页方案,还会根据页面传来的参数来确定是否进行数据总数统计。
获取指定页的数据:
/* ***********************************************************
* Time: 2011-10-05 21:40:08
*********************************************************** */
CREATE PROCEDURE PAGE01
@tblName VARCHAR ( 255 ), -- 表名
@strGetFields VARCHAR ( 1000 ) = ' * ' , -- 需要返回的列
@fldName VARCHAR ( 255 ) = '' , -- 排序的字段名
@PageSize INT = 10 , -- 页尺寸
@PageIndex INT = 1 , -- 页码
@doCount BIT = 0 , -- 返回记录总数, 非 0 值则返回
@OrderType BIT = 0 , -- 设置排序类型, 非 0 值则降序
@strWhere VARCHAR ( 1500 ) = '''' -- 查询条件 (注意: 不要加 where)
AS
DECLARE @strSQL VARCHAR ( 5000 ) -- 主语句
DECLARE @strTmp VARCHAR ( 110 ) -- 临时变量
DECLARE @strOrder VARCHAR ( 400 ) -- 排序类型
IF @doCount != 0
BEGIN
IF @strWhere != ''''
SET @strSQL = ' select count(*) as Total from ' + ' ' + @tblName +
' where ' + @strWhere
ELSE
SET @strSQL = ' select count(*) as Total from ' + @tblName
END -- 以上代码的意思是如果@doCount传递过来的不是0,就执行总数统计。以下的所有代码都是@doCount为0的情况:
ELSE
BEGIN
IF @OrderType != 0
BEGIN
SET @strTmp = ' <(select min '
SET @strOrder = ' order by ' + @fldName + ' desc '
-- 如果@OrderType不是0,就执行降序,这句很重要!
END
ELSE
BEGIN
SET @strTmp = ' >(select max '
SET @strOrder = ' order by ' + @fldName + ' asc '
END
IF @PageIndex = 1
BEGIN
IF @strWhere != ''''
SET @strSQL = ' select top ' + STR ( @PageSize ) + ' ' + @strGetFields
+ '
from ' + @tblName + ' where ' + @strWhere + ' ' + @strOrder
ELSE
SET @strSQL = ' select top ' + STR ( @PageSize ) + ' ' + @strGetFields
+ '
from ' + @tblName + ' ' + @strOrder
-- 如果是第一页就执行以上代码,这样会加快执行速度
END
ELSE
BEGIN
-- 以下代码赋予了@strSQL以真正执行的SQL代码
SET @strSQL = ' select top ' + STR ( @PageSize ) + ' ' + @strGetFields +
' from '
+ @tblName + ' where ' + @fldName + '' + @strTmp + ' ( ' + @fldName
+ ' )
from (select top ' + STR (( @PageIndex - 1 ) * @PageSize ) + ' ' + @fldName
+ '
from ' + @tblName + '' + @strOrder + ' ) as tblTmp) ' + @strOrder
IF @strWhere != ''''
SET @strSQL = ' select top ' + STR ( @PageSize ) + ' ' + @strGetFields
+ ' from [ '
+ @tblName + ' ] where [ ' + @fldName + ' ] ' + @strTmp + ' ([ '
+ @fldName + ' ]) from (select top ' + STR (( @PageIndex - 1 ) * @PageSize )
+ ' [ '
+ @fldName + ' ] from [ ' + @tblName + ' ] where ' + @strWhere
+ ' '
+ @strOrder + ' ) as tblTmp) and ' + @strWhere + ' ' + @strOrder
END
END
EXEC ( @strSQL )
GO
-- -----PAGE01 'tt','*','id'
* Code formatted by SoftTree SQL Assistant ?v5.0.97
* Time: 2011-10-05 21:35:12
*********************************************************** */
CREATE PROCEDURE PAGE1_tt
@tblName VARCHAR ( 255 ), -- 表名
@strGetFields VARCHAR ( 1000 ) = ' * ' , -- 需要返回的列
@fldName VARCHAR ( 255 ) = '' , -- 排序的字段名
@PageSize INT = 10 , -- 页尺寸
@PageIndex INT = 1 , -- 页码
@doCount BIT = 0 , -- 返回记录总数, 非 0 值则返回
@OrderType BIT = 0 , -- 设置排序类型, 非 0 值则降序
@strWhere VARCHAR ( 1500 ) = '''' -- 查询条件 (注意: 不要加 where)
AS
DECLARE @strSQL VARCHAR ( 5000 ) -- 主语句
DECLARE @strTmp VARCHAR ( 110 ) -- 临时变量
DECLARE @strOrder VARCHAR ( 400 ) -- 排序类型
IF @doCount != 0
BEGIN
IF @strWhere != ''''
SET @strSQL = ' select count(*) as Total from ' + ' ' + @tblName +
' where ' + @strWhere
ELSE
SET @strSQL = ' select count(*) as Total from ' + @tblName
END -- 以上代码的意思是如果@doCount传递过来的不是0,就执行总数统计。以下的所有代码都是@doCount为0的情况:
ELSE
BEGIN
IF @OrderType != 0
BEGIN
SET @strTmp = ' <(select min '
SET @strOrder = ' order by ' + @fldName + ' desc '
-- 如果@OrderType不是0,就执行降序,这句很重要!
END
ELSE
BEGIN
SET @strTmp = ' >(select max '
SET @strOrder = ' order by ' + @fldName + ' asc '
END
IF @PageIndex = 1
BEGIN
IF @strWhere != ''''
SET @strSQL = ' select top ' + STR ( @PageSize ) + ' ' + @strGetFields
+ '
from ' + @tblName + ' where ' + @strWhere + ' ' + @strOrder
ELSE
SET @strSQL = ' select top ' + STR ( @PageSize ) + ' ' + @strGetFields
+ '
from ' + @tblName + ' ' + @strOrder
-- 如果是第一页就执行以上代码,这样会加快执行速度
END
ELSE
BEGIN
-- 以下代码赋予了@strSQL以真正执行的SQL代码
SET @strSQL = ' select top ' + STR ( @PageSize ) + ' ' + @strGetFields +
' from '
+ @tblName + ' where ' + @fldName + '' + @strTmp + ' ( ' + @fldName
+ ' )
from (select top ' + STR (( @PageIndex - 1 ) * @PageSize ) + ' ' + @fldName
+ '
from ' + @tblName + '' + @strOrder + ' ) as tblTmp) ' + @strOrder
IF @strWhere != ''''
SET @strSQL = ' select top ' + STR ( @PageSize ) + ' ' + @strGetFields
+ ' from [ '
+ @tblName + ' ] where [ ' + @fldName + ' ] ' + @strTmp + ' ([ '
+ @fldName + ' ]) from (select top ' + STR (( @PageIndex - 1 ) * @PageSize )
+ ' [ '
+ @fldName + ' ] from [ ' + @tblName + ' ] where ' + @strWhere
+ ' '
+ @strOrder + ' ) as tblTmp) and ' + @strWhere + ' ' + @strOrder
END
END
EXEC ( @strSQL )
GO
-- PAGE1_tt 'tt','*','id',100,1,0
上面的这个存储过程是一个通用的存储过程,其注释已写在其中了。 在大数据量的情况下,特别是在查询最后几页的时候,查询时间一般不会超过9秒;而用其他存储过程,在实践中就会导致超时,所以这个存储过程非常适用于大容量数据库的查询。 笔者希望能够通过对以上存储过程的解析,能给大家带来一定的启示,并给工作带来一定的效率提升,同时希望同行提出更优秀的实时数据分页算法。
以上的这第三种存储过程在小数据量的情况下,有如下现象:
1、分页速度一般维持在1秒和3秒之间。
2、在查询最后一页时,速度一般为5秒至8秒,哪怕分页总数只有3页或30万页。