1. 程式人生 > >以搶紅包為例帶你走進高併發程式設計

以搶紅包為例帶你走進高併發程式設計

目錄

1.寫在前面

2.模擬超發現象

2.1 概述

2.2 資料庫建表

2.3 編寫SQL

2.4 編寫Mapper介面

2.5 編寫Service

2.6 編寫Controller

2.7 模擬使用者請求

2.8 測試超發現象

3.解決併發之-悲觀鎖

3.1 概述

3.2 改寫Sql

3.3 測試悲觀鎖

3.4 悲觀鎖存在的問題

4.解決併發之-樂觀鎖

4.1 概述

4.2 改寫sql

4.3 樂觀鎖重入機制

4.3.1 概述

4.3.2 程式碼實現

4.4 測試樂觀鎖

5.解決併發之-Redis+Lua

6.總結 


1.寫在前面


本案例模擬的是5000個人同時搶紅包池中的3000個紅包的場景,以此案例來討論高併發程式設計會遇到的問題以及如何解決。

本案例在全註解SSM環境中編寫,關於如何搭建SSM,可以參考博主其他部落格:

《全註解搭建SSM》https://blog.csdn.net/czr11616/article/details/84325586


2.模擬超發現象


2.1 概述

什麼是超發現象?

在本案例下,紅包池中一共只有3000個紅包,5000個人同時搶,最後會出現有超過3000個人搶到了紅包,紅包池中的剩餘紅包數變成了負數,這就是超發現象

超發現象產生的原因?

先來看看搶紅包的邏輯,在搶紅包之前,需要判斷紅包池中是否還有紅包,如果有,則紅包池中的剩餘紅包數減1,並新增一條搶到紅包的記錄,如果沒有,直接返回搶紅包失敗。

在這樣的邏輯下,假設最後紅包池中只有一個紅包了,有5個人在同一時刻去搶,這時他們會同時判斷紅包池中是否還有紅包,發現有,紅包池中剩餘紅包數減一併新增搶紅包記錄。最後就會出現有3004個人搶到了紅包,紅包池中的紅包數變成了負4,超發現象就這樣產生了。

綜上,超發現象產生的根本原因就是由於多執行緒操作同一條資料從而導致資料不一致。。

接下來模擬超發現象

2.2 資料庫建表

建立紅包池表 t_red_packet ,並插入紅包資料

,下面是建表和插入資料sql語句

-- ----------------------------
-- Table structure for t_red_packet
-- ----------------------------
DROP TABLE IF EXISTS `t_red_packet`;
CREATE TABLE `t_red_packet` (
  `id` int(12) NOT NULL AUTO_INCREMENT,
  `user_id` int(12) NOT NULL,
  `amount` decimal(16,2) NOT NULL,
  `send_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `total` int(12) NOT NULL,
  `unit_amount` decimal(12,0) NOT NULL,
  `stock` int(12) NOT NULL,
  `version` int(12) NOT NULL DEFAULT '0',
  `note` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

-- ----------------------------
-- Records of t_red_packet
-- ----------------------------
INSERT INTO `t_red_packet` VALUES ('1', '1', '30000.00', '2018-11-22 18:25:29', '3000', '10', '3000', '0', '3萬元金額,3千個小紅包,每個10元');

建好的紅包池表如下圖

其中amount為紅包池總金額,total為紅包池總紅包數,unit_amount為每個紅包金額,stock為紅包池剩餘紅包數。

 建立使用者搶紅包表 t_user_red_packet

-- ----------------------------
-- Table structure for t_user_red_packet
-- ----------------------------
DROP TABLE IF EXISTS `t_user_red_packet`;
CREATE TABLE `t_user_red_packet` (
  `id` int(12) NOT NULL AUTO_INCREMENT,
  `red_packet_id` int(12) NOT NULL,
  `user_id` int(12) NOT NULL,
  `amount` decimal(16,2) NOT NULL,
  `grap_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `note` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=240 DEFAULT CHARSET=utf8;

建好的搶紅包表如下:

red_packet_id為紅包池id,user_id為搶到紅包的使用者id,amount為搶到的金額,grap_time為搶到紅包的時間。 

2.3 編寫SQL

根據之前描述的搶紅包邏輯,在搶紅包之前先檢查紅包池,如果有剩餘,則紅包池剩餘紅包數減一,並新增一條搶到紅包的記錄。所以一共有三條sql:檢視紅包池、更新紅包池庫存、新增搶到紅包記錄。

在你的紅包池對映器配置檔案中加入以下sql

檢視紅包池sql

<select id="getRedPacket" parameterType="long" resultType="com.ssm.pojo.RedPacket">
	select id,user_id as userId,amount,send_date as sendDate,total,unit_amount as unitAmount,stock,version,note from T_RED_PACKET where id = #{id}
</select>

更新紅包池庫存sql

<update id="decreaseRedPacket" parameterType="long">
	update T_RED_PACKET set stock = stock-1 where id = #{id}
</update>

 在你的使用者搶紅包對映器配置檔案中加入

新增搶到紅包記錄sql

<insert id="grapRedPacket" parameterType="com.ssm.pojo.UserRedPacket" useGeneratedKeys="true" keyProperty="id">
	insert into T_USER_RED_PACKET (red_packet_id,user_id,amount,grap_time,note) values (#{redPacketId},#{userId},#{amount},now(),#{note})
</insert>

2.4 編寫Mapper介面

操作資料庫除了對映器還需要介面,建立紅包池介面(RedPacketDao)和使用者搶紅包介面(UserRedPacketDao)

 RedPacketDao:

package com.ssm.dao;

import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

import com.ssm.pojo.RedPacket;

@Repository
public interface RedPacketDao {

	/*
	 * 獲取紅包池資訊
	 * @param id 紅包池id
	 * @return 紅包池具體資訊
	 */
	public RedPacket getRedPacket(Long id);
	/*
	 * 更新紅包池庫存
	 * @param id -- 紅包池id
	 * @return 更新記錄條數
	 */
	public int decreaseRedPacket(Long id);
}

UserRedPacketDao:

package com.ssm.dao;

import org.springframework.stereotype.Repository;

import com.ssm.pojo.UserRedPacket;

@Repository
public interface UserRedPacketDao {

	/*
	 * 插入搶紅包記錄
	 * @param userRedPacket 搶紅包記錄
	 * @return 影響記錄條數
	 */
	public int grapRedPacket(UserRedPacket userRedPacket);
}

2.5 編寫Service

編寫紅包池service介面RedPacketService

根據搶紅包邏輯,介面中編寫了檢視紅包池資訊和更新紅包池庫存方法

package com.ssm.service;

import com.ssm.pojo.RedPacket;

public interface RedPacketService {

	//檢視紅包池
	public RedPacket getRedPacket(Long id);
	//更新紅包池庫存
	public int decreaseRedPacket(Long id);
}

編寫實現類RedPacketServiceImpl

package com.ssm.service.impl;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import com.ssm.dao.RedPacketDao;
import com.ssm.pojo.RedPacket;
import com.ssm.service.RedPacketService;

@Service
public class RedPacketServiceImpl implements RedPacketService {

	@Autowired
	private RedPacketDao redPacketDao = null;
	/*
	 * 獲取紅包池資訊
	 * @param id 紅包池id
	 * @return 紅包池具體資訊
	 */
	@Override
	//指定資料庫事務的隔離級別和傳播行為
	@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
	public RedPacket getRedPacket(Long id) {
		
		return redPacketDao.getRedPacket(id);
	}
	/*
	 * 更新紅包池庫存
	 * @param id -- 紅包池id
	 * @return 更新記錄條數
	 */
	@Override
	@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
	public int decreaseRedPacket(Long id) {
		int result = redPacketDao.decreaseRedPacket(id);
		return result;
	}

}

編寫使用者搶紅包介面UserRedPacketService

package com.ssm.service;

public interface UserRedPacketService {

	/*
	 * 使用者搶紅包
	 * @param redPacketId 紅包池id
	 * @param userId 使用者id
	 * @return 搶紅包是否成功
	 */
	public int grapRedPacket(Long redPacketId,Long userId);

}

編寫使用者搶紅包介面實現類UserRedPacketServiceImpl

根據搶紅包邏輯,在搶之前,先判斷紅包池中是否有紅包,若沒有,直接返回失敗,若有,紅包池庫存減一併新增一條搶紅包記錄。

package com.ssm.service.impl;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import com.ssm.dao.RedPacketDao;
import com.ssm.dao.UserRedPacketDao;
import com.ssm.pojo.RedPacket;
import com.ssm.pojo.UserRedPacket;
import com.ssm.service.UserRedPacketService;

@Service
public class UserRedPacketServiceImpl implements UserRedPacketService {
	
	@Autowired
	private RedPacketDao redPacketDao = null;

	@Autowired
	private UserRedPacketDao userRedPacketDao = null;
	
	private static final int FAILED = 0;
	/*
	 * 使用者搶紅包方法
	 * @param redPacketId 紅包池id
	 * @param userId 使用者id
	 * @return 搶紅包成功或失敗
	 */
	@Override
	@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
	public int grapRedPacket(Long redPacketId, Long userId) {
		
		//獲取紅包池資訊
		RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
		//判斷紅包池中是否有剩餘紅包
		if(redPacket.getStock() >0) {
			//若有剩餘則紅包池中庫存減一
			redPacketDao.decreaseRedPacket(redPacketId);
			//並且新建一條搶紅包記錄並插入使用者搶紅包表
			UserRedPacket userRedPacket = new UserRedPacket();
			userRedPacket.setRedPacketId(redPacketId);
			userRedPacket.setUserId(userId);
			userRedPacket.setAmount(redPacket.getUnitAmount());
			userRedPacket.setNote("搶紅包"+redPacketId);
			//插入記錄
			int result = userRedPacketDao.grapRedPacket(userRedPacket);
			//返回插入記錄數1,即成功
			return result;
		}
		//若沒有庫存直接返回失敗
		return FAILED;
	}
}

2.6 編寫Controller

 核心邏輯寫完了,接下來該寫和前端互動的controller,controller中呼叫了使用者搶紅包的介面UserRedPacketService 

package com.ssm.controller;

import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.ssm.service.UserRedPacketService;

@Controller
//配置請求路徑
@RequestMapping("/userRedPacket")
public class UserRedPacketController {
	
	@Autowired
	private UserRedPacketService userRedPacketService = null;
	//配置請求路徑
	@RequestMapping("/grapRedPacket")
	@ResponseBody
	public Map<String,Object> grapRedPacket(Long redPacketId,Long userId){
		
		//呼叫搶紅包介面
		int result = userRedPacketService.grapRedPacket(redPacketId, userId);
            //根據介面返回值給前端返回搶紅包是否成功
		Map<String,Object> resultMap = new HashMap<String,Object>();
		boolean flag = result > 0;
		resultMap.put("success", flag);
		resultMap.put("message", flag?"搶紅包成功":"搶紅包失敗");
		return resultMap;
	}

}

2.7 模擬使用者請求

controller寫好了,接下來根據controller中配置的請求路徑模擬5000個使用者同時搶紅包的場景

<input type="button" value="開始搶紅包" onclick="grapRedPacket()">
<script type="text/javascript">
	function grapRedPacket(){
		//模擬5000個非同步請求,進行併發
		var max = 5000;
		for(var i=1;i<=max;i++){
			$.post({
				url:"../userRedPacket/grapRedPacket.do?redPacketId=1&userId="+i,
				success:function(result){	
				}
			})
		}
	}
</script>

2.8 測試超發現象

點選頁面按鈕開始搶紅包,稍等片刻,觀察資料庫中紅包池表

你會驚奇的發現紅包池中的庫存變成了負數,沒錯,超發現象產生了,我們再來看看使用者搶紅包表:

果然有3002個使用者搶到了紅包,這更加驗證了超發現象的產生,那麼怎麼解決超發現象呢?


3.解決併發之-悲觀鎖


3.1 概述

什麼是悲觀鎖?

顧名思義,就是悲觀的認為會發生併發衝突,因此,在資料處理時會給資料庫中的資料加鎖,避免其他執行緒操作資料,直到本執行緒事務提交併釋放鎖,其他執行緒才能操作資料。

悲觀鎖的實現方式?

我們知道悲觀鎖是給資料庫資料加鎖,所以悲觀鎖是依靠資料庫提供的鎖機制,我們往往通過sql語句給指定的資料庫記錄加鎖,例如sql語句:select * from table_name where id = 1 for update,加上for update後,資料庫執行sql時就會給id=1的這條記錄上鎖,我們就稱執行此sql語句的事務持有了悲觀鎖,事務提交後鎖才會釋放,釋放之前,其他事務不能操作id=1的這條記錄。

知道了悲觀鎖的概念以後,我們來講一講如何解決之前的紅包超發現象。

3.2 改寫Sql

回顧之前的搶紅包邏輯,第一步是檢視紅包池資訊判斷是否還有剩餘紅包,如果我們在這條查詢sql中加鎖,那麼其他執行緒就會等待該事務最後搶完紅包並提交以後才繼續執行,就避免了併發操作資料的問題,下面改寫這條查詢語句:

<select id="getRedPacketForUpdate" parameterType="long" resultType="com.ssm.pojo.RedPacket">
	select id,user_id as userId,amount,send_date as sendDate,total,unit_amount as unitAmount,stock,version,note from T_RED_PACKET 
	where id = #{id} for update
</select>

當然還需要在RedPacketDao介面中去建立對應的方法去執行這條sql。

3.3 測試悲觀鎖

加入悲觀鎖後進行測試:

紅包池表:

使用者搶紅包表:

可以看到超發現象解決了,紅包剛剛好發完。

3.4 悲觀鎖存在的問題

 當一條執行緒操作資料庫的時候給資料庫記錄上鎖,其他的執行緒就會被掛起,等待該執行緒中的事務提交,事務提交以後鎖釋放,被掛起的執行緒恢復執行,開始搶奪cpu執行權,搶到的執行緒執行資料庫操作的時候又上鎖,其他執行緒又都掛起.......這樣執行緒頻繁地掛起釋放是非常消耗資源的,也會影響效能

那麼有什麼其他解決辦法呢?下面來講樂觀鎖


4.解決併發之-樂觀鎖


4.1 概述

什麼是樂觀鎖?

與悲觀鎖不同,樂觀鎖是樂觀的認為不會發生併發衝突,所以並不會像悲觀鎖那樣“佔著資料不放,給資料上鎖”,其他執行緒也可以操作資料,但是樂觀鎖機制會在最後更新資料的時候比較當前的資料和之前的資料是否一致,如果不一致,那麼就代表這條資料被其他執行緒修改過,則放棄更新,如果一致,則完成更新

如何實現樂觀鎖?

我們通常會給資料庫表新增一個版本號欄位(version),用於標示記錄的版本,每當有事務修改記錄的時候,版本號就加1。因此我們在更新資料庫記錄的時候就可以比較當前記錄的版本號與之前的版本號是否一致,從而判斷該記錄是否被修改過,如果沒有修改過,則完成更新同時給版本號加1。

4.2 改寫sql

根據之前的描述我們來改寫更新紅包池的sql語句,通過更新執行結果來判斷是否需要插入搶紅包成功記錄。

<update id="decreaseRedPacketForVersion">
	update T_RED_PACKET set stock = stock-1,version = version + 1
		where id = #{id} and version = #{version}
</update>

這裡通過傳遞之前記錄的版本號來判斷是否需要更新庫存和版本號。

4.3 樂觀鎖重入機制

4.3.1 概述

 根據之前的描述,如果通過版本號判斷資料被修改過,則會放棄更新。那麼就會出現一個問題,在高併發的場景下,會出現很多併發修改資料的情況,都會放棄更新,那麼最後就會導致有很多失敗的搶紅包請求,5000個人搶3000個紅包,紅包沒有被搶完,怎麼解決呢?

在一次搶紅包過程中發現剩餘紅包數被其他執行緒修改,並不會放棄,而會繼續去搶,這種更新失敗後繼續判斷的機制我們稱為樂觀鎖重入機制,我們通過指定重試的次數來提高成功率。

4.3.2 程式碼實現

public int grapRedPacketForVersion(Long redPacketId, Long userId) {
		
		for(int i=0;i<3;i++) {
			//獲取紅包池資訊
			RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
			//判斷紅包池中是否還有剩餘紅包
			if(redPacket.getStock() > 0 ) {
				//通過版本號判斷資料庫記錄是否被修改過,如果被修改過,繼續搶
				int IsChange = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
				if(IsChange == 0) {
					continue;
				}
				//如果沒有被修改過,插入使用者搶紅包記錄
				UserRedPacket userRedPacket = new UserRedPacket();
				userRedPacket.setRedPacketId(redPacketId);
				userRedPacket.setUserId(userId);
				userRedPacket.setAmount(redPacket.getUnitAmount());
				userRedPacket.setNote("搶紅包"+redPacketId);
				int result = userRedPacketDao.grapRedPacket(userRedPacket);
				return result;
			}else {
				return FAILED;
			}
		}
		//如果搶三次還沒有搶到,則返回失敗
		return FAILED;
	}

4.4 測試樂觀鎖

點選前端搶紅包按鈕觀察資料庫:

 


 

 可以看到沒有出現超發現象並且紅包也被搶完了。


5.解決併發之-Redis+Lua


由於這裡涉及到許多其他的知識點,我會新開一篇部落格去講,看這裡:

《暫時沒有啊哈哈哈哈哈》

在這裡我們先講一下Redis+Lua為什麼可以解決併發問題,因為Redis本身是單執行緒的,這意味著每次只會有一條執行緒去執行redis命令,從而也就不會有併發問題,而Lua指令碼的執行又是原子性的,這意味著你可以把對資料的操作寫在Lua腳本里,從而保證腳本里的資料庫操作可以同時成功或失敗。

有讀者可能會有疑問,Redis如此注重效能,為什麼Redis是單執行緒的呢?難道單執行緒比多執行緒更快嗎?對於Redis來說,單執行緒的確比多執行緒更快,為什麼呢?看這裡:

《Redis核心技術---單執行緒》https://blog.csdn.net/czr11616/article/details/84483130


6.總結 


我們通過三種方式去處理併發問題,分別為悲觀鎖、樂觀鎖和Redis+Lua,那麼這三者有什麼優劣呢?

對於悲觀鎖來說,由於是對資料庫記錄加鎖,導致會有大量執行緒被阻塞,而且需要有大量的恢復過程,這非常的消耗資源,且資料庫效能有所下降。

對於樂觀鎖來說,雖然不會有執行緒的阻塞,但是為了提高成功率,需要實現重入機制,這會導致大量多餘的sql被執行,這對於資料庫的效能要求比較高,容易引起資料庫的瓶頸,且因為需要手動編寫重入程式碼,開發難度加大。

對於Redis+Lua呢,首先它非常快,而且極大地減輕了資料庫的負擔,但是由於Redis是基於記憶體的資料庫,不是很穩定,有可能發生宕機。