1. 高並發最佳化分析
關於並發
並發性上不去是因為當多個線程同時訪問一行資料時,產生了事務,因此產生寫鎖,每當一個擷取了事務的線程把鎖釋放,另一個排隊線程才能拿到寫鎖,QPS(Query Per Second每秒查詢率)和事務執行的時間有密切關係,事務執行時間越短,並發性越高,這也是要將費時的I/O操作移出事務的原因。
在本項目中高並發發生在哪。
在上圖中,紅色的部分就表示會發生高並發的地方,綠色部分表示對於高並發沒有影響。
為什麼需要單獨擷取系統時間。
這是為了我們的秒殺系統的最佳化做鋪墊。比如在秒殺還未開始的時候,使用者大量重新整理秒殺商品詳情頁面是很正常的情況,這時候秒殺還未開始,大量的請求發送到伺服器會造成不必要的負擔。
我們將這個詳情頁放置到CDN中,這樣使用者在訪問該頁面時就不需要訪問我們的伺服器了,起到了降低伺服器壓力的作用。而CDN中儲存的是靜態化的詳情頁和一些靜態資源(css,js等),這樣我們就拿不到系統的時間來進行秒殺時段的控制,所以我們需要單獨設計一個請求來擷取我們伺服器的系統時間。
CDN(Content Delivery Network)的理解
擷取系統時間不需要最佳化
因為Java訪問一次記憶體(Cacheline)大約10ns,1s=10億ns,也就是如果不考慮GC,這個操作1s可以做1億次。
秒殺地址介面分析 無法使用CDN緩衝,因為CDN適合請求對應的資源不變化的,比如靜態資源、JavaScript;秒殺地址返回的資料是變化的,不適合放在CDN緩衝; 適合服務端緩衝:Redis等,1秒鐘可以承受10萬qps。多個Redis組成叢集,可以到100w個qps. 所以後端緩衝可以用業務系統控制。
秒殺地址介面最佳化
秒殺操作最佳化分析 無法使用cdn緩衝 後端緩衝困難: 庫存問題 一行資料競爭:熱點商品
大部分寫的操作和核心操作無法使用CDN,也不可能在緩衝中減庫存。你在Redis中減庫存,那麼使用者也可能通過緩衝來減庫存,這樣庫存會不一致,所以要通過mysql的事務來保證一致性。
比如一個熱點商品所有人都在搶,那麼會在同一時間對資料表中的一行資料進行大量的update set操作。
行級鎖在commit之後才釋放,所以最佳化方向是減少行級鎖的持有時間。
延遲問題很關鍵 同城機房網路(0.5ms~2ms),最高並發性是1000qps。 Update後JVM -GC(記憶體回收機制)大約50ms,最高並發性是20qps。並發性越高,GC就越可能發生,雖然不一定每次都會發生,但一定會發生。
異地機房,比如北京到上海之間的網路延遲,進過計算大概13~20ms。
如何判斷update更新庫存成功。
有兩個條件: update自身沒報錯; 用戶端確認update影響記錄數
最佳化思路: 把用戶端邏輯放到MySQL服務端,避免網路延遲和GC影響
如何把用戶端邏輯放到MySQL服務端
有兩種方案: 定製SQL方案,在每次update後都會自動認可,但需要修改MySQL源碼,成本很高,不是大公司(BAT等)一般不會使用這種方法。 使用預存程序:整個事務在MySQL端完成,用預存程序寫商務邏輯,服務端負責調用。
接下來先分析第一種方案
根據上圖的成本分析,我們的秒殺系統採用第二種方案,即使用預存程序。
最佳化總結 前端控制
暴露介面,按鈕防重複(點擊一次按鈕後就變成灰色,禁止重複點擊按鈕) 動待用資料分離
CDN緩衝,後端緩衝 事務競爭最佳化
減少事務行級鎖的持有時間 2. Redis後端緩衝最佳化編碼
關於CDN的說明
由於不同公司提供的CDN的介面暴露不同,不同的公司租用的機房調用的API也不相同,所以慕課網的視頻中並沒有對CDN的使用過程進行講解。 2.1 下載安裝Redis
前往官網下載安裝Stable版本的Redis,安裝後可以將安裝目錄添加到系統變數Path裡以方便使用,我使用的是Windows系統的Redis,懶得去官網下載的可以點這裡下載。
安裝後,運行redis-server.exe啟動伺服器成功,接著運行redis-cli.exe啟動用戶端串連伺服器成功,說明Redis已經安裝成功了。
為什麼使用Redis
Redis屬於NoSQL,即非關係型資料庫,它是key-value型資料庫,是直接在記憶體中進行存取資料的,所以有著很高的效能。
利用Redis可以減輕MySQL伺服器的壓力,減少了跟資料庫伺服器的通訊次數。秒殺的瓶頸就在於跟資料庫伺服器的通訊速度(MySQL本身的主鍵查詢非常快) 2.2 在pom.xml中配置Redis用戶端
<!--添加Redis依賴 --><dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>2.7.3</version></dependency>
Jedis
Redis有很多用戶端,我們的項目是用Java語言寫的,自然選擇對應Java語言的用戶端,而官網最推薦我們的Java用戶端是Jedis,在pom.xml裡配置了Jedis依賴就可以使用它了,記得要先開啟Redis的伺服器,Jedis才能串連到伺服器。
由於Jedis並沒有實現內部序列化操作,而Java內建的序列化機制效能又不高,我們是一個秒殺系統,需要考慮高並發最佳化,在這裡我們採用開源社區提供的更高效能的自訂序列化工具protostuff。 2.3 在pom.xml中配置protostuff依賴
<!--prostuff序列化依賴 --><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>
關於序列化和還原序列化
序列化是處理物件流程的機制,就是將對象的內容進行流化,可以對流化後的對象進行讀寫操作,也可以將流化後的對象在網路間傳輸。還原序列化就是將流化後的對象重新轉化成原來的對象。
在Java中內建了序列化機制,通過implements Serializable來標識一個對象實現了序列化介面,不過其效能並不高。 2.4 使用Redis最佳化地址暴露介面
原本查詢秒殺商品時是通過主鍵直接去資料庫查詢的,選擇將資料緩衝在Redis,在查詢秒殺商品時先去Redis緩衝中查詢,以此降低資料庫的壓力。如果在緩衝中查詢不到資料再去資料庫中查詢,再將查詢到的資料放入Redis緩衝中,這樣下次就可以直接去緩衝中直接查詢到。
以上屬於資料訪問層的邏輯(DAO層),所以我們需要在dao包下建立一個cache目錄,在該目錄下建立RedisDao.java,用來存取緩衝。
RedisDao
public class RedisDao { private final JedisPool jedisPool; public RedisDao(String ip, int port) { jedisPool = new JedisPool(ip, port); } private RuntimeSchema<Seckill> schema = RuntimeSchema.createFrom(Seckill.class); public Seckill getSeckill(long seckillId) { // redis操作邏輯 try { Jedis jedis = jedisPool.getResource(); try { String key = "seckill:" + seckillId; // 並沒有實現哪部序列化操作 // 採用自訂序列化 // protostuff: pojo. byte[] bytes = jedis.get(key.getBytes()); // 緩衝重擷取到 if (bytes != null) { Seckill seckill = schema.newMessage(); ProtostuffIOUtil.mergeFrom(bytes, seckill, schema); // seckill被還原序列化 return seckill; } } finally { jedis.close(); } } catch (Exception e) { } return null; } public String putSeckill(Seckill seckill) { try { Jedis jedis = jedisPool.getResource(); try { String key = "seckill:" + seckill.getSeckillId(); byte[] bytes = ProtostuffIOUtil.toByteArray(seckill, schema, LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE)); // 逾時緩衝 int timeout = 60 * 60;// 1小時 String result = jedis.setex(key.getBytes(), timeout, bytes); return result; } finally { jedis.close(); } } catch (Exception e) { } return null; }}
注意
使用protostuff序列化工具時,被序列化的對象必須是pojo對象(具備setter/getter)
在spring-dao.xml中手動注入RedisDao
由於RedisDao和MyBatis的DAO沒有關係,MyBatis不會幫我們自動實現該介面,所以我們需要在spring-dao.xml中手動注入RedisDao。由於我們在RedisDao是通過構造方法來注入ip和port兩個參數的,所以需要配置,如果不配置這個標籤,我們需要為ip和port提供各自的setter和getter(注入時可以沒有getter)。
在這裡我們直接把value的值寫死在標籤裡邊了,實際開發中需要把ip和port參數的值寫到設定檔裡,通過讀取設定檔的方式讀取它們的值。
<!--redisDao --><bean id="redisDao" class="com.lewis.dao.cache.RedisDao"> <constructor-arg index="0" value="localhost" /> <constructor-arg index="1" value="6379" /></bean>
修改SeckillServiceImpl
使用註解注入RedisDao屬性
@Autowired private RedisDao redisDao;
修改exportSeckillURI()
public Exposer exportSeckillUrl(long seckillId) { // 最佳化點:緩衝最佳化:逾時的基礎上維護一致性 // 1.訪問redi Seckill seckill = redisDao.getSeckill(seckillId); if (seckill == null) { // 2.訪問資料庫 seckill = seckillDao.queryById(seckillId); if (seckill == null) {// 說明查不到這個秒殺產品的記錄 return new Exposer(false, seckillId); } else { // 3.放入redis redisDao.putSeckill(seckill); } } // 若是秒殺未開啟 Date startTime = seckill.getStartTime(); Date endTime = seckill.getEndTime(); // 系統目前時間 Date nowTime = new Date(); if (startTime.getTime() > nowTime.getTime() || endTime.getTime() < nowTime.getTime()) { return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime()); } // 秒殺開啟,返回秒殺商品的id、用給介面加密的md5 String md5 = getMD5(seckillId); return new Exposer(true, md5, seckillId);}
2.5 測試類別RedisDaoTest
通過IDE工具快速產生測試類別RedisDaoTest,新寫一個testSeckill(),對getSeckill和putSeckill方法進行全域測試。
@RunWith(SpringJUnit4ClassRunner.class)// 告訴junit spring的設定檔@ContextConfiguration({ "classpath:spring/spring-dao.xml" })public class RedisDaoTest { private final Logger logger = LoggerFactory.getLogger(this.getClass()); private long id = 1001; @Autowired private RedisDao redisDao; @Autowired private SeckillDao seckillDao; @Test public void testSeckill() { Seckill seckill = redisDao.getSeckill(id); if (seckill == null) { seckill = seckillDao.queryById(id); if (seckill != null) { String result = redisDao.putSeckill(seckill); logger.info("result={}", result); seckill = redisDao.getSeckill(id); logger.info("seckill={}", seckill); } } }}
如果測試通過了,會輸出result={}OK以及id為1001的商品資訊,如果輸出的都是null,那說明你沒有開啟Redis伺服器,所以在記憶體中沒有存取到緩衝。
為什麼不用Redis的hash來儲存物件。
第一:通過Jedis儲存對象的方式有大概三種 本項目採用的方式:的序列化成byte位元組,最終存byte位元組; 對象轉hashmap,也就是你想表達的hash的形式,最終存map; 對象轉json,最終存json,其實也就是字串
第二:其實如果你是平常的項目,並發不高,三個選擇都可以,這種情況下以hash的形式更加靈活,可以對象的單個屬性,但是問題來了,在秒殺的情境下,三者的效率差別很大。
第三:結果如下
| 10w資料 |
時間 |
記憶體佔用 |
| 存json |
10s |
14M |
| 存byte |
6s |
6M |
| 存jsonMap |