一、 应用场景描述
系统主要为教师在线学习提供服务,其中视频学习网站支持教师在线视频学习,教师在视频学习过程中其学习过程会被记录下来。每个专题下对应多个教学视频,每个教学视频时长不尽一致。现在的记录规则是:教师在看视频的时候,视频所在的页面每分钟提交一次请求,记录该视频已学习时长,并将该记录更新到数据库。
目前数据库中有8266个教师用户,在***政策下,极有可能在某一段时间内大部分教师用户同时在线看视频。这意味着在极端情况下,每分钟可能会提交6000+个请求,对应用服务器带来了很大的压力。另外,我们在更新学习时长记录前,会将其与已学时长(需要实时的查询)相比较,如果当前提交的时长比已学时长大则更新,否则不更新,频繁的查询与更新数据库严重降低了系统的响应速度。对学习时长的记录过程进行优化迫在眉睫。
二、 硬软件情况
硬件:一台服务器,核心4核,内存
软件:Windows Server 2008 64位操作系统, Tomcat 7, jdk1.6,MySql5.5
三、 优化过程
一》第一阶段
分析:首先想到的是,这个过程主要的压力在于大量的服务器请求和频繁的数据库连接,那么,合并请求,利用缓存机制应该可以解决问题。
解决方案:将用户的请求置入缓存,定时集中处理,合并更新操作。
具体做法:利用线程安全的包装后的HashMap作为用户请求缓存。
public static Map<LearnTime, Integer> map = Collections.synchronizedMap(new LinkedHashMap<LearnTime,Integer>()); //用户请求来了之后将请求置入缓存 protected static void put(String userName, String videoId, int topicId, int totalTime, int learnTime) { LearnTime learn = new LearnTime(userName, videoId, topicId, totalTime); //可以保证缓存中的时间比看过的时长要大 if(map.containsKey(learn)) { if(learnTime > map.get(learn)) { map.put(learn, learnTime); } }else { //每个"用户-视频"在一个缓存时间内只查一次 int learnedTime = dao.getLearnedTime(userName, videoId, topicId);已经学习的时间 if(learnTime > learnedTime) { map.put(learn, learnTime); }else{ map.put(learn, learnedTime); } } } |
每隔一个小时,对缓存数据做一次处理,将学习记录更新到数据库:遍历HashMap数据项,生成sql语句,拼接到一起,然后在一个连接之内处理完。
Iterator<LearnTime> i = s.iterator(); StringBuilder str = new StringBuilder(); while (i.hasNext()) { learn = i.next(); userName = learn.getUserName(); videoId = learn.getVideoId(); topicId = learn.getTopicId(); learnTime = map.get(learn); totalTime = learn.getTotalTime(); if(learnTime < totalTime){ //分数保留两位小数 String sScore = new DecimalFormat("#.00").format(0.5*learnTime/totalTime); Double score = Double.valueOf(sScore); str.append(sql语句); }else if(learnTime >= totalTime) { learnedTime = dao.getLearnedTime(userName, videoId, topicId);已经学习的时间 if(learnedTime != totalTime) { str.append("sql语句"); } } } |
这样处理之后,系统性能得到了一定的改善,但是数据库连接的压力还是挺大的,从程序代码中可以看到,在往缓存中添加学习记录和更新之前,都有连接数据库进行查询的操作,对数据库连接也有较大的消耗。另外,用户端对服务器的大量请求并没有得到较好的解决。因此还需要继续优化。
二》第二阶段
分析:前面已经分析了仍然存在的两个问题,其一是应用服务器的大量并发请求,第二是数据库的频繁访问。还有一个没有提到过的,当缓存正在被读取时,往缓存里面写数据是要被阻塞的,如果缓存遍历和更新处理过慢,则会导致长时间的请求阻塞。
解决方案:对于应用服务器的请求,由于每个请求做的事情仅仅是做一个查询,然后向缓存里面更新数据,这个过程是非常短的,我们可以利用Tomcat配置一个较大的线程池,以响应如此多的请求;对于数据库的频繁访问,不难发现,其实更新过程已经合并了,只是查询已学时长还是单个做的,查询仅仅为了校验是否更新,若把是否更新交给数据库去决定,那么所有的查询请求都会合并到更新中去,这样这个问题就解决了;缓存的遍历快慢由缓存的大小决定,需要选择合适的缓存周期;更新的处理可以剥离出来,在遍历缓存的同时,将数据取出,另外开一个线程来处理更新操作,让HashMap的锁释放,减少阻塞时长,遍历并生成sql语句过程应该可以控制在几秒内,影响不大。
具体做法:
1.将请求置入缓存
public static Map<LearnTime, Integer> map = Collections.synchronizedMap(new LinkedHashMap<LearnTime,Integer>()); //用户请求来了之后将请求置入缓存 protected static void put(String userName, String videoId, int topicId, int totalTime, int learnTime) { LearnTime learn = new LearnTime(userName, videoId, topicId, totalTime); //可以保证缓存中的时间比看过的时长要大 if(map.containsKey(learn)) { if(learnTime > map.get(learn)) { map.put(learn, learnTime); } }else { //直接放入缓存,判断是否更新在数据库处理 map.put(learn, learnTime); } } |
2.更新过程剥离
static class UpdateTask implements Runnable{ private String sql; public UpdateTask(String sql) { this.sql = sql; } @Override public void run() { dao.updateLearnTime(sql); } } |
3.缓存周期清理
count++; //每处理一次更新,计数加一 //达到缓存周期时长,将缓存清理掉,计数清零 if(count % clearCycle == 0) { map.clear(); count = 0; } |
4.校验过程都在数据库做,即更新语句作限制,略。
5.应用服务器Tomcat连接池配置。
1)下载tcnative-1.dll,以支持APR请求 2)将dll文件复制到windows/system32下面,或者将其加入path 3)配置Tomcat下的server.xml <Executor name="tomcatThreadPool" namePrefix="tomcatThreadPool-" maxThreads="1000" maxIdleTime="300000" minSpareThreads="100" prestartminSpareThreads="true" /> <Connector executor="tomcatThreadPool" URIEncoding="utf-8" port="80" protocol="org.apache.coyote.http11.Http11AprProtocol" connectionTimeout="20000" redirectPort="8453" maxThreads="1000" minSpareThreads="200" acceptCount="1000" /> |
四、 优化总结
该实际场景的优化主要在四个方面:1、合并数据库连接请求;2、增加应用服务器响应线程数;3、实际更新处理与缓存周期剥离以减少阻塞;4、权衡缓存大小和用户使用习惯,合理设置缓存清理周期。
另外,该场景最初的瓶颈在于频繁的数据库连接,而正好可以通过合并连接来优化。在极端情况下,没法合并连接呢?这就必须要在数据库访问层利用连接池进行优化,在现有架构下,还不知道如何配置数据库连接池,这是一个需要摸索的重要优化点。
五、 测试数据
实际场景下,假设8000+用户同时在线看视频,一分钟有8000+次请求,平均每秒140次。每个请求由一个线程来执行,在测试时,模拟这个过程。
测试用例:每100毫秒提交20个请求,也就是一秒200个请求,每个请求开启一个新的线程执行,共提交2000000请求,测试时长10000秒。以3000用户(由于不好实际模拟,采用将数据库中数据提取出来的方式,用随机提交请求的方式在程序中测试)在线看视频,每三分钟处理一次更新,每8个处理周期清理一次缓存。测试结果毫无压力。可能在实际场景中,需要遍历的缓存项会多一些,但是根据经验,服务器执行更新语句可以达到每秒3000条以上,况且并不会对缓存阻塞,因此完全满足性能需求。
把请求频率改为每100毫秒提交50个请求,也就是每秒500个请求,其他条件不变,测试时长4000秒。测试结果也很理想,没有多大改变,也就是说支持3万用户的时长记录请求木有问题。服务器资源有浪费啊!
测试代码如下:
Random random = new Random(); LearnTime learn; int index = 0; int learnTime = 0; for( int i = 0;i < 2000000;i++){ index = random.nextInt(3000); learn = learns[index]; learnTime = random.nextInt(learn.getTotalTime()+1); Thread thread = new Thread( new PutTask(learn, learnTime)); thread.start(); if(i%50 == 0){ Thread. sleep(100); } } //请求线程模拟如下: static class PutTask implements Runnable{ private LearnTimelearn; privateintlearnTime; public PutTask(LearnTime learn, int learnTime){ this.learn = learn; this.learnTime = learnTime; } publicvoid run() { LearnTimeHandleServlet. put(learn,learnTime); } } |