绝大多数秒杀系统都需要实现高并发,这样就必须在原来的项目基础上进行优化。简单的优化很有可能就会很大地提高系统的并发性能,但是这些优化往往是系统开发人员很少注意的,或者直接被人们忽略。因此要成为一个出色的开发人员,学会优化技巧与时刻具备系统优化的意识是必须的。
项目源码地址:
https://github.com/HuangFuGui/Javaweb/tree/master/Maven%2BSpringMVC%2BSpring%2BMyBatis
注意:在看本博客之前建议先大致看明白项目结构
本项目秒杀业务核心SQL操作:
先是UPDATE货存(货存减1),再是INSERT购买明细。中间可能会出现重复秒杀,秒杀结束,系统内部错误等异常,只要出现异常,事务就会回滚。
事务行为分析:
当一个事务开启的时候拿到了数据库表中某一行的行级锁,另一个事务进来数据库时发现锁住了同一行,若之前的事务不提交或回滚,这个行级锁不会被释放,后面进来的那个事务就要等待行级锁。当第一个事务提交或回滚后,行级锁被释放,第二个事务就能获得这个行级锁进行数据操作,多个事务以此类推,这些过程是一个串行化的操作,也是一个含有大量阻塞的操作。这是mysql数据库或是绝大多数关系型数据库事务实现的方案。
秒杀系统瓶颈分析:
注:java的GC操作:项目中DAO层各数据库操作类通过MyBatis实现的生成相应对象注入Spring容器中,当使用后不再被使用时,就会进行垃圾回收。
项目优化分析:
通过分析事务的行为与秒杀系统瓶颈可以知道,要减少事务等待的时间,削弱阻塞的过程,就要想办法减少行级锁持有的时间。
简单的并发优化(优化思路一):
分析:
参照优化思路一,持有行级锁在UPDATE上,INSERT不涉及行级锁(没INSERT之前根本不存在相应的行,更不可能会有行级锁)。因此可以先插入购买明细,这个过程虽然存在网络延迟,但是各个事务之间是可以并行的所以不需要等待,这样就可以减少各个事务一部分的等待与阻塞。实现减少MySQL row lock的持有时间。(但还是要把UPDATE库存的结果返回给客户端,客户端再决定是否提交事务,即还有2次网络延迟)
修改秒杀业务核心代码顺序后:
int insertCount = successKilledDao.insertSuccessKilled(seckillId,userPhone,nowTime);
//唯一:seckillId,userPhone(联合主键)
if(insertCount<=0){
//重复秒杀
throw new RepeatKillException("seckill repeated");
}
else {
int updateCount = seckillDao.reduceNumber(seckillId, nowTime);
if (updateCount <= 0) {
//并发量太高,有可能在等行级锁的时候库存没有了,并且秒杀时间问题在前面已经验证。
throw new SeckillCloseException("seckill is closed");
}
else {
//秒杀成功
SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone);
return new SeckillExecution(seckillId, SeckillStateEnums.SUCCESS, successKilled); //枚举
}
}
深度优化(利用存储过程实现事务SQL在MySQL端执行):
关于数据库存储过程的理解与使用,可以参考本人博客:http://blog.csdn.net/qq_33290787/article/details/51892481
参照优化思路二,利用存储过程将秒杀业务核心事务SQL放在MySQL端执行,这样就可以避免事务执行过程中的网络延迟与GC影响,事务行级锁持有时间几乎就是数据库数据操作的时间。大大削弱了事务等待的阻塞效应。
秒杀核心SQL事务存储过程:
DELIMITER //
CREATE PROCEDURE excuteSeckill(IN fadeSeckillId INT,IN fadeUserPhone VARCHAR (15),IN fadeKillTime TIMESTAMP ,OUT fadeResult INT)
BEGIN
DECLARE insertCount INT DEFAULT 0;
START TRANSACTION ;
INSERT IGNORE success_killed(seckill_id,user_phone,state,create_time) VALUES(fadeSeckillId,fadeUserPhone,0,fadeKillTime); --先插入购买明细
SELECT ROW_COUNT() INTO insertCount;
IF(insertCount = 0) THEN
ROLLBACK ;
SET fadeResult = -1; --重复秒杀
ELSEIF(insertCount < 0) THEN
ROLLBACK ;
SET fadeResult = -2; --内部错误
ELSE --已经插入购买明细,接下来要减少库存
UPDATE seckill SET number = number -1 WHERE seckill_id = fadeSeckillId AND start_time < fadeKillTime AND end_time > fadeKillTime AND number > 0;
SELECT ROW_COUNT() INTO insertCount;
IF (insertCount = 0) THEN
ROLLBACK ;
SET fadeResult = 0; --库存没有了,代表秒杀已经关闭
ELSEIF (insertCount < 0) THEN
ROLLBACK ;
SET fadeResult = -2; --内部错误
ELSE
COMMIT ; --秒杀成功,事务提交
SET fadeResult = 1; --秒杀成功返回值为1
END IF;
END IF;
END
//
DELIMITER ;
SET @fadeResult = -3;
CALL excuteSeckill(8,13813813822,NOW(),@fadeResult);
SELECT @fadeResult;
Java客户端(MyBatis)调用数据库存储过程:
首先,在Dao层新建一个接口:void killByProcedure(Map [泛型:String,Object] paramMap); 然后在相应的XML中配置实现(注意:jdbcType没有INT类型的枚举,要使用BIGINT;同样没有VARCHAR的枚举,要使用BIGINT代替。):
<select id="killByProcedure" statementType="CALLABLE">
CALL executeSeckill(
#{ seckillId , jdbcType = BIGINT , mode= IN },
#{ phone ,jdbcType = BIGINT , mode= IN },
#{ killTime , jdbcType = TIMESTAMP , mode= IN },
#{ result , jdbcType = BIGINT , mode= OUT }
)
select>
然后,Service层重新写入一个方法SeckillExecution executeSeckillProcedure(int seckillId, String userPhone, String md5);(注意:在使用MapUtils时要注入commons-collections 3.2依赖)
public SeckillExecution executeSeckillProcedure(int seckillId, String userPhone, String md5) {
if( md5==null || !md5.equals(getMD5(seckillId)) ){
return new SeckillExecution(seckillId,SeckillStateEnums.DATA_REWRITE);
}
Timestamp nowTime = new Timestamp(System.currentTimeMillis());
Map<String,Object> map = new HashMap<String,Object>();
map.put("seckillId",seckillId);
map.put("phone",userPhone);
map.put("killTime",nowTime);
map.put("result", null);
try{
seckillDao.killByProcedure(map);
int result = MapUtils.getInteger(map,"result",-2);
if(result == 1){
SuccessKilled sk = successKilledDao.queryByIdWithSeckill(seckillId,userPhone);
return new SeckillExecution(seckillId,SeckillStateEnums.SUCCESS,sk);
}
else{
return new SeckillExecution(seckillId,SeckillStateEnums.stateOf(result));
}
}
catch (Exception e){
logger.error(e.getMessage(),e);
return new SeckillExecution(seckillId,SeckillStateEnums.INNER_ERROR);
}
}
再者,在web-control层将调用方法改成executeSeckillProcedure,同时因为executeSeckillProcedure已经将重复秒杀,秒杀结束(无库存)合并到返回的SeckillExecution中,所以不用再捕获这两个异常(原本在service层要抛出这两个异常,是为了告诉Spring声明式事务该程序出错要进行事务回滚)
try{
SeckillExecution seckillExecution = seckillService.executeSeckillProcedure(seckillId,phone,md5);
return new SeckillResult(true,seckillExecution);
}
catch (Exception e){
logger.error(e.getMessage(),e);
SeckillExecution seckillExecution = new SeckillExecution(seckillId, SeckillStateEnums.INNER_ERROR);
return new SeckillResult(true,seckillExecution);
}
最后,集成测试web层:
可见秒杀成功,重复秒杀,秒杀结束都正常进行!
测试是否有并发性能提升:
写到这里,最关心的应该是:我思考了那么多东西,修改了一部分项目结构,究竟有没有起到效果呢?下面就来进行测试。
测试方法:将新写的executeSeckillProcedure方法与原本的executeSeckill方法进行多次比较
第一次测试executeSeckill:
11:12:55.602 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
11:12:55.616 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.629 [main] DEBUG o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@60856961] will be managed by Spring
11:12:55.635 [main] DEBUG d.S.insertSuccessKilled - ==> Preparing: INSERT ignore INTO success_killed(seckill_id,user_phone,create_time) VALUE (?,?,?)
11:12:55.664 [main] DEBUG d.S.insertSuccessKilled - ==> Parameters: 8(Integer), 11111111111(String), 2016-07-14 11:12:55.596(Timestamp)
11:12:55.666 [main] DEBUG d.S.insertSuccessKilled - <== Updates: 1
11:12:55.677 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.678 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5] from current transaction
11:12:55.679 [main] DEBUG daoPackage.SeckillDao.reduceNumber - ==> Preparing: UPDATE seckill SET number = number - 1 WHERE seckill_id = ? AND start_time <= ? AND end_time >= ? AND number > 0
11:12:55.680 [main] DEBUG daoPackage.SeckillDao.reduceNumber - ==> Parameters: 8(Integer), 2016-07-14 11:12:55.596(Timestamp), 2016-07-14 11:12:55.596(Timestamp)
11:12:55.682 [main] DEBUG daoPackage.SeckillDao.reduceNumber - <== Updates: 1
11:12:55.682 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.683 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5] from current transaction
11:12:55.685 [main] DEBUG d.S.queryByIdWithSeckill - ==> Preparing: SELECT sk.seckill_id, sk.user_phone, sk.create_time, sk.state, s.seckill_id "seckill.seckill_id", s.name "seckill.name", s.number "seckill.number", s.start_time "seckill.start_time", s.end_time "seckill.end_time", s.create_time "seckill.create_time" FROM success_killed sk INNER JOIN seckill s ON sk.seckill_id = s.seckill_id WHERE sk.seckill_id = ? AND sk.user_phone=?
11:12:55.686 [main] DEBUG d.S.queryByIdWithSeckill - ==> Parameters: 8(Integer), 11111111111(String)
11:12:55.705 [main] DEBUG d.S.queryByIdWithSeckill - <== Total: 1
11:12:55.712 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.713 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Transaction synchronization committing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.714 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.714 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.720 [main] INFO SeckillServiceTest - execution=SeckillExecution{seckillId=8, state=1, stateInfo='Seckill success!', successKilled=SuccessKilled{seckillId=8, userPhone='11111111111', state=0, createTime=Thu Jul 14 11:12:56 GMT+08:00 2016}}
分析:
我们只需要看时间为11:12:55.680到11:12:55.713的部分,因为在11:12:55.680时间点java客户端将UPDATE库存的SQL操作以及参数发送到MySQL服务端,这个时间正是该事务开始拿到行级锁的时间!在11:12:55.713时间点事务被提交,行级锁被释放。这段时间0.033s就是其他并发事务需要等待与阻塞的时间。而其他的时间段(Jdbc连接或发送SQL或发送参数),各个事务是可以并行的。
第一次测试executeSeckillProcedure:
11:22:34.318 [main] DEBUG o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@1800a575] will not be managed by Spring
11:22:34.346 [main] DEBUG daoPackage.SeckillDao.queryById - ==> Preparing: SELECT seckill_id,name,number,start_time,end_time,create_time FROM seckill WHERE seckill_id = ?
11:22:34.385 [main] DEBUG daoPackage.SeckillDao.queryById - ==> Parameters: 8(Integer)
11:22:34.409 [main] DEBUG daoPackage.SeckillDao.queryById - <== Total: 1
11:22:34.417 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@413f69cc]
put into redis
11:22:34.432 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
11:22:34.433 [main] DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@42b02722] was not registered for synchronization because synchronization is not active
11:22:34.434 [main] DEBUG o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@5ce8d869] will not be managed by Spring
11:22:34.435 [main] DEBUG d.SeckillDao.killByProcedure - ==> Preparing: CALL executeSeckill( ?, ?, ?, ? )
11:22:34.457 [main] DEBUG d.SeckillDao.killByProcedure - ==> Parameters: 8(Integer), 11111111112(String), 2016-07-14 11:22:34.432(Timestamp)
11:22:34.463 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@42b02722]
11:22:34.473 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
11:22:34.473 [main] DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@19b93fa8] was not registered for synchronization because synchronization is not active
11:22:34.473 [main] DEBUG o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@40db2a24] will not be managed by Spring
11:22:34.474 [main] DEBUG d.S.queryByIdWithSeckill - ==> Preparing: SELECT sk.seckill_id, sk.user_phone, sk.create_time, sk.state, s.seckill_id "seckill.seckill_id", s.name "seckill.name", s.number "seckill.number", s.start_time "seckill.start_time", s.end_time "seckill.end_time", s.create_time "seckill.create_time" FROM success_killed sk INNER JOIN seckill s ON sk.seckill_id = s.seckill_id WHERE sk.seckill_id = ? AND sk.user_phone=?
11:22:34.474 [main] DEBUG d.S.queryByIdWithSeckill - ==> Parameters: 8(Integer), 11111111112(String)
11:22:34.480 [main] DEBUG d.S.queryByIdWithSeckill - <== Total: 1
11:22:34.480 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@19b93fa8]
11:22:34.481 [main] INFO SeckillServiceTest - Seckill success!
分析:
我们先来看看几个重要的时间点:11:22:34.435是为了请求存储过程;11:22:34.457将存储过程的参数发送至MySQL服务端;11:22:34.463关闭了一个SqlSession,这说明什么?这说明在存储过程的事务已经执行完并有返回结果了,不然不会关掉当前连接数据库的会话。接下来再新建一个SqlSession连接数据库来queryByIdWithSeckill,即获得秒杀成功后的Seckill对象。
所以,真正在数据库中事务的行级锁持有时间(其他事务等待与阻塞的时间),为11:22:34.457到11:22:34.463的时间,竟然只有0.006s!在其他时间段,各个事务的操作是可以并行的。
第2次,第3次及更多的测试与分析如上:
executeSeckill:11:35:36.621到11:35:36.677(0.056s)
executeSeckillProcedure:11:39:42.655到11:39:42.660(0.005s)
executeSeckill:11:42:14.273到11:42:14.319(0.046s)
executeSeckillProcedure:11:44:10.239到11:44:10.246(0.007s)
executeSeckill:11:45:35.829到11:45:35.861(0.032s)
executeSeckillProcedure:11:46:47.685到11:46:47.691(0.006s)
…
通过多组测试可以知道:用存储过程事务的行级锁持有时间大约为0.006s,而Java客户端托管的事务行级锁持有时间大约为0.040s,相差0.034s。如果一个热点商品在同一秒同一毫秒内竞争的人数是500,这样事务排队时间要多出0.034s * 500 = 17s。很不幸运的排在最后的事务要多阻塞17s的时间!这样用户的体验是很不好的。因此,高并发优化对用户体验有举足轻重的作用。