2008年10月22日星期三

Java Web 开发中的分页

本文先大致地介绍一下各种分页的方式,以及他们的优缺点。最后使用 JSP + Servlet + JDBC + Oracle/MySQL 的模式实现了一个分页查询的页面。

在 Web 开发中查询是个很常见的操作,但是查询所返回的结果集有时可能非常大,比如:Google 的搜索结果,有成千上万,数十万,数百万的搜索结果。对于这些庞大的结果集来说,不可能也不现实在一页中显示到客户端的浏览器上。这时我们就得采取分页措施,比如说每页显示 20 条记录,再用一些导向标记转向下一页或者让用户输入指定的页数,这样一来,数据传输量大大地减小了,速度也加快了,客户端能获得更好的体验。

分页方案

下面来看一下,在开发中常用的分页方法和分页技术有哪些?并且具有什么优缺点。在 POJOs in Action 一书中对此作了比较深入的分析(详见 Chris Richardson, POJOs in Action, Manning, 2006, Chapter 11: Implementing Dynamic Paged Queries),这里把该章中表 11.1 的分页方案引用一下,看看具体的分页方案有哪些。

分页方案 优  点 缺  点 适用场合
使用一条查询语句获取所有的数据 读取的一致性
只需要一条代价昂贵的查询语句
存储空间耗费
执行时间耗费
结果集小
查询主键 只需要一条代价昂贵的查询语句和多条代价小的查询语句 读取的不致性
存储主键空间上的开销
查询时间耗费
适量的结果集
JDBCResultSet的延时遍历 只需要执行一条查询语句
避免一次装入所有的数据
严重地限制了应用的扩展性
长事务将会锁住很多的数据
串行级别的长事务可能会失败
少量的用户
重新查询数据库 无状态
仅查出所需要显示的数据
重复查询
读取的不致性
结果集大
用户数多

来简单地看一下这四种分页方式的实现原理是什么,以及在什么情况下运用,详见的介绍参见 POJOs in Action 一书。下面这些介绍均节选自该书。

使用一条查询语句获取所有的数据

该方法是针对小数据量而首选的方法,该方法执行一条查询语句将所有的结果集存放在服务器或者浏览器的 Session 状态中。

这个方法只有在结果集很小的情况下才能使用。如果返回的结果集很大,那么执行这样的查询代价就非常之高了。把大量的结果放在服务器内存中,或者传到客户端的浏览器中,这种做法不仅没效率,而且也是不现实的。结果集会占用大量的内存和长时间地用于网络传输,因此这个方法很少用到。

查询主键

这是将前一种方法改动后所衍生出来的一种方式。应用首次执行一条 SQL 语句选出所有满足查询条件的数据记录的主键,存放到 Session 状态中。之后应用就可以通过这些主键,再发出一条查询语句以取出每一页的数据。

这个方法相对于上一种方法而言所占用的内存要少了很多。但是如果所得到的结果是非常大的话,即使只存放一些主键也会导致 Session 状态变得非常大,需要占用服务器大量的内存,并且很长时间才能发送到客户端中。而且这种方式相对于前者来说,这种方式在翻页的时候数据库中的记录更新过了,就会造成读取不一致。这种方式只适合于中等大小的结果集分页。

JDBCResultSet的延时遍历

这种方式与前两种一次装入所有数据的方法相反,这是采用应用保持与数据库的连接,当用户进行翻页的时候,遍历相应的 JDBC 结果集。但是,这种做法会导致 J2EE 应用程序数据库连接这个宝贵的资源,因此这种方案不能支持大量的用户访问。而且,如果长时间进行查询的话可能会锁住大量的记录。由于存在这些限制,这种方法几乎是不现实的。

重新查询数据库

从前面三种方案中可以看出,在大部分的应用中结果集都很大,无法全部放到内存中去。而且,在用户请求之间保持数据库的连接不放也是不现实的。因此,得选择一个更具扩展性的方法,就是在用户进行翻页时,应用程序重新查询数据库。一般有几种不同的做法:

  • 一次查询返回一页的数据。每次用户点“前一页”或者“后一页”按钮时,应用程序就到数据库中查出所需要页数的数据。
  • 一次查询多页的数据,保存在应用程序的 Session 中,这种做法可以减少数据库访问的次数,也可以减少响应的时间。

这种方法有诸多的优点:

  • 在处理 HTTP 请求时才进行数据库连接,无需要一直持有这个连接,所以具有良好的扩展性。
  • 需要显示多少数据,就加载多少数据,只需要加载一部分的数据,而无须加载整个结果集,这样占用的内存小,而且也加快了网络的传输速度。
  • 每一个查询只获取一页的数据,因此也减少了数据库的负担。
  • 对于大量的结果集来说,用户也许只需要看一两页的数据,相对于前面的几种方案来说,这种方案最为经济。

当然了,除了这些优点之外,它还存在着一些缺点,来看一下这种方式的缺点:

  • 在用户来回翻页的时候,可能会多次执行同样的查询语句,这会在一定程度上影响效率。
  • 用户看到的数据与实际的数据可能不一致,因为在翻页查询的时候,数据库可能被改变。

虽然有上面的一些缺点,但是相对于其优点来说已经是很不错的分页方案了,这个方案仍然是很多应用程序的最佳选择。

下面就将以这种分页方法通过一个 Java Web 应用程序的分页实例,来说明一下这种方案在具体应用上应该注意哪一些问题。这个分页采用最为基本的 JSP + Servlet + JDBC + MySQL/Oracle 来实现。

分页实例及注意事项

为了能更为方便地操作分页查询出来的数据,设计了一个用于封装分页类Pagination这个类用于封装一些分页数据和参数。分页数据就是当前页返回的结果集,分页参数由以下几个分页元数据构成:一页所显示数据的条目数量、当前页数、总的结果集数量(用于计算一共有多少页)、是否存在下一页。对于后两个参数来说适用于不同的场合,前者适用于需要知晓总页数的分页需求,后者适用于不需要知晓总页数的分页需求,只需要知道是否还存在下一页。这个类的代码如下:

import java.util.List;
import com.bao.config.Config;

public class Pagination {
   
    /**
     * 默认的当前页
     */
    private final static int DEFAULT_CURRENT_PAGE = 1;
    
    /**
     * 当前页
     */
    private int current;
    
    /**
     * 未分页时总的数据量
     */
    private int count;
    
    /**
     * 每页大小
     */
    private int pageSize;
    
    /**
     * 当前页的数据集
     */
    private List data;
    
    /**
     * 是否还存在下一页
     */
    private boolean hasNextPage;
    
    public Pagination(int current) {
        this.pageSize = Config.getConfig().getPageSize();
        this.current = current > 0 ? current : DEFAULT_CURRENT_PAGE;
    }
    
    /**
     * 根据总的数据量和每页大小计算总页数
     * @return      总页数
     */
    public int getTotalPage() {
        return (count + pageSize - 1) / pageSize;
    }

    /**
     * 获得当前页的数据数量
     * @return
     */
    public int getCurrentSize() {
        if(data == null) {
            return 0;
        }
        return data.size();
    }

    public int getCount() {
        return count;
    }

    public void setCount(int count) {        
        this.count = count;
    }

    public int getCurrent() {
        return current;
    }

    public List getData() {
        return data;
    }
    
    /**
     * 设置数据集
     * @param data
     */
    public void setData(List data) {
        // 判断是否还有下一页
        if(data.size() > pageSize) {
            // 存在下一页
            setHasNextPage(true);
            // 移除多余的最后一条记录
            data.remove(pageSize);
        } else {
            // 不存在下一页
            setHasNextPage(false);
        }
        this.data = data;
    }
    
    public boolean isHasNextPage() {
        return hasNextPage;
    }
    public int getPageSize() {
        return pageSize;
    }
    
    /**
     * 在查询时多查一条记录,用于判断是否还存在下一页
     * @return
     */
    public int getQueryPageSize() {
        return pageSize + 1;
    }

    private void setHasNextPage(boolean hasNextPage) {
        this.hasNextPage = hasNextPage;
    }
}

(未完待续)