前幾節終於實現了這個高並發秒殺業務,現在問題是如何優化這個業務使其能扛住一定程度的並發量。
一. 優化分析
對於整個業務來說,首先是分析哪些地方會出現高並發,以及哪些地方會影響到了業務的性能。可能會出現高並發的地方:詳情頁,獲取系統時間,地址暴露接口,執行秒殺操作。
這個業務為什么要單獨獲取時間呢?用戶會在詳情頁大量刷新,為了優化這里,將detal.jsp詳情頁和一些靜態資源(css,js等)部署在CDN的節點上(至於這個CDN是什么,下面會說),也就是說用戶訪問詳情頁是不需要訪問我們的系統的,這樣就降低了服務器的負荷,但這個時候就拿不到系統的時間了,所以要單獨做一個請求來獲取當前的系統時間。
那么什么是CDN呢,content distribute network 內容分發網絡,本質上是一種加速用戶獲取數據的系統,把一些用戶頻繁訪問的靜態資源部署在離用戶最近的網絡節點上,關於CDN的具體解釋可以見這篇博文:http://blog.csdn.net/coolmeme/article/details/9468743。對於獲取系統時間這個操作,因為java訪問一次內存大約10ns,所以不需要優化。
對於秒殺地址接口,因為是經常變化的,所以不適合部署在CDN上,要部署在我們服務器的系統上,這里要用到服務器端緩存如:redis緩存服務器來進行優化。
redis緩存服務器:Redis是一個開源的使用ANSI C語言編寫、支持網絡、可基於內存亦可持久化的日志型、Key-Value數據庫,並提供多種語言的API。關於Redis,他是一個內存數據庫,即將硬盤上的部分數據緩存在內存中。對內度的讀取的速度要遠快於對硬盤的讀取,記得我們數據庫老師以前和我們說過,對於數據庫的設計而言,優化的最核心的部分是如何減少對磁盤的IO操作,因為磁盤的IO是其實就是硬盤的磁頭在讀磁片上的磁道,這是個機械運動,速度要遠慢於內存的讀寫。關於redis的一些知識,還要深入學習才行。
對於秒殺操作,這個是整個業務最核心的東西,不可能部署在CDN上,也不能使用redis緩存服務器,因為不可能在緩存中取減庫存,要在mysql中操作,否則會產生數據不一致的情況。老師也說了一些其他的優化方案,不過我聽不懂就是了,什么原子計數器,分布式MQ,消費消息並落地之類的。貌似和分布式系統有關?不明白啊,還得好好去學,先知道有這個東西先。
對於並發程序來說,拖慢速度的關鍵是事務控制,涉及到數據庫中的行級鎖,優化方向是:如何減少行級鎖的持有時間。那么優化思路是:將客戶端邏輯放到MySql服務端,同時避免網絡延遲和GC(垃圾回收)的影響。具體說就是把在客戶端中的事務控制放在MySql服務端。具體方式就是使用存儲過程,使整個事務在到MySql端完成。什么是存儲過程:在大型數據庫系統中,一組為了完成特定功能的SQL 語句集,存儲在數據庫中,經過第一次編譯后再次調用不需要再次編譯,用戶通過指定存儲過程的名字並給出參數(如果該存儲過程帶有參數)來執行它。具體見:存儲過程簡介。
二. 具體優化
1.redis后端緩存優化編碼
redis的下載和安裝,以及如何使用Redis的官方首選Java開發包Jedis:Windows下Redis的安裝使用。
在dao包下新建cache目錄,新建RedisDao類,用於訪問我們的redis。
RedisDao.java
1 package org.seckill.dao.cache; 2 3 import com.dyuproject.protostuff.LinkedBuffer; 4 import com.dyuproject.protostuff.ProtobufIOUtil; 5 import com.dyuproject.protostuff.ProtostuffIOUtil; 6 import com.dyuproject.protostuff.runtime.RuntimeSchema; 7 import org.seckill.entity.Seckill; 8 import org.slf4j.Logger; 9 import org.slf4j.LoggerFactory; 10 import redis.clients.jedis.Jedis; 11 import redis.clients.jedis.JedisPool; 12 13 /** 14 * Created by yuxue on 2016/10/22. 15 */ 16 public class RedisDao { 17 private final Logger logger= LoggerFactory.getLogger(this.getClass()); 18 private final JedisPool jedisPool; 19 20 private RuntimeSchema<Seckill> schema=RuntimeSchema.createFrom(Seckill.class); 21 22 public RedisDao(String ip, int port ){ 23 jedisPool=new JedisPool(ip,port); 24 } 25 26 public Seckill getSeckill(long seckillId) { 27 //redis操作邏輯 28 try{ 29 Jedis jedis=jedisPool.getResource(); 30 try { 31 String key="seckill:"+seckillId; 32 //並沒有實現序列化機制 33 //get->byte[]->反序列化->Object(Seckill) 34 //采用自定義序列化 35 //protostuff : pojo. 36 byte[] bytes=jedis.get(key.getBytes()); 37 //緩存獲取到 38 if(bytes!=null){ 39 //空對象 40 Seckill seckill=schema.newMessage(); 41 ProtostuffIOUtil.mergeFrom(bytes,seckill,schema); 42 //seckill被反序列化 43 return seckill; 44 } 45 }finally { 46 jedis.close(); 47 } 48 }catch (Exception e){ 49 logger.error(e.getMessage(),e); 50 } 51 return null; 52 } 53 54 public String putSeckill(Seckill seckill){ 55 // set Object(Seckill) -> 序列化 ->發送給redis 56 try{ 57 Jedis jedis=jedisPool.getResource(); 58 try{ 59 String key="seckill:"+seckill.getSeckillId(); 60 byte[] bytes=ProtostuffIOUtil.toByteArray(seckill,schema, 61 LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE)); 62 //超時緩存 63 int timeout=60*60;//1小時 64 String result=jedis.setex(key.getBytes(),timeout,bytes); 65 return result; 66 }finally{ 67 jedis.close(); 68 } 69 }catch (Exception e){ 70 logger.error(e.getMessage(),e); 71 } 72 return null; 73 } 74 75 }
這里有個優化點是:redis並沒有實現對象的序列化,需要我們自己手動去序列化對象,當然這里可以讓對象實現Serializable接口,也就是用jdk提供的對象序列化機制。但是這里為了優化這個目的,我們需要一個速度更快得序列化機制,所以老師這里用的是基於谷歌Protobuff的ProtoStuff序列化機制。
ProtoStuff的依賴
<!--protostuff序列化依賴--> <dependency> <groupId>com.dyuproject.protostuff</groupId> <artifactId>protostuff-core</artifactId> <version>1.0.8</version> </dependency> <dependency> <groupId>com.dyuproject.protostuff</groupId> <artifactId>protostuff-runtime</artifactId> <version>1.0.8</version> </dependency>
在spring-dao.xml配置RedisDao
1 <!--RedisDao--> 2 <bean id="redisDao" class="org.seckill.dao.cache.RedisDao"> 3 <constructor-arg index="0" value="localhost"/> 4 <constructor-arg index="1" value="6379"/> 5 </bean>
修改SeckillServiceImpl.java為
1 package org.seckill.service.impl; 2 3 import org.apache.commons.collections.MapUtils; 4 import org.seckill.dao.SeckillDao; 5 import org.seckill.dao.SuccesskilledDao; 6 import org.seckill.dao.cache.RedisDao; 7 import org.seckill.dto.Exposer; 8 import org.seckill.dto.SeckillExecution; 9 import org.seckill.entity.Seckill; 10 import org.seckill.entity.SuccessKilled; 11 import org.seckill.enums.SeckillStatEnum; 12 import org.seckill.exception.RepeatKillException; 13 import org.seckill.exception.SeckillCloseException; 14 import org.seckill.exception.SeckillException; 15 import org.seckill.service.SeckillService; 16 import org.slf4j.Logger; 17 import org.slf4j.LoggerFactory; 18 import org.springframework.beans.factory.annotation.Autowired; 19 import org.springframework.stereotype.Service; 20 import org.springframework.transaction.annotation.Transactional; 21 import org.springframework.util.DigestUtils; 22 23 import java.util.Date; 24 import java.util.HashMap; 25 import java.util.List; 26 import java.util.Map; 27 28 /** 29 * Created by yuxue on 2016/10/15. 30 */ 31 @Service 32 public class SeckillServiceImpl implements SeckillService { 33 private Logger logger = LoggerFactory.getLogger(this.getClass()); 34 35 @Autowired 36 private SeckillDao seckillDao; 37 38 @Autowired 39 private SuccesskilledDao successkilledDao; 40 41 @Autowired 42 private RedisDao redisDao; 43 44 //md5鹽值字符串,用於混淆MD5 45 private final String salt = "fsladfjsdklf2jh34orth43hth43lth3"; 46 47 public List<Seckill> getSeckillList() { 48 return seckillDao.queryAll(0, 4); 49 } 50 51 public Seckill getById(long seckillId) { 52 return seckillDao.queryById(seckillId); 53 } 54 55 public Exposer exportSeckillUrl(long seckillId) { 56 //優化點:緩存優化,超時的基礎上維護一致性 57 //1.訪問redis 58 Seckill seckill = redisDao.getSeckill(seckillId); 59 if (seckill == null) { 60 //2.若緩存中沒有則訪問數據庫 61 seckill = seckillDao.queryById(seckillId); 62 if (seckill == null) { 63 return new Exposer(false, seckillId); 64 } else { 65 //3.放入redis 66 redisDao.putSeckill(seckill); 67 } 68 } 69 Date startTime = seckill.getStartTime(); 70 Date endTime = seckill.getEndTime(); 71 Date nowTime = new Date(); 72 if (nowTime.getTime() < startTime.getTime() || 73 nowTime.getTime() > endTime.getTime()) { 74 return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime()); 75 } 76 //轉化特定字符串的過程,不可逆 77 String md5 = getMD5(seckillId); 78 return new Exposer(true, md5, seckillId); 79 } 80 81 private String getMD5(long seckillId) { 82 String base = seckillId + "/" + salt; 83 String md5 = DigestUtils.md5DigestAsHex(base.getBytes()); 84 return md5; 85 } 86 87 @Transactional 88 /* 89 * 使用注解控制事務方法的優點: 90 * 1:開發團隊一致的約定 91 * 2:保證事務方法的執行時間經可能的短,不要穿插其他網絡操作RPC/HTTP請求或者剝離到事務方法外部,使得 92 * 這個事務方法是個比較干凈的對數據庫的操作 93 * 3:不是所有的方法都需要事務,如只有一條修改操作,只讀操作不需要事務控制 94 * */ 95 public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException, 96 RepeatKillException, SeckillCloseException { 97 if (md5 == null || !md5.equals(getMD5(seckillId))) { 98 throw new SeckillException("seckill data rewrite"); 99 } 100 //執行秒殺邏輯:減庫存+記錄購買行為 101 Date nowTime = new Date(); 102 103 try { 104 //記錄購買行為 105 int insertCount = successkilledDao.insertSucessSeckilled(seckillId, userPhone); 106 //唯一:seckillId,userPhone 107 if (insertCount <= 0) { 108 //重復秒殺 109 throw new RepeatKillException("seckill repeated"); 110 } else { 111 //減庫存,熱點商品競爭 112 int updateCount = seckillDao.reduceNumber(seckillId, nowTime); 113 if (updateCount <= 0) { 114 //沒有更新到記錄,秒殺結束,rollback 115 throw new SeckillCloseException("seckill is close"); 116 } else { 117 //秒殺成功,commit 118 SuccessKilled successKilled = successkilledDao.queryByIdWithSeckill(seckillId, userPhone); 119 return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, successKilled); 120 } 121 } 122 } catch (SeckillCloseException e1) { 123 throw e1; 124 } catch (RepeatKillException e2) { 125 throw e2; 126 } catch (Exception e) { 127 logger.error(e.getMessage()); 128 throw new SeckillException("seckill inner error" + e.getMessage()); 129 } 130 } 131 132 public SeckillExecution executeSeckillProdure(long seckillId, long userPhone, String md5){ 133 if (md5 == null || !md5.equals(getMD5(seckillId))) { 134 return new SeckillExecution(seckillId, SeckillStatEnum.DATA_REWRITE); 135 } 136 Date killTime=new Date(); 137 Map<String,Object> map=new HashMap<String, Object>(); 138 map.put("seckillId",seckillId); 139 map.put("phone",userPhone); 140 map.put("killTime",killTime); 141 map.put("result",null); 142 try{ 143 seckillDao.killByProcedure(map); 144 //獲取result 145 int result= MapUtils.getInteger(map,"result",-2); 146 if(result==1){ 147 SuccessKilled sk=successkilledDao. 148 queryByIdWithSeckill(seckillId,userPhone); 149 return new SeckillExecution(seckillId,SeckillStatEnum.SUCCESS,sk); 150 }else{ 151 return new SeckillExecution(seckillId,SeckillStatEnum.stateOf(result)); 152 } 153 }catch (Exception e){ 154 logger.error(e.getMessage(),e); 155 return new SeckillExecution(seckillId,SeckillStatEnum.INNER_ERROR); 156 } 157 } 158 }
對於暴露秒殺接口exportSeckillUrl這個方法,原本是直接從數據庫中取Seckill對象的,現在優化為先在Redis緩存服務器中取,如果沒有則去數據庫中取,並將其放入Redis緩存中。這里還有個優化點就是在執行秒殺executeSeckill方法中
將insert操作放到了update之前。
2. 利用存儲過程
對於現在的update操作,還是在客戶端控制事務的,為了進一步優化,現在將update的操作邏輯放在Mysql端來執行,也就是利用存儲過程來完成商品更新操作,減少行級鎖的持有時間。
在src/main/sql目錄下新建seckill.sql, 編寫存儲過程
1 -- 秒殺執行存儲過程 2 DELIMITER $$ -- console ; 轉換為 $$ 3 -- 定義存儲過程 4 -- 參數:in 輸入參數;out 輸出參數 5 -- row_count():返回上一條修改類型sql(delete, insert,update)的影響行數 6 -- row_count: 0:未修改;>0:表示修改的行數;<0:sql錯誤/未執行 7 CREATE PROCEDURE `seckill`.`execute_seckill` 8 (in v_seckill_id bigint, in v_phone bigint, 9 in v_kill_time timestamp,out r_result int) 10 BEGIN 11 DECLARE insert_count int DEFAULT 0; 12 START TRANSACTION ; 13 insert ignore into seccess_killed 14 (seckill_id,user_phone,create_time) 15 values (v_seckill_id,v_phone,v_kill_time); 16 select row_count() into insert_count; 17 IF (insert_count=0) THEN 18 ROLLBACK ; 19 set r_result=-1; 20 ELSEIF (insert_count<0) THEN 21 ROLLBACK ; 22 set r_result=-2; 23 ELSE 24 update seckill 25 set number=number-1 26 where seckill_id=v_seckill_id 27 and end_time>v_kill_time 28 and start_time<v_kill_time 29 and number>0; 30 select row_count() into insert_count; 31 IF (insert_count=0) THEN 32 ROLLBACK ; 33 set r_result=0; 34 ELSEIF (insert_count<0) THEN 35 ROLLBACK ; 36 set r_result=-2; 37 ELSE 38 COMMIT; 39 set r_result=1; 40 END IF; 41 END IF; 42 END; 43 $$ 44 -- 存儲過程定義結束 45 46 DELIMITER ; 47 -- 48 set @r_result=-3; 49 -- 執行存儲過程 50 call execute_seckill(1004,13225534035,now(),@r_result); 51 -- 獲取結果 52 select @r_result; 53 54 -- 存儲過程 55 -- 1:存儲過程優化:事務行級鎖持有時間 56 -- 2:不要過度依賴存儲過程 57 -- 3:簡單的邏輯可以應用存儲過程 58 -- 4:QPS:一個秒殺單6000/qps
在Service層和dao層分別定義調用存儲過程的接口,然后在Mybatis中配置調用存儲過程
1 package org.seckill.service; 2 3 import org.seckill.dto.Exposer; 4 import org.seckill.dto.SeckillExecution; 5 import org.seckill.entity.Seckill; 6 import org.seckill.exception.RepeatKillException; 7 import org.seckill.exception.SeckillCloseException; 8 import org.seckill.exception.SeckillException; 9 10 import java.util.List; 11 12 /** 13 * 業務接口:站在"使用者"角度設計接口 14 * 三個方面:方法定一粒度,參數,返回類型/異常 15 * Created by yuxue on 2016/10/15. 16 */ 17 public interface SeckillService { 18 19 /** 20 * 查詢所有秒殺記錄 21 * @return 22 */ 23 List<Seckill> getSeckillList( ); 24 25 /** 26 * 查詢單個秒殺記錄 27 * @param seckillId 28 * @return 29 */ 30 Seckill getById(long seckillId); 31 32 /** 33 * 秒殺開啟時輸出秒殺接口地址 34 * 否則輸出系統時間和秒殺時間 35 * @param seckillId 36 */ 37 Exposer exportSeckillUrl(long seckillId); 38 39 /** 40 * 執行秒殺操作 41 * @param seckillId 42 * @param userPhone 43 * @param md5 44 */ 45 SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) 46 throws SeckillException,RepeatKillException,SeckillCloseException; 47 48 /** 49 * 執行秒殺操作by 存儲過程 50 * @param seckillId 51 * @param userPhone 52 * @param md5 53 */ 54 SeckillExecution executeSeckillProdure(long seckillId, long userPhone, String md5); 55 }
1 package org.seckill.dao; 2 3 import org.apache.ibatis.annotations.Param; 4 import org.seckill.entity.Seckill; 5 6 import java.util.Date; 7 import java.util.List; 8 import java.util.Map; 9 10 /** 11 * Created by yuxue on 2016/10/12. 12 */ 13 public interface SeckillDao { 14 15 /** 16 * 減庫存 17 * @param seckillId 18 * @param killTime 19 * @return 如果影響的行數大於1,表示更新記錄行數 20 */ 21 int reduceNumber(@Param("seckillId") long seckillId,@Param("killTime") Date killTime); 22 23 /** 24 * 根據id查詢秒殺對象 25 * @param seckillId 26 * @return 27 */ 28 Seckill queryById(long seckillId); 29 30 /** 31 * 根據偏移量查詢秒殺商品列表 32 * @param offset 33 * @param limit 34 * @return 35 */ 36 List<Seckill> queryAll(@Param("offset") int offset, @Param("limit") int limit); 37 38 /** 39 * 使用存儲過程執行秒殺 40 * @param paramMap 41 */ 42 void killByProcedure(Map<String,Object> paramMap); 43 }
下面的要點便是如何在Mybatis中配置killByProcedure這個接口,存儲過程的調用本質上是Mybatis在調用它,那么就得配置配置才行
1 <?xml version="1.0" encoding="UTF-8" ?> 2 <!DOCTYPE mapper 3 PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 4 "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> 5 6 <mapper namespace="org.seckill.dao.SeckillDao"> 7 8 9 <select id="queryById" resultType="Seckill" parameterType="long"> 10 select seckill_id,name,number,start_time, end_time, create_time 11 from seckill 12 where seckill_id=#{seckillId} 13 </select> 14 15 <update id="reduceNumber"> 16 update 17 seckill 18 set 19 number=number -1 20 where seckill_id = #{seckillId} 21 and start_time <![CDATA[ <= ]]> #{killTime} 22 and end_time >= #{killTime} 23 and number > 0; 24 </update> 25 26 <select id="queryAll" resultType="Seckill"> 27 select seckill_id,name,number,start_time,end_time,create_time 28 from seckill 29 order by create_time DESC 30 limit #{offset},#{limit} 31 </select> 32 <!--mybatis調用存儲過程--> 33 <select id="killByProcedure" statementType="CALLABLE"> 34 call execute_seckill( 35 #{seckillId,jdbcType=BIGINT,mode=IN}, 36 #{phone,jdbcType=BIGINT,mode=IN}, 37 #{killTime,jdbcType=TIMESTAMP,mode=IN}, 38 #{result,jdbcType=INTEGER ,mode=OUT} 39 ) 40 </select> 41 42 </mapper>
這里要記住配置這個存儲過程的一些參數。
<!--mybatis調用存儲過程--> 33 <select id="killByProcedure" statementType="CALLABLE"> 34 call execute_seckill( 35 #{seckillId,jdbcType=BIGINT,mode=IN}, 36 #{phone,jdbcType=BIGINT,mode=IN}, 37 #{killTime,jdbcType=TIMESTAMP,mode=IN}, 38 #{result,jdbcType=INTEGER ,mode=OUT} 39 ) 40 </select>
SeckillServiceImpl里調用存儲過程來執行秒殺的方法executeSeckillProdure具體實現如下:
1 public SeckillExecution executeSeckillProdure(long seckillId, long userPhone, String md5){ 2 if (md5 == null || !md5.equals(getMD5(seckillId))) { 3 return new SeckillExecution(seckillId, SeckillStatEnum.DATA_REWRITE); 4 } 5 Date killTime=new Date(); 6 Map<String,Object> map=new HashMap<String, Object>(); 7 map.put("seckillId",seckillId); 8 map.put("phone",userPhone); 9 map.put("killTime",killTime); 10 map.put("result",null); 11 try{ 12 seckillDao.killByProcedure(map); 13 //獲取result 14 int result= MapUtils.getInteger(map,"result",-2); 15 if(result==1){ 16 SuccessKilled sk=successkilledDao. 17 queryByIdWithSeckill(seckillId,userPhone); 18 return new SeckillExecution(seckillId,SeckillStatEnum.SUCCESS,sk); 19 }else{ 20 return new SeckillExecution(seckillId,SeckillStatEnum.stateOf(result)); 21 } 22 }catch (Exception e){ 23 logger.error(e.getMessage(),e); 24 return new SeckillExecution(seckillId,SeckillStatEnum.INNER_ERROR); 25 } 26 }
至此,優化篇就寫完了,寫得略微粗糙了點,好多細節都沒有具體分析,主要是快考試了,也沒什么時間寫博客了,忙死,估計這篇寫完后就好好復習准備期末考試了吧。至此,終於寫完了整個項目的過程,伴隨着自己的一些理解,以后還要修改修改。結尾老師總結了下這個課程涉及到的知識點又介紹了下一般網站的系統部署架構,什么Nginx,Jetty,rabbitmq之類的。。。不得不感嘆技術世界真是深似海,越學就會覺得自己不會的東西越多,不管怎樣,慢慢來吧,在技術的道路上前進着!!!