--------------------------------------------------------------------------------------------javascript
很久沒寫博客了,由於一直在忙項目和其餘工做中的事情,最近有空,恰好看到了一個秒殺系統的設計,感受仍是很是不錯的一個系統,因而在這裏分享一下。html
秒殺場景主要兩個點:
1:流控系統,防止後端過載或沒必要要流量進入,由於慕課要求課程的長度和簡單性,沒有加。
2:減庫存競爭,減庫存的update必然涉及exclusive lock ,持有鎖的時間越短,併發性越高。
前端
對於搶購系統來講,首先要有可搶購的活動,並且這些活動具備促銷性質,好比直降500元。其次要求可搶購的活動類目豐富,用戶纔有充分的選擇性。立刻就雙十一了,用戶剁手期間增量促銷活動量很是多,可能某個活動力度特別大,大多用戶都在搶,必然對系統是一個考驗。這樣搶購系統具備秒殺特性,併發訪問量高,同時用戶也可選購多個限時搶商品,與普通商品一塊兒進購物車結算。這種大型活動的負載多是平時的幾十倍,因此經過增長硬件、優化瓶頸代碼等手段是很難達到目標的,因此搶購系統得專門設計。
java
在這裏以秒殺單個功能點爲例,以ssm框架+mysql+redis等技術來講明。node
使用mysql數據庫:這裏主要是兩個表,主要是一個商品表和一個購買明細表,在這裏用戶的購買信息的登陸註冊這裏就不作了,用戶購買時須要使用手機號碼來進行秒殺操做,購買成功使用的是商品表id和購買明細的用戶手機號碼作爲雙主鍵。mysql
CREATE DATABASE seckill; USE seckill; CREATE TABLE seckill( seckill_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '商品庫存id', `name` VARCHAR(120) NOT NULL COMMENT '商品名稱', number INT NOT NULL COMMENT '庫存數量', start_time TIMESTAMP NOT NULL COMMENT '秒殺開啓時間', end_time TIMESTAMP NOT NULL COMMENT '秒殺結束時間', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間', PRIMARY KEY (seckill_id), KEY idx_start_time(start_time), KEY idx_end_time(end_time), KEY idx_create_time(create_time) )ENGINE=INNODB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8 COMMENT '秒殺庫存表' --初始化數據 INSERT INTO seckill(NAME,number,start_time,end_time) VALUES ('4000元秒殺ipone7',300,'2016-11-5 00:00:00','2016-11-6 00:00:00'), ('3000元秒殺ipone6',200,'2016-11-5 00:00:00','2016-11-6 00:00:00'), ('2000元秒殺ipone5',100,'2016-11-5 00:00:00','2016-11-6 00:00:00'), ('1000元秒殺小米5',100,'2016-11-5 00:00:00','2016-11-6 00:00:00'); --秒殺成功明細表 --用戶登陸認證相關的信息 CREATE TABLE success_kill( seckill_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '秒殺商品id', user_phone BIGINT NOT NULL COMMENT '用戶手機號', state TINYINT NOT NULL DEFAULT-1 COMMENT '狀態標識,-1無效,0成功,1已付款', create_time TIMESTAMP NOT NULL COMMENT '建立時間', PRIMARY KEY(seckill_id,user_phone), KEY idx_create_time(create_time) )ENGINE=INNODB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8 COMMENT '秒殺成功明細表' SELECT * FROM seckill; SELECT * FROM success_kill;
DELIMITER $$ CREATE PROCEDURE seckill.execute_seckill (IN v_seckill_id BIGINT, IN v_phone BIGINT, IN v_kill_time TIMESTAMP, OUT r_result INT) BEGIN DECLARE insert_count INT DEFAULT 0; START TRANSACTION; INSERT IGNORE INTO success_kill(seckill_id,user_phone,create_time,state) VALUE(v_seckill_id,v_phone,v_kill_time,0); SELECT ROW_COUNT() INTO insert_count; IF(insert_count = 0) THEN ROLLBACK; SET r_result = -1; ELSEIF(insert_count < 0) THEN ROLLBACK; SET r_result = -2; ELSE UPDATE seckill SET number = number - 1 WHERE seckill_id = v_seckill_id AND end_time > v_kill_time AND start_time < v_kill_time AND number > 0; SELECT ROW_COUNT() INTO insert_count; IF(insert_count = 0) THEN ROLLBACK; SET r_result = 0; ELSEIF (insert_count < 0) THEN ROLLBACK; SET r_result = -2; ELSE COMMIT; SET r_result = 1; END IF; END IF; END; $$ DELIMITER ; SET @r_result = -3; CALL execute_seckill(1000,13813813822,NOW(),@r_result); SELECT @r_result;
由於咱們有兩個表,因此天然建兩個實體bean啦!新建一個Seckill.javagit
private long seckillId; private String name; private int number; private Date startTime; private Date endTime; private Date createTime;
再新建一個SuccessKill。github
private long seckillId; private long userPhone; private short state; private Date createTime; private Seckill seckill;
接口咱們也是來兩個:SeckillDao.java和SuccessKillDao.javaajax
內容分別爲:redis
public interface SeckillDao { //減庫存 int reduceNumber(@Param("seckillId")long seckillId,@Param("killTime")Date killTime); Seckill queryById(long seckilled); List<Seckill> queryAll(@Param("offset") int offset,@Param("limit") int limit); public void seckillByProcedure(Map<String, Object> paramMap); }
public interface SuccessKillDao { /** * 插入購買明細 * * @param seckillId * @param userPhone * @return */ int insertSuccessKill(@Param("seckillId")long seckillId,@Param("userPhone")long userPhone); /** * 根據id查詢 * * @param seckill * @return */ SuccessKill queryByIdWithSeckill(@Param("seckillId")long seckillId,@Param("userPhone")long userPhone); }
在mybatis中對上面的接口進行實現,這裏能夠經過mybatis來實現。
<mapper namespace="cn.tf.seckill.dao.SeckillDao"> <update id="reduceNumber" > update seckill set number=number-1 where seckill_id=#{seckillId} and start_time <![CDATA[<=]]>#{killTime} and end_time>=#{killTime} and number >0 </update> <select id="queryById" resultType="Seckill" parameterType="long"> select seckill_id,name,number,start_time,end_time,create_time from seckill where seckill_id =#{seckillId} </select> <select id="queryAll" resultType="Seckill"> select seckill_id,name,number,start_time,end_time,create_time from seckill order by create_time desc limit #{offset},#{limit} </select> <select id="seckillByProcedure" statementType="CALLABLE"> call execute_seckill( #{seckillId,jdbcType=BIGINT,mode=IN}, #{phone,jdbcType=BIGINT,mode=IN}, #{killTime,jdbcType=TIMESTAMP,mode=IN}, #{result,jdbcType=INTEGER,mode=OUT} ) </select> </mapper>
<mapper namespace="cn.tf.seckill.dao.SuccessKillDao"> <insert id="insertSuccessKill"> insert ignore into success_kill(seckill_id,user_phone,state) values (#{seckillId},#{userPhone},0) </insert> <select id="queryByIdWithSeckill" resultType="SuccessKill"> 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_kill sk inner join seckill s on sk.seckill_id=s.seckill_id where sk.seckill_id=#{seckillId} and sk.user_phone=#{userPhone} </select> </mapper>
在這裏咱們說的庫存不是真正意義上的庫存,實際上是該促銷能夠搶購的數量,真正的庫存在基礎庫存服務。用戶點擊『提交訂單』按鈕後,在搶購系統中獲取了資格後纔去基礎庫存服務中扣減真正的庫存;而搶購系統控制的就是資格/剩餘數。傳統方案利用數據庫行鎖,可是在促銷高峯數據庫壓力過大致使服務不可用,目前採用redis集羣(16分片)緩存促銷信息,例如促銷id、促銷剩餘數、搶次數等,搶的過程當中按照促銷id散列到對應分片,實時扣減剩餘數。當剩餘數爲0或促銷刪除,價格恢復原價。
這裏使用的是redis來進行處理。這裏使用的是序列化工具RuntimeSchema。
在pom.xml中配置以下:
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>2.7.2</version> </dependency> <dependency> <groupId>com.dyuproject.protostuff</groupId> <artifactId>protostuff-api</artifactId> <version>1.0.8</version> </dependency> <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>
public class RedisDao { private Logger logger = LoggerFactory.getLogger(this.getClass()); private JedisPool jedisPool; private int port; private String ip; public RedisDao(String ip, int port) { this.port = port; this.ip = ip; } //Serialize function private RuntimeSchema<Seckill> schema = RuntimeSchema.createFrom(Seckill.class); public Seckill getSeckill(long seckillId) { jedisPool = new JedisPool(ip, port); //redis operate try { Jedis jedis = jedisPool.getResource(); try { String key = "seckill:" + seckillId; //因爲redis內部沒有實現序列化方法,並且jdk自帶的implaments Serializable比較慢,會影響併發,所以須要使用第三方序列化方法. byte[] bytes = jedis.get(key.getBytes()); if(null != bytes){ Seckill seckill = schema.newMessage(); ProtostuffIOUtil.mergeFrom(bytes,seckill,schema); //reSerialize return seckill; } } finally { jedisPool.close(); } } catch (Exception e) { logger.error(e.getMessage(),e); } return null; } public String putSeckill(Seckill seckill) { jedisPool = new JedisPool(ip, port); //set Object(seckill) ->Serialize -> byte[] try{ Jedis jedis = jedisPool.getResource(); try{ String key = "seckill:"+seckill.getSeckillId(); byte[] bytes = ProtostuffIOUtil.toByteArray(seckill, schema, LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE)); //time out cache int timeout = 60*60; String result = jedis.setex(key.getBytes(),timeout,bytes); return result; }finally { jedisPool.close(); } }catch (Exception e){ logger.error(e.getMessage(),e); } return null; } }
<bean id="redisDao" class="cn.tf.seckill.dao.cache.RedisDao"> <constructor-arg index="0" value="115.28.16.234"></constructor-arg> <constructor-arg index="1" value="6379"></constructor-arg> </bean>
接下來就是service的處理了。這裏主要是由兩個重要的業務接口。
一、暴露秒殺 和 執行秒殺 是兩個不一樣業務,互不影響 二、暴露秒殺 的邏輯可能會有更多變化,如今是時間上達到要求才能暴露,說不定下次加個別的條件才能暴露,基於業務耦合度考慮,分開比較好。三、從新更改暴露秒殺接口業務時,不會去影響執行秒殺接口,對於測試都是有好處的。。。另外 很差的地方是前端須要調用兩個接口才能執行秒殺。
//從使用者角度設計接口,方法定義粒度,參數,返回類型 public interface SeckillService { List<Seckill> getSeckillList(); Seckill getById(long seckillId); //輸出秒殺開啓接口地址 Exposer exportSeckillUrl(long seckillId); /** * 執行描述操做 * * @param seckillId * @param userPhone * @param md5 */ SeckillExecution executeSeckill(long seckillId,long userPhone,String md5) throws SeckillCloseException,RepeatKillException,SeckillException; /** * 經過存儲過程執行秒殺 * @param seckillId * @param userPhone * @param md5 */ SeckillExecution executeSeckillByProcedure(long seckillId, long userPhone, String md5); }
實現的過程就比較複雜了,這裏加入了前面所說的存儲過程還有redis緩存。這裏作了一些異常的處理,以及數據字典的處理。
@Service public class SeckillServiceImpl implements SeckillService{ private Logger logger=LoggerFactory.getLogger(this.getClass()); @Autowired private SeckillDao seckillDao; @Autowired private SuccessKillDao successKillDao; @Autowired private RedisDao redisDao; //加鹽處理 private final String slat="xvzbnxsd^&&*)(*()kfmv4165323DGHSBJ"; public List<Seckill> getSeckillList() { return seckillDao.queryAll(0, 4); } public Seckill getById(long seckillId) { return seckillDao.queryById(seckillId); } public Exposer exportSeckillUrl(long seckillId) { //優化點:緩存優化 Seckill seckill = redisDao.getSeckill(seckillId); if (seckill == null) { //訪問數據庫 seckill = seckillDao.queryById(seckillId); if (seckill == null) { return new Exposer(false, seckillId); } else { //放入redis redisDao.putSeckill(seckill); } } Date startTime = seckill.getStartTime(); Date endTime = seckill.getEndTime(); //當前系統時間 Date nowTime = new Date(); if (nowTime.getTime() < startTime.getTime() || nowTime.getTime() > endTime.getTime()) { return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime()); } //轉換特定字符串的過程,不可逆 String md5 = getMD5(seckillId); return new Exposer(true, md5, seckillId); } @Transactional public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException, RepeatKillException, SeckillCloseException { if (md5 == null || (!md5.equals(getMD5(seckillId)))) { throw new SeckillException("Seckill data rewrite"); } //執行秒殺邏輯:減庫存,記錄購買行爲 Date nowTime = new Date(); try { //記錄購買行爲 int insertCount = successKillDao.insertSuccessKill(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 { //秒殺成功 SuccessKill successKilled = successKillDao.queryByIdWithSeckill(seckillId, userPhone); return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, successKilled); } } } catch (SeckillCloseException e1) { throw e1; } catch (RepeatKillException e2) { throw e2; } catch (Exception e) { logger.error(e.getMessage()); //全部編譯期異常轉換爲運行時異常 throw new SeckillException("Seckill inner error" + e.getMessage()); } } /** * @param seckillId * @param userPhone * @param md5 * @return * @throws SeckillException * @throws RepeteKillException * @throws SeckillCloseException */ public SeckillExecution executeSeckillByProcedure(long seckillId, long userPhone, String md5) { if (md5 == null || (!md5.equals(getMD5(seckillId)))) { throw new SeckillException("Seckill data rewrite"); } Date killTime = new Date(); Map<String, Object> map = new HashMap<String, Object>(); map.put("seckillId", seckillId); map.put("phone", userPhone); map.put("killTime", killTime); map.put("result", null); //執行存儲過程,result被賦值 try { seckillDao.seckillByProcedure(map); //獲取result int result = MapUtils.getInteger(map, "result", -2); if (result == 1) { SuccessKill successKilled = successKillDao.queryByIdWithSeckill(seckillId, userPhone); return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, successKilled); } else { return new SeckillExecution(seckillId, SeckillStatEnum.stateOf(result)); } } catch (Exception e) { logger.error(e.getMessage(), e); return new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR); } } private String getMD5(long seckillId) { String base = seckillId + "/" + slat; String md5 = DigestUtils.md5DigestAsHex(base.getBytes()); return md5; } }
在springMVC中,是基於restful風格來對訪問地址進行處理,因此咱們在控制層也這樣進行處理。
@Controller @RequestMapping("/seckill") public class SeckillController { private final Logger logger=LoggerFactory.getLogger(this.getClass()); @Autowired private SeckillService seckillService; @RequestMapping(value="/list",method=RequestMethod.GET) public String list(Model model){ List<Seckill> list = seckillService.getSeckillList(); model.addAttribute("list",list); return "list"; } @RequestMapping(value = "/{seckillId}/detail", method = RequestMethod.GET) public String detail(@PathVariable("seckillId") Long seckillId, Model model){ if(seckillId == null){ return "redirect:/seckill/list"; } Seckill seckill = seckillService.getById(seckillId); if(seckill == null){ return "redirect:/seckill/list"; } model.addAttribute("seckill", seckill); return "detail"; } @RequestMapping(value = "/{seckillId}/exposer", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"}) @ResponseBody public SeckillResult<Exposer> exposer(@PathVariable("seckillId") Long seckillId){ SeckillResult<Exposer> result; try { Exposer exposer = seckillService.exportSeckillUrl(seckillId); result = new SeckillResult<Exposer>(true,exposer); } catch (Exception e) { result = new SeckillResult<Exposer>(false, e.getMessage()); } return result; } @RequestMapping(value = "/{seckillId}/{md5}/execution", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"}) @ResponseBody public SeckillResult<SeckillExecution> execute(@PathVariable("seckillId")Long seckillId, @PathVariable("md5")String md5, @CookieValue(value = "killPhone", required = false)Long phone){ if(phone == null){ return new SeckillResult<>(false, "未註冊"); } try { SeckillExecution execution = seckillService.executeSeckillByProcedure(seckillId, phone, md5); return new SeckillResult<SeckillExecution>(true, execution); } catch (SeckillCloseException e) { SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.REPEAT_KILL); return new SeckillResult<SeckillExecution>(false, execution); } catch (RepeatKillException e) { SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.END); return new SeckillResult<SeckillExecution>(false, execution); } catch (Exception e) { logger.error(e.getMessage(), e); SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR); return new SeckillResult<SeckillExecution>(false, execution); } } @RequestMapping(value = "/time/now", method = RequestMethod.GET) @ResponseBody public SeckillResult<Long> time(){ Date now = new Date(); return new SeckillResult<>(true, now.getTime()); } }
後臺數據處理完以後就是前臺了,對於頁面什麼的就直接使用bootstrap來處理了,直接調用bootstrap的cdn連接地址。
頁面的代碼我就不貼出來了,能夠到源碼中進行查看,都是很是經典的幾個頁面。值得一提的是這個js的分模塊處理。
//存放主要交互邏輯的js代碼 // javascript 模塊化(package.類.方法) var seckill = { //封裝秒殺相關ajax的url URL: { now: function () { return '/SecKill/seckill/time/now'; }, exposer: function (seckillId) { return '/SecKill/seckill/' + seckillId + '/exposer'; }, execution: function (seckillId, md5) { return '/SecKill/seckill/' + seckillId + '/' + md5 + '/execution'; } }, //驗證手機號 validatePhone: function (phone) { if (phone && phone.length == 11 && !isNaN(phone)) { return true;//直接判斷對象會看對象是否爲空,空就是undefine就是false; isNaN 非數字返回true } else { return false; } }, //詳情頁秒殺邏輯 detail: { //詳情頁初始化 init: function (params) { //手機驗證和登陸,計時交互 //規劃咱們的交互流程 //在cookie中查找手機號 var killPhone = $.cookie('killPhone'); //驗證手機號 if (!seckill.validatePhone(killPhone)) { //綁定手機 控制輸出 var killPhoneModal = $('#killPhoneModal'); killPhoneModal.modal({ show: true,//顯示彈出層 backdrop: 'static',//禁止位置關閉 keyboard: false//關閉鍵盤事件 }); $('#killPhoneBtn').click(function () { var inputPhone = $('#killPhoneKey').val(); console.log("inputPhone: " + inputPhone); if (seckill.validatePhone(inputPhone)) { //電話寫入cookie(7天過時) $.cookie('killPhone', inputPhone, {expires: 7, path: '/SecKill'}); //驗證經過 刷新頁面 window.location.reload(); } else { //todo 錯誤文案信息抽取到前端字典裏 $('#killPhoneMessage').hide().html('<label class="label label-danger">手機號錯誤!</label>').show(300); } }); } //已經登陸 //計時交互 var startTime = params['startTime']; var endTime = params['endTime']; var seckillId = params['seckillId']; $.get(seckill.URL.now(), {}, function (result) { if (result && result['success']) { var nowTime = result['data']; //解決計時偏差 var userNowTime = new Date().getTime(); console.log('nowTime:' + nowTime); console.log('userNowTime:' + userNowTime); //計算用戶時間和系統時間的差,忽略中間網絡傳輸的時間(本機測試大約爲50-150毫秒) var deviationTime = userNowTime - nowTime; console.log('deviationTime:' + deviationTime); //考慮到用戶時間可能和服務器時間不一致,開始秒殺時間須要加上時間差 startTime = startTime + deviationTime; // //時間判斷 計時交互 seckill.countDown(seckillId, nowTime, startTime, endTime); } else { console.log('result: ' + result); alert('result: ' + result); } }); } }, handlerSeckill: function (seckillId, node) { //獲取秒殺地址,控制顯示器,執行秒殺 node.hide().html('<button class="btn btn-primary btn-lg" id="killBtn">開始秒殺</button>'); $.post(seckill.URL.exposer(seckillId), {}, function (result) { //在回調函數種執行交互流程 if (result && result['success']) { var exposer = result['data']; if (exposer['exposed']) { //開啓秒殺 //獲取秒殺地址 var md5 = exposer['md5']; var killUrl = seckill.URL.execution(seckillId, md5); console.log("killUrl: " + killUrl); //綁定一次點擊事件 $('#killBtn').one('click', function () { //執行秒殺請求 //1.先禁用按鈕 $(this).addClass('disabled');//,<-$(this)===('#killBtn')-> //2.發送秒殺請求執行秒殺 $.post(killUrl, {}, function (result) { if (result && result['success']) { var killResult = result['data']; var state = killResult['state']; var stateInfo = killResult['stateInfo']; //顯示秒殺結果 node.html('<span class="label label-success">' + stateInfo + '</span>'); } }); }); node.show(); } else { //未開啓秒殺(因爲瀏覽器計時誤差,覺得時間到了,結果時間並沒到,須要從新計時) var now = exposer['now']; var start = exposer['start']; var end = exposer['end']; var userNowTime = new Date().getTime(); var deviationTime = userNowTime - nowTime; start = start + deviationTime; seckill.countDown(seckillId, now, start, end); } } else { console.log('result: ' + result); } }); }, countDown: function (seckillId, nowTime, startTime, endTime) { console.log(seckillId + '_' + nowTime + '_' + startTime + '_' + endTime); var seckillBox = $('#seckill-box'); if (nowTime > endTime) { //秒殺結束 seckillBox.html('秒殺結束!'); } else if (nowTime < startTime) { //秒殺未開始,計時事件綁定 var killTime = new Date(startTime);//todo 防止時間偏移 seckillBox.countdown(killTime, function (event) { //時間格式 var format = event.strftime('秒殺倒計時: %D天 %H時 %M分 %S秒 '); seckillBox.html(format); }).on('finish.countdown', function () { //時間完成後回調事件 //獲取秒殺地址,控制現實邏輯,執行秒殺 console.log('______fininsh.countdown'); seckill.handlerSeckill(seckillId, seckillBox); }); } else { //秒殺開始 seckill.handlerSeckill(seckillId, seckillBox); } } }
到了秒殺開始時間段,用戶就能夠點擊按鈕進行秒殺操做。
每一個用戶只能秒殺一次,不能重複秒殺,若是重複執行,會顯示重複秒殺。
秒殺倒計時:
總結:其實在真實的秒殺系統中,咱們是不直接對數據庫進行操做的,咱們通常是會放到redis中進行處理,企業的秒殺目前應該考慮使用redis,而不是mysql。其實高併發是個僞命題,根據業務場景,數據規模,架構的變化而變化。開發高併發相關係統的基礎知識大概有:多線程,操做系統IO模型,分佈式存儲,負載均衡和熔斷機制,消息服務,甚至還包括硬件知識。每塊知識都須要必定的學習週期,須要幾年的時間總結和提煉。