搶紅包案例分析以及程式碼實現(四)

Java團長_發表於2018-11-19

前文回顧



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

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

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

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

所以本篇博文我們將使用Redis + lua指令碼來實現搶紅包的功能。


實現步驟

註解方式配置 Redis

首先在類 RootConfig 上建立一個 RedisTemplate 物件,並將其裝載到 Spring IoC 容器中。

/**
  * 建立一個 RedisTemplate 物件
  */

 @Bean(name = "redisTemplate")
 public RedisTemplate initRedisTemplate() {
   JedisPoolConfig poolConfig = new JedisPoolConfig();
   // 最大空閒數
   poolConfig.setMaxIdle(50);
   // 最大連線數
   poolConfig.setMaxTotal(100);
   // 最大等待毫秒數
   poolConfig.setMaxWaitMillis(20000);
   // 建立Jedis連結工廠
   JedisConnectionFactory connectionFactory = new JedisConnectionFactory(poolConfig);
   connectionFactory.setHostName("192.168.31.66");
   connectionFactory.setPort(6379);
   // 呼叫後初始化方法,沒有它將丟擲異常
   connectionFactory.afterPropertiesSet();
   // 自定Redis序列化器
   RedisSerializer jdkSerializationRedisSerializer = new JdkSerializationRedisSerializer();
   RedisSerializer stringRedisSerializer = new StringRedisSerializer();
   // 定義RedisTemplate,並設定連線工廠
   RedisTemplate redisTemplate = new RedisTemplate();
   redisTemplate.setConnectionFactory(connectionFactory);
   // 設定序列化器
   redisTemplate.setDefaultSerializer(stringRedisSerializer);
   redisTemplate.setKeySerializer(stringRedisSerializer);
   redisTemplate.setValueSerializer(stringRedisSerializer);
   redisTemplate.setHashKeySerializer(stringRedisSerializer);
   redisTemplate.setHashValueSerializer(stringRedisSerializer);
   return redisTemplate;
 }


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

注意, JedisConnectionFactory物件在最後的時候需要自行呼叫 afterPropertiesSet 方法,它實現了 lnitializingBean 接 口。 如果將其配置在 Spring IoC 容器中, Spring 會自動呼叫它,但是這裡我們是自行建立的, 因此需要自行呼叫,否則在運用的時候會丟擲異常。


lua指令碼和非同步持久化功能的開發

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

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

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

第一次執行 Lua 指令碼的時候,先在 Redis 中編譯和快取指令碼,這樣就可以得到一個 SHA1字串,之後通過 SHAl 字串和引數就能呼叫 Lua 指令碼了。



--快取搶紅包列表資訊列表 key
local listKey = 'red_packet_list_'..KEYS[1]  
--當前被搶紅包 key
local redPacket = 'red_packet_'..KEYS[1]
--獲取當前紅包庫存
local stock = tonumber(redis.call('hget', redPacket, 'stock'))
--沒有庫存,返回為 0
if stock <= 0 then
 return 0
end
--庫存減 1
stock = stock-1
--儲存當前庫存
redis.call('hset', redPacket, 'stock', tostring(stock))
--往連結串列中加入當前紅包資訊
redis.call('rpush', listKey, ARGV[1])  
--如果是最後一個紅包,則返回 2 ,表示搶紅包已經結束,需要將列表中的資料儲存到資料庫中
if stock == 0 then
 return 2
end  
--如果並非最後一個紅包,則返回 l ,表示搶紅包成功
return 1


流程:

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

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

  • 將搶紅包資料儲存到 Redis 的連結串列當中,連結串列的 key 為 red_packet_list_ {id}

  • 如果當前庫存為 0 ,那麼返回 2,這說明可以觸發資料庫對 Redis 連結串列資料的儲存,連結串列的 key 為 red_packet_ list_ {id},它將儲存搶紅包的使用者名稱和搶的時間

  • 如果當前庫存不為 0 ,那麼將返回 1,這說明搶紅包資訊儲存成功。


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

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

介面

package com.artisan.redpacket.service;

public interface RedisRedPacketService {

 /**
  * 儲存redis搶紅包列表
  * @param redPacketId --搶紅包編號
  * @param unitAmount -- 紅包金額
  */

 public void saveUserRedPacketByRedis(Long redPacketId, Double unitAmount);
}


實現類

package com.artisan.redpacket.service.impl;

import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import com.artisan.redpacket.pojo.UserRedPacket;
import com.artisan.redpacket.service.RedisRedPacketService;

@Service
public class RedisRedPacketServiceImpl implements RedisRedPacketService {

 private static final String PREFIX = "red_packet_list_";
 // 每次取出1000條,避免一次取出消耗太多記憶體
 private static final int TIME_SIZE = 1000;

 @Autowired
 private RedisTemplate redisTemplate; // RedisTemplate

 @Autowired
 private DataSource dataSource; // 資料來源

 @Override
 // 開啟新執行緒執行
 @Async
 public void saveUserRedPacketByRedis(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;
   int count = 0;
   List<UserRedPacket> userRedPacketList = new ArrayList<UserRedPacket>(TIME_SIZE);
   for (int i = 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 (int j = 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 = new UserRedPacket();
       userRedPacket.setRedPacketId(redPacketId);
       userRedPacket.setUserId(userId);
       userRedPacket.setAmount(unitAmount);
       userRedPacket.setGrabTime(new Timestamp(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快取資料.
  *
  * @param userRedPacketList
  *            -- 搶紅包列表
  * @return 搶紅包插入數量.
  */

 private int executeBatch(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 = new SimpleDateFormat("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) {
     /********* 錯誤處理邏輯 ********/
     throw new RuntimeException("搶紅包批量執行程式錯誤");
   } finally {
     try {
       if (conn != null && !conn.isClosed()) {
         conn.close();
       }
     } catch (SQLException e) {
       e.printStackTrace();
     }
   }
   // 返回插入搶紅包資料記錄
   return count.length / 2;
 }
}


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

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

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

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

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

@EnableAsync
public class WebConfig extends AsyncConfigurerSupport {
 ....
 ....
 ....
 @Override
 public Executor getAsyncExecutor() {
   ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
   taskExecutor.setCorePoolSize(5);
   taskExecutor.setMaxPoolSize(10);
   taskExecutor.setQueueCapacity(200);
   taskExecutor.initialize();
   return taskExecutor;
 }
}


使用@EnableAsync 表明支援非同步呼叫,而我們實現了介面 AsyncConfigurerSupport 的getAsyncExecutor 方法,它是獲取一個任務池,當在 Spring 環境中遇到註解@Async就會啟動這個任務池的一條執行緒去執行對應的方法,這樣便能執行非同步了。


Service層新增Redis搶紅包的邏輯

UserRedPacketService介面新增介面方法grapRedPacketByRedis

/**
  * 通過Redis實現搶紅包
  *
  * @param redPacketId
  *            --紅包編號
  * @param userId
  *            -- 使用者編號
  * @return 0-沒有庫存,失敗 1--成功,且不是最後一個紅包 2--成功,且是最後一個紅包
  */

 public Long grapRedPacketByRedis(Long redPacketId, Long userId);


實現類

@Autowired
 private RedisTemplate redisTemplate;

 @Autowired
 private RedisRedPacketService redisRedPacketService;

 // Lua指令碼
 String script = "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指令碼[加入這句話]
 String sha1 = null;

 @Override
 public Long grapRedPacketByRedis(Long redPacketId, Long userId) {
   // 當前搶紅包使用者和日期資訊
   String args = userId + "-" + System.currentTimeMillis();
   Long result = null;
   // 獲取底層Redis操作物件
   Jedis jedis = (Jedis) redisTemplate.getConnectionFactory().getConnection().getNativeConnection();
   try {
     // 如果指令碼沒有載入過,那麼進行載入,這樣就會返回一個sha1編碼
     if (sha1 == null) {
       sha1 = jedis.scriptLoad(script);
     }
     // 執行指令碼,返回結果
     Object res = jedis.evalsha(sha1, 1, redPacketId + "", args);
     result = (Long) res;
     // 返回2時為最後一個紅包,此時將搶紅包資訊通過非同步儲存到資料庫中
     if (result == 2) {
       // 獲取單個小紅包金額
       String unitAmountStr = jedis.hget("red_packet_" + redPacketId, "unit_amount");
       // 觸發儲存資料庫操作
       Double unitAmount = Double.parseDouble(unitAmountStr);
              redisRedPacketService.saveUserRedPacketByRedis(redPacketId, unitAmount);
     }
   } finally {
     // 確保jedis順利關閉
     if (jedis != null && jedis.isConnected()) {
       jedis.close();
     }
   }
   return result;
 }


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


Controller層新增路由方法

@RequestMapping(value = "/grapRedPacketByRedis")
 @ResponseBody
 public Map<String, Object> grapRedPacketByRedis(Long redPacketId, Long userId) {
   Map<String, Object> resultMap = new HashMap<String, Object>();
   Long result = userRedPacketService.grapRedPacketByRedis(redPacketId, userId);
   boolean flag = result > 0;
   resultMap.put("result", flag);
   resultMap.put("message", flag ? "搶紅包成功" : "搶紅包失敗");
   return resultMap;
 }

構造模擬資料,測試

先在 Redis 上新增紅包資訊

127.0.0.1:6379> HMSET red_packet_1 stock 20000 unit_amount 10
OK


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

複製個grapByRedis.jsp,測試吧

<%@ page language="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">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>引數</title>
<!-- 載入Query檔案-->
<script type="text/javascript"
 src="https://code.jquery.com/jquery-3.2.0.js">

       
</script>
<script type="text/javascript">
           $(document).ready(function () {
             //模擬30000個非同步請求,進行併發
             var max = 30000;
             for (var i = 1; i <= max; i++) {
               $.post({
                   //請求搶id為1的紅包
                   //根據自己請求修改對應的url和大紅包編號
                   url: "./userRedPacket/grapRedPacketByRedis.do?redPacketId=1&userId=1",
                   //成功後的方法
                   success: function (result) {
                     console.log("OK")
                   }
               });
               }
         });
       
</script>
</head>
<body>
</body>
</html>


啟動應用,訪問 http://localhost:8080/ssm_redpacket/grapByRedis.jsp

640

640

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


程式碼

https://github.com/yangshangwei/ssm_redpacket


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


總結

640

(全劇終)


Java團長

專注於Java乾貨分享

640

掃描上方二維碼獲取更多Java乾貨

相關文章