构建更有效的WEB应用 用Train架构动态批量处理用户请求来改善服务器性能 作者:Edward Salatovka 译者:xMatrix
版权声明:任何获得Matrix授权的网站,转载时请务必以超链接形式标明文章原始出处和作者信息及本声明 作者:Edward Salatovka;xMatrix 原文地址:http://www.javaworld.com/javaworld/jw-04-2005/jw-0418-train.html 中文地址:http://www.matrix.org.cn/resource/article/43/43933_Efficient_Web_application.html 关键词: efficient Web application
概要 在这篇文章中,作者Edward Salatovka介绍了一种他称为Train的设计架构。Train允许简化组合多用户请求为一次数据库或网络查询,因此改善应用性能和减少硬件需求。这种方式的好处已经通过类似实际应用的压力测试来验证。
想像一下铁路站点以这样一种方式操作,每一个买了票的乘客立刻得到一个专属于他的列车!这种操作方式在实际生活中是可笑的,但在WEB应用服务器和数据应该访问应用却是很常见的。常规的方式意味着每一个用户请求会得到一个自己的线程和数据库连接。每一个用户请求需要一个即时的数据库或网络资源交互。显然这儿应该使有一种聪明地处理外部通讯的方式而不是增加更多的硬件。让我们寻找一种简单但还未注意到的方式来增加应用的效率。
在这篇文章中,我们采用一种新的方法即每一次与数据库或网络资源交互不是基于每一个请求而多个请求,这样高并发性的负效应如超时和死锁就会大大减少,而且大访问量下的性能衰退也可以忽略不计了。
为了使用这种方式-我们必须创建一个相应的运行应用和执行理论测试的环境。
创建一个黑盒 为了显示这种架构的优越性,我们会构建两个简单的功能上相同的servlet。两者都下发相同的包含从示例数据库获取数据的HTML页面。每一个servlet代表一种不同的实现—常规的和新式的。为了构建我们的servlet,我们需要一个WEB应用服务器,一个数据库和一个压力测试器。你可以自己选择相应的软件。这儿我使用的是Tomcat 4, JMeter和DB2。Tomcat 4和JMeter是免费的开源应用。选择DB2是为了尽可能模拟商业WEB环境。
创建数据库并添加随机内容 假设数据库名为“trdata”,我们来创建必要的方案:
//schema.sql connect to trdata; create table trentry (ID integer not null , NAME char(25), DESCR varchar(128), views integer with default 0, constraint p_trentry primary key (ID));
简单的JAVA应用PropSamples生成包含250000行随机数据的表trentry。
//PropSamples.java package train; import java.util.*; import java.sql.*; public class PropSamples {
public static String GetString(int size, Random rand) { StringBuffer strBuff = new StringBuffer(); for (int i = 0; i < size; i++) { char b = (char) (rand.nextInt(25) + 65); strBuff.append(b); } return strBuff.toString(); }
public void Process() throws SQLException { String sqlString = "insert into trentry values(?,?,?,?)"; Connection connection = Util.getDBConnection(); PreparedStatement stmt = connection.prepareStatement(sqlString); Random rand = new Random(); for (int i = 1; i <= 250000; i++) { stmt.clearParameters(); stmt.setInt(1, i); stmt.setString(2, GetString(25, rand)); stmt.setString(3, GetString(128, rand)); stmt.setInt(4, 0); stmt.execute(); if (i%1000 == 0) { connection.commit(); System.out.println(i + " rows committed"); } } } public static void main(String[] args) throws SQLException{ PropSamples propSamples = new PropSamples(); propSamples.Process(); } }
这些代码是用来生成数据。Util类(可以从资源中下载的源程序中获得)包含DB2相关的属性,你可以根据自己的环境而改变。
创建常规的servlet 这儿servlet是最方便的:每一个用户请求由一个独立的轻量级线程来服务,数据库连接可以从连接池中获取,清晰地分离了模型层和视图层等等。下面看一下常规的ClassicServlet:
// ClassicServlet.java package train;
import javax.servlet.*; import javax.servlet.http.*; import java.io.*; import java.util.*; import java.sql.*;
public class ClassicServlet extends HttpServlet { int mEntryLength = 250000; Random mRand; public void init() throws ServletException { mRand = new Random(System.currentTimeMillis()); }
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { PrintWriter out = response.getWriter(); Statement stmt = null; Connection connection = null; String name = ""; String descr = ""; int views = 0; int id = 0; boolean isError = false; synchronized (mRand) { id = mRand.nextInt(mEntryLength); } try { connection = Util.getDBConnection(); stmt = connection.createStatement(); String sqlStr = "Select id, name, descr, views from trentry where id =" + id; ResultSet rs = stmt.executeQuery(sqlStr); while (rs.next()) { //retrieves data from from DB name = rs.getString("NAME"); descr = rs.getString("DESCR"); id = rs.getInt("ID");
views = rs.getInt("VIEWS"); } Statement stmtViews = connection.createStatement(); String sqlViewStr = "update trentry set views = views+1 where id =" + id; stmtViews.executeUpdate(sqlViewStr); //updates number of the page views } catch (SQLException ex) {isError = true;} finally { try { if (stmt != null) { stmt.close(); } if (connection != null) { connection.commit(); connection.close(); } } catch (SQLException ex1) {isError = true;} } if (isError) { out.println("<html>System error</html>"); } else { //Delivers html page to browser out.println("<html>"); out.println("<p>ID: " + id + "</p>"); out.println("<p>Name: " + name + "</p>"); out.println("<p>Description: " + descr + "</p>"); out.println("<p>Views: " + views + "</p>"); out.println("</body></html>"); } }
}
我发布了这个servlet,在游览器中输入http://localhost:8080/train/classicservlet,就可以得到下面的页面: ID: 178866 Name: XEIRVYPSFTNRXYEWQWSKOOPES Description: JBGPGKSMDQKVXVPJCXKIMWLEWJABSGBNTOYRXRKUMDBWOYOCIAKDWGGEBHKIFONGSRBIBJIHSBNGEYIO RKGFOVWYXYXXJKUBBLVBSKOKLFCHIGRUGROKESIJQFERWJTV Views: 0
就这些而已,但这已经很好地模拟了电子商务中的常规操作。Servlet从数据库中获取无意义的数据,更新访问者的数目并输出结果。请注意我们生成随机的键值来从trentry表中获取随机行数据。一个好的数据库管理系统通常会缓存数据。因此,第二次的相同的select语句会执行地更快。我们使用随机键的目的就是使我们的数据不被缓存从而使下面的性能测试更精确。
常规servlet性能 那么这种servlet执行的效果如何呢?我们用JMeter来测试一下。使用JMeter模拟50个并发用户,他们将在5分钟内访问servlet6次。 为了使Tomcat在大访问量下正常工作,我必须修改Tomcat的配置文件server.xml,修改连接数据参数为?axProcessors="150",acceptCount="150"
图1是JMeter结果图的快照,显示了测试的性能结果。
Figure 1. Conventional servlet performance. Click on thumbnail to view full-sized image.
结果很明显:平均每次请求执行的时间为2.2秒(当然,如果我们使用预编译语句和连接池的话结果会更好一些)。而在我只模拟一个用户时,执行时间仅为70毫秒。因此,我们可以确定在常规servlet方式中大访问量将导致性能的明显下降。
Train模式的实现 类推我们的铁路站点,让我们在servlet中尝试一下相同的感觉。如果用户的请求必须等待一次有计划的与数据库的交互而且依赖其他的请求时会怎么样?这种功能很容易用JDBC2.0代码来实现:
statement.addBatch(); //Load the first passenger statement.addBatch(); // Load the second passenger … statement.executeBatch(); //Train is departing
这段代码是一种常用的用来减少与数据交互次数的技巧。 一种更有效的方式是组合多个SQL语句成一个,这样就可以不仅减少与数据库交互的次数而且减少查询的次数。例如,两个不同的select语句select * from trentry where id=333 和 select * from trentry where id=266可以用select * from trentry where id in (333,266)来代替。这样的话,更多的用户请求可以用一次事务范围内的一个SQL语句来完成。
此外,随着性能的提高,体现了另一个好处:这种技术减少了死锁和超时!更少的数据库连接,更少的排他锁,更少的死锁和超时。
有时候是不可能将多个SQL语句组合成一个(如一个普通的更新语句)。那我们就必须使用statement.addBatch()方法。 Train方式组合了这两种方法。
行胜于言 图2中的时序图及步骤列表解释了这个设计的原理。

Figure 2. Sequence diagram of the Train pattern. Click on thumbnail to view full-sized image.
· TrainServlet实例化Job对象,发送Job对象到Dispatcher,然后暂停 · Dispatcher组合Job到批处理中。 · 对每一个批处理,Dispatche创建一个Worker类的实例。 · 在给定时间或批处理中包含一定数量的Job后,Worker生成SQL语句并与数据库交互。 · 每一个SQL语句执行批处理中与所有Job相关的任务。 · Worker中断TrainSevlet线程 · TrainSevlet下发结果给用户。
现在我们打算解剖一下这种设计的实际实现。 我们来看一下下面的servlet:
//TrainServlet.java package train; import javax.servlet.*; import javax.servlet.http.*; import java.io.*; import java.util.*; public class TrainServlet extends HttpServlet { int mEntryLength=250000; Dispatcher mDispatcher; Random mRand; public void init() throws ServletException { mRand = new Random(System.currentTimeMillis()); mDispatcher = new Dispatcher(); Thread ht = new Thread(mDispatcher); //Instantiate and execute in the separate thread. ht.start(); } public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int id=0; synchronized(mRand){ id=mRand.nextInt(mEntryLength); } Job job = new Job(String.valueOf(id)); //Each concurrent request creates job instance. job.mJobThread = Thread.currentThread(); //Job should know the thread of the request. PrintWriter out = response.getWriter(); //Job should know the output stream of the browser. job.mOut = out; mDispatcher.AddJob(job); //Job is sent to the dispatcher. //Dispatcher is a container for all concurrent jobs. try { Thread.sleep(100000); //Let's wait until database interaction is finished. System.out.println("Error: Request is timed out"); //Too bad. 100 seconds was not enough. } catch (InterruptedException ex2) { //Success! Members of the Job instances are populated. } job.Marshall(); // Let's display the page in the browser. } }
新的servlet类似ClassicServlet,但有一些不同:用户请求被包含在Job类的实例中,与数据库的交互由Dispatcher类的实例来处理。 Job类显示如下:
//Job.java package train; import java.io.*; public class Job { String mName; String mDescr; int mViews; String mID; PrintWriter mOut; Thread mJobThread; boolean mHasFailed = false; //Sorry, no getters and setters to save space public Job(String id) { mID = id; }
public void Marshall(){ // displays html page mOut.println("<html><body>"); if(mHasFailed){ mOut.println("System error"); }else{ mOut.println("<p>ID: "+mID+"</p>"); mOut.println("<p>Name: "+mName+"</p>"); mOut.println("<p>Description: "+mDescr+"</p>"); mOut.println("<p>Views: "+mViews+"</p>"); } mOut.println("</body></html>"); } }
Job是一个纯粹的用户请求的代表。 marshall()方法在浏览器中显示信息。mJobThread成员是一个由WEB应用服务器创建执行请求的线程。 Dispatcher类是最重要的但也很简单:
//Dispatcher.java package train;
import java.util.*; public class Dispatcher implements Runnable { private List mCurrentJobBatch = new ArrayList(); //Batch container private int mJobBatchMaxSize = 5; //Maximum number of the jobs in the batch private int mIntervalTime = 50; //Maximum time to wait before batch execution
public synchronized void AddJob(Job job) { mCurrentJobBatch.add(job); if (mCurrentJobBatch.size() == mJobBatchMaxSize) { ProcessJobBatch(); //If batch is full, execute } }
private synchronized void ProcessJobBatch() { if (mCurrentJobBatch.size() == 0) { return; } Worker worker = new Worker(mCurrentJobBatch); Thread ht = new Thread(worker); ht.start(); mCurrentJobBatch = new ArrayList(); }
public void run() { try { while (true) { Thread.sleep(mIntervalTime); ProcessJobBatch(); // Each mIntervalTime milliseconds execute batch } } catch (InterruptedException ex) { } } }
Dispatcher生存在一个独立的线程中并且是所有并发Job的容器。每一批处理在两种条件下会执行,不考虑先后: 1、 自上一次这种条件下执行后mIntervalTime毫秒并且至少有一个Job在批次中。 2、 批次的大小达到mJobBatchMaxSize 如果mJobBatchMaxSize值设为1,TrainServlet就退化为ClassicServlet。这是一个动态批处理的重要标识,这会在稍后解释。 方案的最后部分是Worker类:
//Worker.java package train;
import java.util.*; import java.sql.*;
public class Worker implements Runnable { List mJobs; Map mJobMap; //Helper member for mapping the jobs protected Worker(List jobs) { mJobs = jobs; mJobMap = new HashMap(); }
private void Process() { boolean isError = false; StringBuffer sqlBuff = new StringBuffer( "Select id,name ,descr,views from trentry where id in "); StringBuffer whereClause = CreateWhereClause(); // sqlBuff.append(whereClause); /* Now SQL statement is fully formed and looks like:
Select id,name ,descr,views from trentry where id in (3343,22222,5555). This will allow us to fetch several user requests in one shot */ Connection connection = null; Statement stmt = null; try { connection = Util.getDBConnection(); stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sqlBuff.toString()); System.out.println(sqlBuff.toString()); Map result = new HashMap(); while (rs.next()) { int id = rs.getInt("ID"); String name = rs.getString("NAME"); String descr = rs.getString("DESCR"); int views = rs.getInt("VIEWS"); Job job = (Job) mJobMap.get(String.valueOf(id)); //Populate instance of the Job with data retrieved from database job.mName = name; job.mDescr = descr; job.mViews = views; } String sqlViewStr = "update trentry set views = views+1 where id in " + whereClause; // The same trick for Update statement stmt.executeUpdate(sqlViewStr); } catch (SQLException ex) { isError = true; } finally { try {
if (stmt != null) { stmt.close(); } if (connection != null) { if (isError) { connection.rollback();
} else { connection.commit(); } connection.close(); } } catch (SQLException ex1) { isError = true; } } FinishJobs(isError); return; }
private StringBuffer CreateWhereClause() { StringBuffer clause = new StringBuffer("("); for (int i = 0; i < mJobs.size(); i++) { Job job = (Job) mJobs.get(i); String id = job.mID; if (i != 0) { clause.append(","); } clause.append(id); mJobMap.put(id, job); } clause.append(")"); return clause; }
private void FinishJobs(boolean isError) { for (int i = 0; i < mJobs.size(); i++) { Job job = (Job) mJobs.get(i); if (isError) { job.mHasFailed=true; //Rudimentary error handling } job.mJobThread.interrupt(); /* Wake up the TrainServlet to deliver the page to the browser */ } }
public void run() { Process(); } }
Worker类创建SQL语句,为一次与数据库的交互来组合Job,用来自数据库的信息生成Job实例并且唤醒Job线程。
Train servlet性能 我使用相同的JMeter配置来测试TrainServlet。是否我们在浪费时间来开发Train模式呢?当然不是,就如图3显示的那样。
Figure 3. TrainServlet performance. Click on thumbnail to view full-sized image.
我们提高了15倍的效率!结果可能与硬件/软件/特定的实现有关,但显然效率的提高是肯定的。 这个图描述了静态的50个用户并发访问的模拟环境。但更实际和有意义的数据可以通过动态回归分析来取得。对这种类型的分析,我模拟了不同的并发用户数据并且测量两种servlet对每个请求的性能。
图4显示了测量结果。
Figure 4. Comparative dynamic performance. Click on thumbnail to view full-sized image.
结果有点难心置信。相比ClassicServlet,TrainServlet在大量外部访问时并没有出现性能衰退。当然在小于3个并发用户时,ClassicServlet的响应更快一些。我称这种现象为低访问量惩罚,这出现在只有一个用户请求时。在这种情况下,我们需要等到下一次计划时间到达时才能执行,也就是说在只有一个请求时批处理并不能提供什么好处。
最后的探讨 这儿描述的这种方案因为简洁的原因写得相当简单,有一些地方明显值得改善。
前瞻性的访问量分析和动态批处理 在这里我们设置mIntervalTime的值为50毫秒。假设访问量很小,平均100毫秒才有一个新的请求,那么我们将会延迟每个请求将近25毫秒。我们如何才能以免这种低访问量惩罚呢?解决方案来自基于前瞻性访问量分析的动态批处理。换句话说,我们根据前一次会话中的用户请求数来可以改变批处理中的最工作数据。最简单的算法如下: 1、 记下最近50毫秒内服务的请求数量。 2、 如果数据小于5,则设置mJobBatchMaxSize为1否则为5。
如我上面讨论的,当mJobBatchMaxSize为1时,TrainServlet就与ClassicServle一样了。
多个命令的分配器 标准WEB应用服务器处理多个命令而不是一个,实际应用中Dispatcher和Worker应该能为所有的服务。每一个命令应该有自己的Worker类,Dispatcher应该作为一个单例来实现。然而,这儿两种可能的Dispatcher设计方式: 1、 每一个命令有一个自己的Dispatcher类。如果特定时间后,没有请求需要服务则Dispatcher线程就退出,因为我们不需要很多空闭的线程来消耗系统资源。因此对比在serverlet的init()方法中实例化Dispatcher,我们更倾向于调用:Dispatcher.getIntance().AddJob(job)。 2、 仅有一个类来管理来自不同命令的不同批处理并且通过命令名将他们与相关的工作者相关联。这时候,调用看起来更像:Dispatcher.getInstance().AddJob(this.getClass().getName(),job).
作为JDBC代理的Train模式 从实际的观点来看,想从运行中的标准应用或者放弃传统的想法来重新构建应用是困难的。那么如果构建某种JDBC代理使得来自应用的JDBC调用基本一致时会怎么样呢?可以用EJB作为包装器来处理。这种方式是有效的,因为他可以处理任何如并发用户访问网络资源或数据库的应用而不只是服务于WEB应用。但是这已经是另一个主题了。
小结 在这篇文章中,我们设计了一种新的称为Train的方式来开发有效的WEB应用。我们基于Train模式构建了一个实际的应用并且证明他可以在压力测试提供100%的性能改善。Train模式的使用并不限于WEB应用,通过减少软硬件的需求,他可以为任何处理并发用户访问网络资源或数据库的应用带来好处。 在这里特别感谢我的朋友们Mark Jackson, Sander Berents, Tom Griffin, and Eric Van Stegeren对我的支持和帮助。
关于作者 Edward Salatovka是一个工作于专利信息提供商的高级主管工程师。 Thomson Delphion最近12年他作为核心开发人员、技术领导及架构师工作于大型项目如IBM的WebSphere Commerce和后续的高技术启动的创新的解决方案
资源 ·javaworld.com:javaworld.com ·Matrix-Java开发者社区:http://www.matrix.org.cn/ ·下载本文示例代码:http://www.javaworld.com/javaworld/jw-04-2005/train/jw-0418-train.zip ·来自JAVA开发人员Almanac的“在数据库中执批量SQL语句”:http://javaalmanac.com/egs/java.sql/BatchUpdate.html
|