天天看點

搶紅包案例分析以及代碼實作(四)

搶紅包案例分析以及代碼實作(四)

前文回顧

搶紅包案例分析以及代碼實作(一)

搶紅包案例分析以及代碼實作(二)

搶紅包案例分析以及代碼實作(三 )

上面三篇博文是使用的MySql資料庫來作為資料的載體資料最終會将資料儲存到磁盤中,而Redis使用的是記憶體,記憶體的速度比磁盤速度肯定要快很多。

對于使用 Redis實作搶紅包,首先需要知道的是Redis的功能不如資料庫強大,事務也不是很完整.是以要保證資料的正确性資料的正确性可以通過嚴格的驗證得以保證。

而 Redis的 Lua 語言是原子性的,且功能更為強大,是以優先選擇使用Lua語言來實作搶紅包。

但是無論如何對于資料而言,在 Redis 當中存儲,始終都不是長久之計 , 因為 Redis并非一個長久儲存資料的地方,更多的時候隻是為了提供更為快速的緩存,是以當紅包金額為 0 或者紅包逾時的時候(逾時操作可以使用定時機制實,這裡暫不讨論), 會将紅包資料儲存到資料庫中,,這樣才能夠保證資料的安全性和嚴格性。

是以本篇博文我們将使用Redis + lua腳本來實作搶紅包的功能。

實作步驟

注解方式配置 Redis

首先在類 RootConfig 上建立一個 RedisTemplate 對象,并将其裝載到 Spring IoC 容器中。

/**

* 建立一個 RedisTemplate 對象

*/

@Bean(name ="redisTemplate")

publicRedisTemplateinitRedisTemplate(){

JedisPoolConfig poolConfig =newJedisPoolConfig();

// 最大空閑數

poolConfig.setMaxIdle(50);

// 最大連接配接數

poolConfig.setMaxTotal(100);

// 最大等待毫秒數

poolConfig.setMaxWaitMillis(20000);

// 建立Jedis連結工廠

JedisConnectionFactory connectionFactory =newJedisConnectionFactory(poolConfig);

connectionFactory.setHostName("192.168.31.66");

connectionFactory.setPort(6379);

// 調用後初始化方法,沒有它将抛出異常

connectionFactory.afterPropertiesSet();

// 自定Redis序列化器

RedisSerializer jdkSerializationRedisSerializer =newJdkSerializationRedisSerializer();

RedisSerializer stringRedisSerializer =newStringRedisSerializer();

// 定義RedisTemplate,并設定連接配接工廠

RedisTemplate redisTemplate =newRedisTemplate();

redisTemplate.setConnectionFactory(connectionFactory);

// 設定序列化器

redisTemplate.setDefaultSerializer(stringRedisSerializer);

redisTemplate.setKeySerializer(stringRedisSerializer);

redisTemplate.setValueSerializer(stringRedisSerializer);

redisTemplate.setHashKeySerializer(stringRedisSerializer);

redisTemplate.setHashValueSerializer(stringRedisSerializer);

returnredisTemplate;

}

這樣 RedisTemplate 就可以在 Spring 上下文中使用了。

注意, JedisConnectionFactory對象在最後的時候需要自行調用 afterPropertiesSet 方法,它實作了 lnitializingBean 接 口。 如果将其配置在 Spring IoC 容器中, Spring 會自動調用它,但是這裡我們是自行建立的, 是以需要自行調用,否則在運用的時候會抛出異常。

lua腳本和異步持久化功能的開發

Redis 并不是一個嚴格的事務,而且事務的功能也是有限的 。加上 Redis 本身的指令也比較有限,功能性不強,為了增強功能性,還可以使用 Lua 語言。

Redis 中的 Lua 語言是一種原子性的操作,可以保證資料的一緻性。

依據這個原理可以避免超發現象,完成搶紅包的功能,而且對于性能而言, Redis 會比資料庫快得多。

第一次運作 Lua 腳本的時候,先在 Redis 中編譯和緩存腳本,這樣就可以得到一個 SHA1字元串,之後通過 SHAl 字元串和參數就能調用 Lua 腳本了。

--緩存搶紅包清單資訊清單 key

locallistKey ='red_packet_list_'..KEYS[1]

--目前被搶紅包 key

localredPacket ='red_packet_'..KEYS[1]

--擷取目前紅包庫存

localstock = tonumber(redis.call('hget', redPacket,'stock'))

--沒有庫存,傳回為 0

ifstock <= 0then

return0

end

--庫存減 1

stock = stock-1

--儲存目前庫存

redis.call('hset', redPacket,'stock', tostring(stock))

--往連結清單中加入目前紅包資訊

redis.call('rpush', listKey, ARGV[1])

--如果是最後一個紅包,則傳回 2 ,表示搶紅包已經結束,需要将清單中的資料儲存到資料庫中

ifstock == 0then

return2

--如果并非最後一個紅包,則傳回 l ,表示搶紅包成功

return1

流程:

判斷是否存在可搶的庫存,如果己經沒有可搶奪 的紅包,則傳回為 0,結束流程

有可搶奪的紅包,對于紅包的庫存減1 ,然後重新設定庫存

将搶紅包資料儲存到 Redis 的連結清單當中,連結清單的 key 為 red_packet_list_ {id}

如果目前庫存為 0 ,那麼傳回 2,這說明可以觸發資料庫對 Redis 連結清單資料的儲存,連結清單的 key 為 red_packet_ list_ {id},它将儲存搶紅包的使用者名和搶的時間

如果目前庫存不為 0 ,那麼将傳回 1,這說明搶紅包資訊儲存成功。

當傳回為 2 的時候,說明紅包己經沒有庫存,會觸發資料庫對連結清單資料的儲存, 這是一個大資料量的儲存。為了不影響最後一次搶紅包的響應,在實際的操作中往往會考慮使用 JMS 消息發送到别的伺服器進行操作,我們這裡選擇一種簡單的方式來實作,去建立一條新的線程去運作儲存 Redis 連結清單資料到資料庫。

那就在Service層寫一個持久到資料庫的服務類吧

接口

packagecom.artisan.redpacket.service;

publicinterfaceRedisRedPacketService{

* 儲存redis搶紅包清單

*@paramredPacketId --搶紅包編号

*@paramunitAmount -- 紅包金額

publicvoidsaveUserRedPacketByRedis(Long redPacketId, Double unitAmount);

實作類

packagecom.artisan.redpacket.service.impl;

importjava.sql.Connection;

importjava.sql.SQLException;

importjava.sql.Statement;

importjava.sql.Timestamp;

importjava.text.DateFormat;

importjava.text.SimpleDateFormat;

importjava.util.ArrayList;

importjava.util.List;

importjavax.sql.DataSource;

importorg.springframework.beans.factory.annotation.Autowired;

importorg.springframework.data.redis.core.BoundListOperations;

importorg.springframework.data.redis.core.RedisTemplate;

importorg.springframework.scheduling.annotation.Async;

importorg.springframework.stereotype.Service;

importcom.artisan.redpacket.pojo.UserRedPacket;

importcom.artisan.redpacket.service.RedisRedPacketService;

@Service

publicclassRedisRedPacketServiceImplimplementsRedisRedPacketService{

privatestaticfinalString PREFIX ="red_packet_list_";

// 每次取出1000條,避免一次取出消耗太多記憶體

privatestaticfinalintTIME_SIZE =1000;

@Autowired

privateRedisTemplate redisTemplate;// RedisTemplate

privateDataSource dataSource;// 資料源

@Override

// 開啟新線程運作

@Async

publicvoidsaveUserRedPacketByRedis(Long redPacketId, Double unitAmount){

System.err.println("開始儲存資料");

Long start = System.currentTimeMillis();

// 擷取清單操作對象

BoundListOperations ops = redisTemplate.boundListOps(PREFIX + redPacketId);

Long size = ops.size();

Long times = size % TIME_SIZE ==0? size / TIME_SIZE : size / TIME_SIZE +1;

intcount =0;

List userRedPacketList =newArrayList(TIME_SIZE);

for(inti =0; i < times; i++) {

// 擷取至多TIME_SIZE個搶紅包資訊

List userIdList =null;

if(i ==0) {

userIdList = ops.range(i * TIME_SIZE, (i +1) * TIME_SIZE);

}else{

userIdList = ops.range(i * TIME_SIZE +1, (i +1) * TIME_SIZE);

userRedPacketList.clear();

// 儲存紅包資訊

for(intj =0; j < userIdList.size(); j++) {

String args = userIdList.get(j).toString();

String[] arr = args.split("-");

String userIdStr = arr[0];

String timeStr = arr[1];

Long userId = Long.parseLong(userIdStr);

Long time = Long.parseLong(timeStr);

// 生成搶紅包資訊

UserRedPacket userRedPacket =newUserRedPacket();

userRedPacket.setRedPacketId(redPacketId);

userRedPacket.setUserId(userId);

userRedPacket.setAmount(unitAmount);

userRedPacket.setGrabTime(newTimestamp(time));

userRedPacket.setNote("搶紅包 "+ redPacketId);

userRedPacketList.add(userRedPacket);

// 插入搶紅包資訊

count += executeBatch(userRedPacketList);

// 删除Redis清單

redisTemplate.delete(PREFIX + redPacketId);

Long end = System.currentTimeMillis();

System.err.println("儲存資料結束,耗時"+ (end - start) +"毫秒,共"+ count +"條記錄被儲存。");

* 使用JDBC批量處理Redis緩存資料.

*

*@paramuserRedPacketList

*            -- 搶紅包清單

*@return搶紅包插入數量.

privateintexecuteBatch(List<UserRedPacket> userRedPacketList){

Connection conn =null;

Statement stmt =null;

int[] count =null;

try{

conn = dataSource.getConnection();

conn.setAutoCommit(false);

stmt = conn.createStatement();

for(UserRedPacket userRedPacket : userRedPacketList) {

String sql1 ="update T_RED_PACKET set stock = stock-1 where id="+ userRedPacket.getRedPacketId();

DateFormat df =newSimpleDateFormat("yyyy-MM-dd HH:mm:ss");

String sql2 ="insert into T_USER_RED_PACKET(red_packet_id, user_id, "+"amount, grab_time, note)"

+" values ("+ userRedPacket.getRedPacketId() +", "+ userRedPacket.getUserId() +", "

+ userRedPacket.getAmount() +","+"'"+ df.format(userRedPacket.getGrabTime()) +"',"+"'"

+ userRedPacket.getNote() +"')";

stmt.addBatch(sql1);

stmt.addBatch(sql2);

// 執行批量

count = stmt.executeBatch();

// 送出事務

conn.commit();

}catch(SQLException e) {

/********* 錯誤處理邏輯 ********/

thrownewRuntimeException("搶紅包批量執行程式錯誤");

}finally{

if(conn !=null&& !conn.isClosed()) {

conn.close();

e.printStackTrace();

// 傳回插入搶紅包資料記錄

returncount.length /2;

注解@Async 表示讓 Spring 自動建立另外一條線程去運作它,這樣它便不在搶最後一個紅包的線程之内。因為這個方法是一個較長時間的方法,如果在同一個線程内,那麼對于最後搶紅包的使用者需要等待的時間太長,使用者體驗不好

這裡是每次取出 1 000 個搶紅包的資訊,之是以這樣做是為了避免取出 的資料過大 , 導緻JVM 消耗過多的記憶體影響系統性能。

對于大批量的資料操作,這是我們在實際操作中要注意的,最後還會删除 Redis儲存的連結清單資訊,這樣就幫助 Redis 釋放記憶體了

對于資料庫的儲存 ,這裡采用了 JDBC的批量處理,每 1000 條批量儲存一次,使用批量有助于性能的提高。

注解@Async 的前提是提供一個任務池給 Spring 環境,這個時候要在原有的基礎上改寫配置類 WebConfig

@EnableAsync

publicclassWebConfigextendsAsyncConfigurerSupport{

....

publicExecutorgetAsyncExecutor(){

ThreadPoolTaskExecutor taskExecutor =newThreadPoolTaskExecutor();

taskExecutor.setCorePoolSize(5);

taskExecutor.setMaxPoolSize(10);

taskExecutor.setQueueCapacity(200);

taskExecutor.initialize();

returntaskExecutor;

使用@EnableAsync表明支援異步調用,而我們實作了接口AsyncConfigurerSupport的getAsyncExecutor方法,它是擷取一個任務池,當在 Spring 環境中遇到注解@Async就會啟動這個任務池的一條線程去運作對應的方法,這樣便能執行異步了。

Service層添加Redis搶紅包的邏輯

UserRedPacketService接口新增接口方法grapRedPacketByRedis

* 通過Redis實作搶紅包

*@paramredPacketId

*            --紅包編号

*@paramuserId

*            -- 使用者編号

*@return0-沒有庫存,失敗 1--成功,且不是最後一個紅包 2--成功,且是最後一個紅包

publicLong grapRedPacketByRedis(Long redPacketId, Long userId);

privateRedisTemplate redisTemplate;

privateRedisRedPacketService redisRedPacketService;

// Lua腳本

Stringscript ="local listKey = 'red_packet_list_'..KEYS[1] \n"

+"local redPacket = 'red_packet_'..KEYS[1] \n"

+"local stock = tonumber(redis.call('hget', redPacket, 'stock')) \n"

+"if stock <= 0 then return 0 end \n"

+"stock = stock -1 \n"

+"redis.call('hset', redPacket, 'stock', tostring(stock)) \n"

+"redis.call('rpush', listKey, ARGV[1]) \n"

+"if stock == 0 then return 2 end \n"

+"return 1 \n";

// 在緩存LUA腳本後,使用該變量儲存Redis傳回的32位的SHA1編碼,使用它去執行緩存的LUA腳本[加入這句話]

Stringsha1 =null;

publicLong grapRedPacketByRedis(Long redPacketId, Long userId) {

// 目前搶紅包使用者和日期資訊

Stringargs = userId +"-"+ System.currentTimeMillis();

Long result =null;

// 擷取底層Redis操作對象

Jedis jedis = (Jedis) redisTemplate.getConnectionFactory().getConnection().getNativeConnection();

// 如果腳本沒有加載過,那麼進行加載,這樣就會傳回一個sha1編碼

if(sha1 ==null) {

sha1 = jedis.scriptLoad(script);

// 執行腳本,傳回結果

Objectres = jedis.evalsha(sha1,1, redPacketId +"", args);

result = (Long) res;

// 傳回2時為最後一個紅包,此時将搶紅包資訊通過異步儲存到資料庫中

if(result ==2) {

// 擷取單個小紅包金額

StringunitAmountStr = jedis.hget("red_packet_"+ redPacketId,"unit_amount");

// 觸發儲存資料庫操作

Double unitAmount = Double.parseDouble(unitAmountStr);

redisRedPacketService.saveUserRedPacketByRedis(redPacketId, unitAmount);

// 確定jedis順利關閉

if(jedis !=null&& jedis.isConnected()) {

jedis.close();

returnresult;

這裡使用了儲存腳本傳回 的 SHAl 字元串 ,是以隻會發送一次腳本到 Redis 伺服器,之後隻傳輸 SHAl 字元串和參數到 Redis 就能執行腳本 了, 當腳本傳回為 2 的時候, 表示此時所有的紅包都已經被搶光了 ,那麼就會觸發 redisRedPacketService 的 saveUserRedPacketByRedis 方法。由于在 saveU serRedPacketByRedis 加入注解@Async , 是以 Spring 會建立一條新的線程去運作它 , 這樣就不會影響最後搶一個紅包使用者 的響應時間了 。

Controller層新增路由方法

@RequestMapping(value ="/grapRedPacketByRedis")

@ResponseBody

publicMap grapRedPacketByRedis(Long redPacketId, Long userId) {

Map resultMap =newHashMap();

Long result = userRedPacketService.grapRedPacketByRedis(redPacketId, userId);

booleanflag = result >0;

resultMap.put("result", flag);

resultMap.put("message", flag ?"搶紅包成功":"搶紅包失敗");

returnresultMap;

構造模拟資料,測試

先在 Redis 上添加紅包資訊

127.0.0.1:6379>HMSETred_packet_1stock20000unit_amount10

OK

初始化了一個編号為1 的大紅包,其中庫存為 2 萬個,每個 10 元. 需要保證資料庫的紅包表内也有對應的記錄才可以。

複制個grapByRedis.jsp,測試吧

<%@pagelanguage="java"contentType="text/html; charset=UTF-8"

pageEncoding="UTF-8"%>

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">

<!-- 加載Query檔案-->

src="https://code.jquery.com/jquery-3.2.0.js">

$(document).ready(function(){

//模拟30000個異步請求,進行并發

varmax =30000;

for(vari =1; i <= max; i++) {

$.post({

//請求搶id為1的紅包

//根據自己請求修改對應的url和大紅包編号

url:"./userRedPacket/grapRedPacketByRedis.do?redPacketId=1&userId=1",

//成功後的方法

success:function(result){

console.log("OK")

});

啟動應用,通路 http://localhost:8080/ssm_redpacket/grapByRedis.jsp

搶紅包案例分析以及代碼實作(四)
搶紅包案例分析以及代碼實作(四)

結合前幾篇的資料統計,使用Redis的方式資料一緻性也得到了保證且性能遠遠高于樂觀鎖和悲觀鎖的方式。

代碼

https://github.com/yangshangwei/ssm_redpacket

好了,搶紅包案例到此就講解完了,下面是對這一系列文章的整體總結。

總結

搶紅包案例分析以及代碼實作(四)

(全劇終)

搶紅包案例分析以及代碼實作(四)