2016年七月,考完最后的网页设计,总算圆满完成了大一的所有功课。回顾这一年来,糟糕的事常有,但这一年过得并不算糟糕。功课还行,生活还算规律,兼职和运动也没落下。

暑假我打算留校自习弥补这学期的遗憾——教务系统崩溃最终没能选上课。学校的教务系统也是正方软件开发的,听师兄说年年选课年年崩溃。我下定决心要开发一款不会崩溃的选课系统,能够让代码跑在线上环境而且被很多人使用应该是所有开发者的梦想吧,那会私心还是有的,至少自己往数据库插入自己的一行选课记录就不用跟着他们去抢课了。

以上就是我开发demo0的全部原因和动力。

人们总是说无知者无畏。跟师兄吹牛的时候其实我还没使用过Linux,也没写过服务端程序,我的最高水平仅在期末数据库实训作业中用JavaGUI远程连接我虚拟博客数据库实现的购物小程序。

正方教务软件用的是.Net和Oracle,但我技术栈只能允许我用Java和MySQL。我分解了整个选课流程——登陆(select),查看选课列表(select),提交选课(update + insert)。web应用的并发瓶颈很多时候不在web服务器,而在数据库。登录、查看选课是读操作,只有最后的提交选课为写操作,在这种读多写少的情景,往往是在读这方面下功夫,比如登陆应该从缓存获取学生的账号信息,或者从其他数据库获取,力求减少读操作对写操作的影响,防止数据库崩溃。另外学校每届学生不超过1万,加上上一届没选上的不会超过1万5千人。开抢几十秒里应该有好几千的负载,但只要扛住开抢后的一分钟或者30秒就已经足够了。

我把我的分析分享给师兄们以寻求技术上的支持,但师兄们都没写过高并发的web应用,大都抱怀疑态度,或有的说即使做出来学校也不会用。只有教我C语言的计算机启蒙老师——林萍老师不断鼓励我尝试下去,直至今日我依旧很感谢老师的鼓励,或者说感谢老师明知道取代教务软件不现实但还是没一语道破。

暑假里我每天都待在校企项目班,里面有很多师兄留校做项目,基本都是移动端,尽管技术方向不同但我还是从他们身上学到很多技术外的知识,也留下宝贵的友谊。那两个月里其实还是蛮难熬的,特别是到后面那种离开学越来越近,项目却没什么进展的那种无助与焦虑。好在最后还是解决了折磨我长达半个月之久的内存溢出问题,至此项目总算完毕,虽然只有登录、找回密码、选课三个功能,但并发读或写能达到2000多每秒,已经超过教务软件。后来开学我找过学校教务处商量着上线试用,结果自然是吃闭门羹的。当时我的要求是把全校所有学生的账号和密码给我,以及教务系统的表结构,如此一来选完课直接根据表结构转储到学校教务系统的数据库就完事了。不管如何,学生的账号密码教务处是不肯给的,最后我也只是要到了一张选课的表结构。我把事情告诉林萍老师,老师依旧鼓励我,希望我能用文字记录下来,其实我内心是希望教训我活该、天真、幼稚。开学后我尝试过写博客记录,但知识不够深入,最终只能不了了之。

当时我用的是Servlet、JSP、MySQL(Myisam引擎)、ajax实现。如今应用越来越复杂,我也很反感写原生Servlet。最近我用新技术栈重构了demo0,力求保持代码简洁可拓展的同时争取在性能上有更大的突破。

如今使用的是:Spring Boot、Thymeleaf、JPA、MySQL(Innodb引擎)、Lombok。
如果你了解这些框架可以略过下面的简介。

  • Spring Boot:在Spring框架之上的一种微框架,基于约定默认优先原则使得你不再需要配置各种繁琐的配置,内嵌有Web容器(Tomcat或Jetty)加速开发期间Web容器的重启速度。
  • Thymeleaf: 一种以.html结尾的模板引擎,能够在非Web容器环境下预览,减少了html转换jsp的麻烦。
  • JPA:一种ORM的标准,具体实现一般选择ORM霸主Hibernate。类似于Java的JDBC接口与各个数据库厂商的实现。
  • Lombok:IDE级别的插件,使用注解减少手动编码提高开发效率。

项目开发时遇到很多难题,如今的重构也是。我按小结记录一些分析以及优化手段。

连接池:

我对连接池的衷爱要从大一下学期期末的数据库实训作业讲起,当时我用JavaGUI(Java桌面端程序)开发了一款购物桌面程序,连接了日本虚拟主机的MySQL。开发过程中由于遵从老师的叮嘱——数据库连接用完要关闭,导致我的GUI程序在每次执行数据库操作时界面都会假死半秒。数据库连接是Socket连接,是TCP/IP协议的高度抽象,所以创建数据库连接必然要经过「三次握手」,有三次网络损耗时间,但实际上并没有那么简单,ping命令从本地到日本主机一次来回也就60ms,实际上创建数据库连接却要花费550ms。我当时的解决方案是使用单例复用首次创建的连接,并在每次使用的时候判断连接是否有效,无效则进行重连。
讲到这里道理很浅显,连接池用于存放数据库连接,Tomcat自带的JDBC pool通过拦截器拦截了Connection#close()方法,当你调用Connection#close()方法时会被拦截并执行连接归还操作,由连接池来定期维护和管理连接。
常见的连接池有JDBC pool(Tomcat自带), HikariCP, C3P0。具体的配置属性可参考官方文档,在文末有链接。

MySQL:

MySQL兼顾性能与实用性,是如今关系型数据库的代表,也没什么好说的。

我们学校没有像本科生一样修「数据库系统概念」,我对数据库原理更是一窍不通。现在的我只能记录一些主要的优化手段以及附上尚所能及的分析。

事务的重要性不言而喻,这次重构改用支持事务的Innodb引擎通过抛异常来控制数据的回滚,而不是盲目追求性能使用Myisam然后在代码里手动检查数据库数据的完整性。在这个项目中,唯一用事务的地方是在用户提交选课时——更新课程余量后对记录表插入一行记录,虽然只有两步,但如果不使用事务会出现很多问题,课程余量我设置为非负数(unsigned),当update导致余量为负数时会抛异常,update成功但insert时出现唯一键重复(用户重复选课了)要还原前面update的操作;干脆执行前先两次select查看是否符合插入情况;还有这种情况,用户A、B同时选同一门课,此时该课余量只有1,好在Myisam的表级写锁保证只有一位用户能抢到课,假设是A成功了,这时候论到B获得写锁,但发现课程余量为0,此时A去插入选课记录时发现用户A已经选课而插入失败,再而执行复原的update操作,可惜B用户已经读取到课程为0并告诉了客户端该课程余量为0,实际上课程已经被复原为1,当并发时出现这种情况会更多;
如果使用事务则简单很多,把课程余量减一与插入选课记录作为事务提交,相当于原子性的操作,下面是伪代码:

1
2
3
4
5
6
7
8
try {
connection.setAutoCommit(false);
update 课程表 set 课程编号 = 课程编号 - 1 where 课程id = ?
insert into 记录表 values (学号, 课程编号);
connection.commit();
} catch (异常) {
connection.rollback();
}

想象一下现在有100个人同时选同一门课,这100个线程都同时走到第二行代码,正准备对课程表update操作,最先update的线程获得了该数据行的排它锁(X), 则后面99个线程阻塞在锁等待队列中,当第一个线程update完成后,对该行的排它锁其实还没有释放,要等到该线程rollback()或者commit()后,后面的线程才能获得锁。最后线程1插入课程表成功后执行commit(),此时该课程余量只剩1了,这时线程2终于获得到该行的排他锁,成功该课程余量改为0时,此时如果外面有100条线程请求select目前所有课程的余量(相当于刷新了选课页面),那现在他们看到该课程是1还是0?MySQL的MVCC(数据多版本并发控制)会生成改行数据的一份Snapshot(快照),由于之前线程1已经将该课程余量commit为1,所以后面的100个select会读到余量为1的Snapshot,即使此时在线程2中已经被update为0但还未commit,这个结果是合理的,我们此时希望告诉后面的人该课程余量还剩1,因为执行线程2的程序无法知道下一步插入选课记录能否成功执行,这样减少读写锁冲突也提高了并发update时select的效率。回到刚刚线程2的执行,下一步插入的成败只对应两个结果,rollback或者commit,不管是哪一种结果,线程3都会在线程2rollback或者commit后获得该行的排它锁,如果该课程已经被线程2commit为0,那么从线程3到线程100都会这样执行:获得行级X锁,update失败,rollback。

在享受事务的ACID属性的同时也带来一个问题——无比糟糕的性能。

以下数据基于我笔记本的配置:I7-5500U 2.9GHz(max)/ 500M/s读写固态 / DDR3 8G / JDK1.8.112 / MySQL5.6.32 / Tomcat 8
测试方法分为四种,均使用连接池:

  • 本地原生:非web环境 + 原生Java操作数据库
  • web原生:web环境 + 原生Servlet + 原生Java操作数据库
  • web框架:web环境 + Spring + JPA

这三种测试方法均有我的某些考量:

  • 本地原生:创建固定线程后使这些线程在规定时间里不断执行——从连接池获取连接,开启事务,执行数据库操作,提交事务,归还连接。使用连接池其实还能测试连接池的性能。测得的结果是程序+连接池+MySQL的最佳性能。最佳性能也可以作为其他测试方法的基准参考。
  • web原生:基于Servlet单实例多线程的特点和Tomcat的工作原理,为了减少线程创建和销毁的开销,我在Tomcat配置了线程池,数量为200。
  • web框架:与web原生对比,简单对比SpringMVC 与 ORM 对性能的影响。

得出的数据不具备严密的可比性,仅当做一种参考。测试期间win10创意者版本中的一次升级抑制住了该版本起初Windows Defender的暴动,导致性能测试结果均有大幅提高,而原来的准确数据没有记录下来。

回到正题——糟糕的性能。

Innodb引擎事务的持久性是通过redo log来保证的。默认每次事务的提交都会把日志从Innodb的log_buffer内存中写入到操作系统缓存同时调用fsync同步到Innodb的log_file(记录事务的文件),因为日志已经写入log_file,即使服务器宕机也不会丢失事务。操作系统缓存同步到磁盘文件需要相当大的IO开销,导致本地原生的方法测得只有几百/TPS(Transactions Per Second),解决这个问题需要配置innodb_flush_log_at_trx_commit的值。

  • 当它等于1时如上面描述的,每次事务提交都把日志从log_buffer写入OS缓存再同步到Innodb的log_file,这也是MySQL默认的设置。
  • 当等于2时:每次事务提交都会把日志从log_buffer写入OS缓存,每秒调用fsync使日志从OS缓存同步到log_file。但由于进程调度(process scheduling)问题不能保证准确1秒调用一次。因为日志已经写入OS缓存,即使MySQL崩溃也能保证事务,但假如服务器宕机则会丢失那一秒内的事务。
  • 当等于0时:log_buffer中的日志内容大约每秒一次写入OS缓存,至于OS缓存何时同步到log_file由系统决定,此时如果MySQL奔溃或者服务器宕机都会损失那一秒内的事务。

下面是引自阿里DBA月报的图片。

根据阿里DBA的自述,淘宝平时都会将该值设置为1,只有系统高峰期才会临时改为2。所以把该值设置为0都是极不可取的。当我更该为2后,本地原生测试已经达到1000多TPS。这也足以说明IO开销对并发时性能的严重影响。

谈到日志IO开销就不能忘记另外一个参数:innodb_log_buffer_size,该值默认为8M, 对于大型事务(一个事务中更新大量记录),增大这个值可以避免Innodb在事务提交前log_buffer已经写满日志从而导致不必要的IO开销。

还有innodb_log_file_size,默认48M,当日志被写满后,Innodb会切换到另外一个日志文件,此时会触发数据库检查点(checkpoint),Innodb缓存脏页会刷新,降低Innodb性能,也就是说至少我们不希望在一个并发的时间段中日志就写满了,此时Innodb会切换日志文件,造成额外IO开销。根据经验适当调大,比如128M或者256M,MySQL官方文档建议其值不能调得太高,因为当MySQL崩溃重启恢复时需要更多的时间。

我一开始使用的连接池是Tomcat的JDBC pool, 后来在我开发demo2(一款弹球游戏)时,偶尔发现点击查看留言时要等接近3秒ajax才有响应,我没去深究下去,在那之后我一直使用C3P0。这次重构时我发现一款优秀的开源连接池叫HikariCP(光),我使用本地原生模式测Myisam和Innodb与select和update的四种交叉情况,HikariCP都能以两倍性能完胜C3P0。可见在获取连接与归还连接的效率上HikariCP都做得很棒,使用 show processlist 也没有发现有连接泄露或者其他怪异的现象,从这以后我没再打算用C3P0。HikariCP的GitHub页面上那套基准测试看似完爆了Tomcat,但戏剧性的是在我测试过程中,Tomcat基本持平Hikari,偶尔会微微领先Hikari,这令我很惊讶。

之后我尝试写存储过程,在存储过程中开启事务完成业务逻辑,一开始的只有几百TPS,在我看过MySQL官方文档的JDBC URL连接参数后才明白要加上&cacheCallableStmts=true,表示让JDBC程序缓存prepareCall()方法中字符串参数的解析结果,之后TPS一下子跳上4000多,说明Java调用存储过程时解析那段字符串需要相当大的工作。

1
conn.prepareCall("{call proc_test_update(?, ?)}");

我写了两个存储过程,用于测试的只有两个参数,另外一个有三个参数,测试时我只调用测试用的存储过程,因为调用正式的存储过程测试时会比较麻烦。

JDBC URL连接参数:

平常我们对URL连接参数应该仅限在选用字符集上,但实际上URL参数里也有很多学问。

我上面说的参数&cacheCallableStmts=true带来存储过程调用性能的暴涨已经表明URL参数的重要性。

我挑了几个主要的参数:

  • cachePrepStmts:表示驱动程序是否缓存客户端PreparedStatements的解析结果。默认为false。
  • prepStmtCacheSize:当cachePrepStmts启用,表示驱动程序应该缓存多少解析结果,默认25。
  • prepStmtCacheSqlLimit:原文是If prepared statement caching is enabled, what’s the largest SQL the driver will cache the parsing for?,百度、Google、Bing甚至是官方文档我都没明白什么意思,默认256。

cachePrepStmts与前面的cacheCallableStmts作用是缓存解析结果,都能减少驱动程序解析语句的时间,如果未开启,不管是原生代码、Hibernate或者其他ORM框架在每次使用PreparedStatements都会重新解析语句,这是很糟糕的事情。

再往下讲,cachePrepStmts 开启也只是在驱动程序即Java中缓存解析结果,MySQL服务不会对做语法解析的预编译。看下面代码。

1
2
3
ps = conn.prepareStatement("select * from tb_a where code = ?");
ps.setString(1, "10001");
ps.executeQuery();

当执行查询时,SQL会变成 select * from tb_a where code = '1001'发送给MySQL进行查询,这样的SQL语句是不能直接查询的,要经过SQL语法解析器解析成 from tb_a where code = '1001' select *,每次执行该SQL时都会需要重新解析SQL语法,但其实该SQL的语法结构是固定。解决这个问题可以使这个参数,&useServerPrepStmts=true,表示让MySQL进行语法的预编译。还是上面的代码,当执行第一行时,会把select * from tb_a where code = ?发送给MySQL让其对该SQL做预编译,也就是语法解析,然后缓存该解析结果,以后的所有查询如果找到缓存的解析结果都会进行值替换,从而减少解析SQL语法的时间。

你可以在MySQL中运行show global status like '%prepare%';查看MySQL服务至今总共预编译了几条SQL语句,如果你不知道&useServerPrepStmts=true这个参数,那结果很可能都是0,也就是说MySQL从没有替你预编译过一条SQL语句。

以上就是JDBC URL链接主要优化部分,其它参数请参考官方文档(文章末尾附有链接)。

集成测试

分享我用过的web环境测压工具。
Apache的ApacheBench,在Apache的bin目录下。

1
2
ab -n 8000 -c 100 http://127.0.0.1:8080/demo0/update #总共8000请求,每次并发100请求
ab -t 60 -c 100 http://127.0.0.1:8080/demo0/select #60秒内,并发100请求

webbench

1
2
webbench -t 10 -c 100 http://127.0.0.1:8080 #10秒内 并发100请求
webbench -t 30 -c 100 -f http://127.0.0.1:8080 #-f表示不等待服务器返回数据

JVM:

我对JVM的了解源于我一个致命的简单错误,所以这部分当成一个笑话来看是最合适的。

当时我从未想过Request对象与Session对象的差异,当时我的做法是从数据库读出来的课程数据存放在Session对象中,展示课程的JSP页面从Session对象中获取课程数据后展示出来。

要把这个笑话讲明白还需要一点铺垫。

Session(会话)其实是服务器JVM堆的某一块内存,用来存放此次会话的所有信息,而服务器识别用户的唯一办法就是在用户第一次访问时给用户分配一个Cookie(名为JSESSIONID)让用户保存,以后用户再次访问时都会带上这个Cookie,服务器会通过这个Cookie值在内存中找到此次会话的数据,所以在Session过期前服务器都会记得该用户,这个过期的准确定义是:当两次访问服务器的时长超过规定时间时则此次会话失效,这个过期时间是可以自定义的。Session相对于Request最大的好处是能够多次访问,但由于我的错误使用让这成为最大的坏处。当时我用webbench对选课页面测压以估算数据库的读性能,但十几秒服务器就挂掉了,查过Linux的日志后得知是堆内存溢出。在本地测压时没有这种情况,后来我学会了使用VisualVM,我远程连接到服务器的JVM实时监控GC工作以及内存的分配情况。我尝试过很多办法,比如把JVM的堆内存调大、将old与Young(Eden + Survivor)的比例调为1:1,等等这些增大年轻代内存的方法,但这些法子只是把OMM延长了几秒。每次发生OOM都有一个特点,VisualVM显示Survivor的两个区分配有内存,这也正是奇怪的地方,JVM在年轻代执行Copying算法,Survivor的两个区To和From会在From内存用完时切换角色,但To区总是空的。当时的图 差不多像这样。

在后来一次午饭中,我突然明白To区也被分配内存的原因——Eden区的某些对象存在引用导致GC无法清理进而不断积累在From区,最后导致To区在OOM最后一刻也被分配内存。某些对象就是从数据库读取出来形成的List,而所谓的引用其实是被Session引用。弄懂原理后我在JSP页面用一行代码解决了困扰我半个月的OOM,courseList = null;

如果还不明白那我们来简单剖析用Request对象时Tomcat的工作方式。
当客户端对选课页面发起HTTP请求时,握手以及连接的建立由Tomcat来完成这些不用关心,初期工作完成后,Tomcat会从线程池(不是连接池)中借出一条线程用来访问Servlet的方法,调用这个方法时虚拟机栈会将这个方法的栈帧进栈,这个栈帧保存了所有的本地变量以及堆内存对象的地址,自然包含我定义的List,之后Servlet转发到JSP页面(其实JSP也会被编译为类),相当于虚拟机栈再进栈,JSP页面处理后将信息输出给Response,最后虚拟机栈将两个方法出栈,此时在Eden区只有List对象,而且已经没有被引用,当Eden区内存用完执行GC时这些对象都能被一次性的清理。

开发最后我也没能改用Request对象,虽然那一行代码能够解决OOM,但实在low得每次想起时只能在心里尴尬的笑着。

框架:

Spring:Java中最喜欢的框架。Spring.io上有非常多的项目,其中「Spring Framework」仅MVC的复杂已经超乎我想象。从体积上说,当时使用原生生成的war包应该只有几M大小,现在的Spring常常达到十几M,Spring Boot就恐怖了,demo0这种简单的web项目已经达到20多M。尽管体积庞大但真正使用起来并没有的累赘感,反而非常轻便。原生JDBC和Servlet与SpringData、SpringMVC的混用非常方便,你可以根据需要随时切换,在Spring的世界里没有非此即彼的观念。
JPA:目前仅会的ORM框架,注解式事务特别喜欢,目前还没深入研究过ORM。不过这次重构中发现其实ORM才是性能的累赘,不过正如前面我说的,在Spring的世界里,对于某些性能有更高要求的地方,你可以很轻松的切换Servlet和原生JDBC。
Lombok: ,一款IDE级别的插件,eclipse或idea都支持,开发工具的安装方法参考官网或者百度。在集成工具编译时会自动对字节码加强。虽然这个框架并不能提高应用的性能,但却能提高开发效率。一个场景:但你发现Course实体类还要加一个开课日期时,你添加一个Data属性,然后使用快捷键新增了getter、setter属性,还要在全参构造器修改。如果你使用了Lombok:新增了一个Data属性,然后,其实没有然后了。具体的用法文末有官网链接或者参考我的GitHub上的demo0。

其他:

  • Tomcat线程池
  • 静态文件使用gzip或者CND

当时在架构上我还尝试了很多新技术,比如MySQL的主从复制用来做读写分离,Nginx反向代理来实现两台服务器的负载均衡。但结果都是吃力不讨好,只能说项目还没达到需要负载均衡的地步,使用两台服务器也会带来额外的网络开销,总之只是徒增测试的难度以及不可控因素。现在如果要从架构方面来提高性能,我可能会选择支持事务与持久化的Redis。也许以后我还会用Redis重构demo0。

至此,demo小结已经完满结束。最后附上重构后性能图的链接性能图

文中链接:
demo0项目:https://github.com/Dog-Lee/demo0
JDBC URL连接参数:https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-configuration-properties.html
Tomcat JDBC pool:http://tomcat.apache.org/tomcat-8.0-doc/jdbc-pool.html
HikariCP:https://github.com/brettwooldridge/HikariCP
Lombok:http://www.projectlombok.org/