一、什么是高并發
高并發是指在同一個時間點,有大量用戶同時訪問URL地址,比如淘寶雙11、定時領取紅包就會產生高并發;又比如貼吧的爆吧,就是惡意的高并發請求,也就是DDOS攻擊(通過大量合法的請求占用大量網絡資源,以達到癱瘓網絡的目的)。
二、高并發帶來的后果
- 服務端
??導致站點服務器、DB服務器資源被占滿崩潰。
??數據的存儲和更新結果和理想的設計不一致。 - 用戶角度
??尼瑪,網站這么卡,刷新了還這樣,垃圾網站,不玩了。
三、并發下的處理
-
配置數據庫連接池C3P0
??配置連接池的原因是因為我們要做一個高并發的秒殺系統,可能一些連接會被鎖住,其他的線程就可能會拿不到連接的情況,所以我們要調整一下連接池的屬性來更符合我們的場景
<!-- 數據庫連接池 -->
<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource">
<!-- 配置連接池屬性 -->
<property name="driverClass" value="${driver}"/>
<property name="jdbcUrl" value="${url}"/>
<property name="user" value="${username}"/>
<property name="password" value="${password}"/>
<!-- c3p0連接池的私有屬性 -->
<property name="maxPoolSize" value="30"/>
<property name="minPoolSize" value="10"/>
<property name="autoCommitOnClose" value="false"/>
<!-- 獲取連接超時時間 -->
<property name="checkoutTimeOut" value="1000"/>
<!-- 當獲取連接失敗重試次數 -->
<property name="acquireRetryAttempsts" value="2"/>
</bean>
-
事務+鎖來防止并發導致數據錯亂
??建議所有的數據操作都寫在一個sql事務里面。下面舉三個例子來說明情況。
①簽到功能:一天一個用戶只能簽到一次,簽到成功后用戶獲得一個積分,我們可以把添加簽到和添加積分放到一個事務里面,這樣在添加失敗,或者編輯用戶積分失敗的時候可以回滾數據。
②在高并發情況下用戶進行抽獎,很可能會導致用戶參與抽獎的時候積分被扣除,而獎品實際上已經被抽完了。我們可以在事務里面,通過WITH(UPDLOCK)鎖住商品表,或者update表的獎品剩余數量和最后編輯時間字段,來把數據行鎖住,然后進行用戶積分的消耗,都完成后提交事務,失敗就回滾,這樣就放置數據錯亂。
//當我們用UPDLOCK來讀取記錄時可以對取到的記錄加上更新鎖
//從而加上鎖的記錄在其它的線程中是不能更改的只能等本線程的事務結束后才能更改
update commodity with (updlock) set count = count-1 where id=?;
③ 如果要實現這樣一個需求:cache里面的數據必須每天9點更新一次,其他時間點緩存每小時更新一次。并且到9點的時候,凡是已經打開頁面的用戶會自動刷新頁面。
??這里面包含的用戶觸發緩存更新的邏輯:用戶刷新頁面,當緩存存在的時候,會獲取到最后一次緩存更新的時間。如果當前時間>9點,并且最后緩存時間在9點之前,則會從數據庫中重新獲取數據保存到cache中。如果大量用戶在9點之前已經打開了頁面,而且在9點之后還未關閉頁面,那么就會導致在9點的時候會有很多并發請求過來,數據庫服務器壓力暴增。
??要解決這個問題,最好就是只有一個請求去數據庫獲取,其他都是從緩存中獲取數據。此時,我們就可以用鎖來解決:從數據讀取到緩存那段代碼前面加上鎖,這樣在并發的情況下只會有一個請求是從數據庫里獲取數據,其他都是從緩存中獲取。
但是不是所有的方法都需要加事務,比如讀操作。
事務時間要盡可能短
??當在高并發系統進行寫入操作的時候就會鎖定你寫入的那行代碼,要是寫入時間很長那么鎖定的時間也很長,不利于高并發的操作。特別是網絡操作運行時間一般都比較長,所以最好不要穿插進來。利用緩存處理高并發
把被用戶大量訪問的靜態資源緩存在CDN中
??在秒殺的時候,如果秒殺沒有開始,用戶看到喜歡的商品,用戶就會不停刷新這個頁面。所以類似于秒殺詳情頁這些被用戶大量訪問的頁面靜態資源(如html、css、js)就應該部署到CDN節點上,也就是用戶訪問的那些html已經不在系統中了,而是在CDN節點上。
用戶大量刷新→CDN(detail頁靜態化,靜態資源js、css等)→高并發系統
合理使用nosql緩存數據庫
??高并發接口,比如秒殺地址接口是沒辦法使用CDN緩存的,因為CDN適合我這個請求對應的資源不變的,比如JavaScript,JavaScript拿回來在瀏覽器執行,它的內容是不變的。但是高并發接口的返回數據是在變化的,比如秒殺接口:一開始沒有秒殺,隨著時間推移已經開啟秒殺,再往后秒殺已經關閉了。所以高并發接口不適合放在CDN緩存,但是適合放在服務器端緩存。
??后端緩存可以用應用系統來控制,比如先訪問數據庫拿到高并發接口的數據,然后放在redis緩存里面,下次訪問直接在緩存里面找。
??使用這種方法的好處就是一致性維護成本低:請求地址要求拿到高并發接口的數據的時候,先訪問服務器端緩存,若沒有再訪問數據庫。如果高并發接口的數據需要改變的時候,我們可以等待緩存超時再更新數據,或者直接穿透到數據庫更新,又或者當數據庫數據更新的時候主動更新一下緩存。使用一級緩存,減少nosql服務器壓力
??一級緩存使用站點服務器緩存去存儲數據,注意只存儲部分請求量大的數據,并且緩存的數據量要控制,不能過分的使用站點服務器的內存而影響了站點應用程序的正常運行。善用原子計數器
??在秒殺系統中,熱點商品會有大量用戶參與進來,然后就產生了大量減庫存競爭。所以當執行秒殺的時候系統會做一個原子計數器(可以通過redis/nosql實現),它記錄的是商品的庫存。當用戶執行秒殺的時候,就會去減庫存,也就是減原子計數器,保證原子性。當減庫存成功之后就回去記錄行為消息(誰去減了庫存),減了會后作為一個消息當到一個分布的MQ(消息隊列)中,然后后端的服務器會把其落地到MySQL中。
原子計數器:主要是高并發的統計的時候要用到。比如:
increment() 和 decrement() 操作是原子的讀-修改-寫操作。為了安全實現計數器,必須使用當前值,并為其添加一個值,或寫出新值,所有這些均視為一項操作,其他線程不能打斷它。
善用redis的消息隊列
??使用redis的list,當用戶參與到高并發活動時,將參與用戶的信息添加到消息隊列中,然后再寫個多線程程序去消耗隊列(pop數據),這樣能避免服務器宕機的危險。
??通過消息隊列可以做很多的服務,比如定時短信發送服務,使用sorted set(sset),發送時間戳作為排序依據,短信數據隊列根據時間升序,然后寫個程序定時循環去讀取sset隊列中的第一條,當前時間是否超過發送時間,如果超過就進行短信發送。事務競爭的優化
??在高并發秒殺系統中,第一個用戶執行減庫存操作,在commit/rollback以前,第二個秒殺用戶也要執行減庫存,但是因為一個用戶得到了鎖,其他用戶就必須進行等待(因為當事務不去提交/回滾的話行級鎖是沒辦法釋放的)。也就是說后面的線程想減庫存,必須等到前面的線程釋放哈行鎖。這就變成了一個串行的操作:同一個商品減庫存,大家都要排隊等,就產生了大量阻塞操作。而且,sql語句發送給數據庫也可能存在網絡延遲,這樣后面的用戶等待時間就更長了。
解決方案
①MySQL源碼層的修改方案:在update后面加上這樣一句話:/+[auto_commit]/,當你執行完這條update的時候它會自動回滾。回滾的條件是:update影響的記錄數是1就可以commit,如果為0就會rollback。也就是不給java客戶端和MySQL之間網絡延遲,然后再由java客戶端其控制commit和rollback,而是直接通過語句發過去你就告訴我commit和rollback。這個成本比較高,需要修改MySQL源碼
②使用存儲過程:存儲過程的本質就是讓一組sql組成一組事務,然后再MySQL端完成,避免客戶端完成事務造成性能的干擾。一般情況下,spring聲明事務和手動控制事務都是客戶端控制事務。這些事務在行級鎖沒有那么高的競爭情況下是完全OK的,但是秒殺是一個特殊的應用場景,它會在同一行中產生熱點,大家都競爭同一行,這個時候存儲過程就能夠發揮作用了,他把整個sql執行過程放在MySQL端完成,MySQL執行sql的效率非常高。*簡單的邏輯我們可以使用存儲過程,太過復雜的就不要依賴了。
-- 秒殺執行存儲過程
DELIMITER $$ -- onsole ; 轉換為 $$
-- 定義存儲過程
-- 參數:in 輸入參數; out 輸出參數
-- row_count():返回上一條修改類型sql(delete,insert,upodate)的影響行數
-- row_count: 0:未修改數據; >0:表示修改的行數; <0:sql錯誤/未執行修改sql
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_killed (seckill_id, user_phone, create_time)
VALUES(v_seckill_id, v_phone, v_kill_time);
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(1001, 13631231234, now(), @r_result);
-- 獲取結果
SELECT @r_result;
③通常我們的操作是:減庫存(rowLock)→插入購買明細→commit/rollback(freeLock)。我們可以在這個基礎上進行一些簡單的優化,調換操作的順序:插入購買明細→減庫存(rowLock)→commit/rollback(freeLock),我這樣們的延遲就只會發生在update語句這個點上。
腳本合理控制請求
??比如用腳本防止用戶重復點擊導致多余的請求。使用具有高并發能力的編程語言去開發
nodejs就是一個具有高并發能力的編程語言,它使用單線程異步時間機制,不會因為數據邏輯處理問題導致服務器資源被占用而導致服務器宕機,我們可以使用NodeJs寫web接口。
apache模式,以下簡稱A模式。一共有三個點餐窗口,三位服務人員,三位廚師(請自行腦補畫面,但是別亂想)。顧客在任一窗口點餐[所謂多線程],點完后服務員傳達廚師,等待廚師出餐,服務員返給顧客[同步返回響應結果]。顧客本次購物結束。服務員進行下一位顧客的點餐[接收下一個請求]。
??nodejs模式,以下簡稱N模式。一共只有一個點餐窗口一位服務員[單線程],一位廚師[CPU]。顧客在窗口點餐,點完后服務員傳達廚師,廚師進行出餐,而服務員不必等待[不必等待當前請求返回結果],直接進行下一位顧客的點餐,然后繼續傳達下一個顧客的訂單給廚師。廚師挨個完成后拋出給出餐窗口[異步返回響應結果],顧客到出餐窗口取餐,本次購物結束。
比如要統計用戶通過各種方式(如點擊圖片/鏈接)進入到商品詳情的行為次數,如果同時有1w個用戶同時在線訪問頁面,一次拉動滾動條屏幕頁面展示10件商品,這樣就會有10w個請求過來,服務端需要把請求的次數數據入庫,這樣服務器分分鐘給跪了。
??要解決這些訪問量大的數據統計接口的問題,我們可以通過nodejs寫一個數據處理接口,把統計數據先存到redis的list中,然后再使用nodejs寫一個腳本,腳本的功能就是從redis里取出數據保存到mysql數據庫中。這個腳本會一直運行,當redis沒有數據需求要同步到數據庫中的時候,sleep,然后再進行數據同步操作。
集群
??集群是一種多服務器結構,也就是把同一個業務,部署在多個服務器上(區別于分布式,分布式是把個業務分拆多個子業務,部署在不同的服務器上),這樣就可以提高單位時間內執行的任務數來提升效率,把壓力分擔到多臺服務器上。
??我們可以集群部署Mysql數據庫,或者NoSQL DB服務器(如mongodb服務器、redis服務器),把一些常用的查詢數據,并且不會經常變化的數據保存到NoSQL DB服務器,來減少數據庫服務器的壓力,加快數據的響應速度。-
構建一個好的服務器架構
大致的服務器架構如下:
服務器
├負載均衡
│├Nginx
│└阿里云SLB
├資源監控
└分布式數據庫
├主從分離、集群
├分布式
└表優化、索引優化等
NoSQL
├redis
│├主從分離
│└集群
├mongodb
│├主從分離
│└集群
├memcache
│├主從分離
│└集群
└...
CDN
├html
├css
├js
└image
高并發情境中,更新用戶相關緩存需要分布式存儲,比如使用用戶ID進行hash分組,把用戶分不到不用的緩存中,這樣一個緩存集合的總量不會很大,不會影響查詢效率。