深入淺出boot2.0 第15章,搶購商品(悲觀樂觀鎖 redis處理併發)

設計與開發

表結構

CREATE TABLE `t_product` (
  `id` int(12) NOT NULL AUTO_INCREMENT COMMENT '產品編號',
  `product_name` varchar(60) NOT NULL COMMENT '產品名稱',
  `stock` int(10) NOT NULL COMMENT '庫存',
  `price` decimal(16,2) NOT NULL COMMENT '單價',
  `version` int(10) NOT NULL DEFAULT '0' COMMENT '版本號',
  `note` varchar(255) DEFAULT NULL COMMENT '備註',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='產品信息表';


CREATE TABLE `t_purchase_record` (
  `id` int(12) NOT NULL AUTO_INCREMENT COMMENT '編號',
  `user_id` int(12) NOT NULL COMMENT '用戶編號',
  `product_id` int(12) NOT NULL COMMENT '產品編號',
  `price` decimal(16,2) NOT NULL COMMENT '價格',
  `quantity` int(12) NOT NULL COMMENT '數量',
  `sum` decimal(12,2) NOT NULL COMMENT '總價',
  `purchase_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '購買日期',
  `note` varchar(512) DEFAULT NULL COMMENT '備註',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='購買信息表';

  • 判斷產品表 的產品 有沒有足夠的庫存 支持用戶的購買,如果有 則對產品 減庫存
  • 然後在 將 購買信息 插入到購買記錄中,如果庫存不足,則返回交易失敗。

pom引用



		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-aop</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-redis</artifactId>
			<exclusions>
				<!--不依賴Redis的異步客戶端lettuce -->
				<exclusion>
					<groupId>io.lettuce</groupId>
					<artifactId>lettuce-core</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<!--引入Redis的客戶端驅動jedis -->
		<dependency>
			<groupId>redis.clients</groupId>
			<artifactId>jedis</artifactId>
		</dependency>

		//連接池,可以不引用
		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-pool2</artifactId>
		</dependency>

		//web 必須引用
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		//mybatis 必須引用
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>1.3.2</version>
		</dependency>

		//驅動必須引用
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-tomcat</artifactId>
			<scope>provided</scope>
		</dependency>

		<dependency>
			<groupId>org.apache.tomcat.embed</groupId>
			<artifactId>tomcat-embed-jasper</artifactId>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>javax.servlet</groupId>
			<artifactId>jstl</artifactId>
			<scope>provided</scope>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	

MyBatis 開發持久層

pojo

@Alias("product")
public class ProductPo implements Serializable {
    private static final long serialVersionUID = 3L;
    private Long id;
    private String productName;
    private int stock;
    private double price;
    private int version;
    private String note;
}

@Alias("purchaseRecord")
public class PurchaseRecordPo implements Serializable {
	private static final long serialVersionUID = -3L;
	private Long id;
	private Long userId;
	private Long productId;
	private double price;
	private int quantity;
	private double sum;
	private Timestamp purchaseTime;
	private String note;
}
  • 定義 Alias 的別名

mapper文件

ProductMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.springboot.chapter15.dao.ProductDao">
    
	<!-- 獲取產品 -->
	<select id="getProduct" parameterType="long" resultType="product">
		select id, product_name as productName,
		stock, price, version, note from t_product
		where id=#{id}
	</select>

	<!-- 減庫存 -->
	<update id="decreaseProduct">
		update t_product set stock = stock - #{quantity},
		version = version +1
		where id = #{id} 
	</update>
    
</mapper>

PurchaseRecordMapper.xml


<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.springboot.chapter15.dao.PurchaseRecordDao">
    
	<insert id="insertPurchaseRecord" parameterType="purchaseRecord">
		insert into t_purchase_record(
		user_id, product_id, price, quantity, sum, purchase_date, note)
		values(#{userId}, #{productId}, #{price}, #{quantity},
		#{sum}, now(), #{note})
	</insert>
    
</mapper>

dao

@Mapper
public interface ProductDao {
    // 獲取產品
    public ProductPo getProduct(Long id);

     //減庫存,而@Param標明MyBatis參數傳遞給後臺
    public int decreaseProduct(@Param("id") Long id, @Param("quantity") int quantity);
    
//    public int decreaseProduct(@Param("id") Long id, 
//    	    @Param("quantity") int quantity, @Param("version") int version);
}
@Mapper
public interface PurchaseRecordDao {
    public int insertPurchaseRecord(PurchaseRecordPo pr);
}

開發業務層 和 控制層

service

public interface PurchaseService {	
	/**
	 * 處理購買業務
	 * @param userId 用戶編號
	 * @param productId 產品編號
	 * @param quantity 購買數量
	 * @return 成功or失敗
	 */
	public boolean purchase(Long userId, Long productId, int quantity);

	boolean purchaseRedis(Long userId, Long productId, int quantity);

	boolean dealRedisPurchase(List<PurchaseRecordPo> prpList);
}
@Service
public class PurchaseServiceImpl implements PurchaseService {
    @Autowired
    private ProductDao productDao = null;
    @Autowired
    private PurchaseRecordDao purchaseRecordDao = null;

    @Override
    // 啓動Spring數據庫事務機制
    @Transactional
    public boolean purchase(Long userId, Long productId, int quantity) {
        // 獲取產品
        ProductPo product = productDao.getProduct(productId);
        // 比較庫存和購買數量
        if (product.getStock() < quantity) {
            // 庫存不足
            return false;
        }
        // 扣減庫存
        productDao.decreaseProduct(productId, quantity);
        // 初始化購買記錄
        PurchaseRecordPo pr = this.initPurchaseRecord(userId, product, quantity);
        // 插入購買記錄
        purchaseRecordDao.insertPurchaseRecord(pr);
        return true;
    }
    
        // 初始化購買信息
    private PurchaseRecordPo initPurchaseRecord(Long userId, ProductPo product, int quantity) {
        PurchaseRecordPo pr = new PurchaseRecordPo();
        pr.setNote("購買日誌,時間:" + System.currentTimeMillis());
        pr.setPrice(product.getPrice());
        pr.setProductId(product.getId());
        pr.setQuantity(quantity);
        double sum = product.getPrice() * quantity;
        pr.setSum(sum);
        pr.setUserId(userId);
        return pr;
    }
    //第一版完畢
}

controller

// REST風格控制器
@RestController
public class PurchaseController {
	@Autowired
	PurchaseService purchaseService = null;

	// 定義JSP視圖
	@GetMapping("/test")
	public ModelAndView testPage() {
		ModelAndView mv = new ModelAndView("test");
		return mv;
	}

	@PostMapping("/purchase")
	public Result purchase(Long userId, Long productId, Integer quantity) {
		boolean success = purchaseService.purchaseRedis(userId, productId, quantity);
		String message = success ? "搶購成功" : "搶購失敗";
		Result result = new Result(success, message);
		return result;
	}

	// 響應結果
	class Result {
		private boolean success = false;
		private String message = null;

		public Result() {
		}

		public Result(boolean success, String message) {
			this.success = success;
			this.message = message;
		}

		/**** setter and getter ****/
	}
}

配置和測試

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>
    <title>購買產品測試</title>
    <script type="text/javascript"
            src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
</head>
<!--後面需要改寫這段JavaScript腳本進行測試-->
<script type="text/javascript">

    var params = {
        userId : 1,
        productId : 1,
        quantity : 3
    };
    // 通過POST請求後端
    $.post("./purchase", params, function(result) {
        alert(result.message);
    });

    for (var i = 1; i <= 50000; i++) {
        var params = {
            userId: 1,
            productId: 1,
            quantity: 1
        };
        // 通過POST請求後端,這裏的JavaScript會採用異步請求
        $.post("./purchase", params, function (result) {
        });
    }
</script>
<body>
<h1>搶購產品測試</h1>
</body>
</html>

配置

spring.mvc.view.prefix=/WEB-INF/jsp/
spring.mvc.view.suffix=.jsp

########## 數據庫配置 ##########
spring.datasource.url=jdbc:mysql://localhost:3306/spring_boot_chapter15
spring.datasource.username=root
spring.datasource.password=123456

#spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.tomcat.max-idle=10
spring.datasource.tomcat.max-active=50
spring.datasource.tomcat.max-wait=10000
spring.datasource.tomcat.initial-size=5

# 採用隔離級別爲讀寫提交
spring.datasource.tomcat.default-transaction-isolation=2

########## MyBatis配置 ##########
# 映射文件
mybatis.mapper-locations=classpath:com/springboot/chapter15/mapper/*.xml
# 掃描別名
mybatis.type-aliases-package=com.springboot.chapter15.pojo

main方法

// 定義掃描包
@SpringBootApplication(scanBasePackages = "com.springboot.chapter15")

// 定義掃描MyBatis接口
@MapperScan(annotationClass = Mapper.class, basePackages = "com.springboot.chapter15")

@EnableScheduling
public class Chapter15Application {

	public static void main(String[] args) {
		SpringApplication.run(Chapter15Application.class, args);
	}
}

高併發測試

  • 線程1:讀取庫存爲1,可購買

  • 線程2:讀取庫存爲1,可購買

  • 線程1,扣減庫存。此時庫存爲0

  • 線程2:扣減庫存。此時庫存爲 -1 。超發了。

  • 線程1:插入交易記錄。

  • 線程2:插入交易記錄,錯誤,庫存已經不足。

  • 線程2,此時並不會 感知 線程1 的這個操作。而是按照 原來讀取到的1,進行扣減。

  • 這樣就會 出現 -1 。

悲觀鎖

  • 共享的數據 被 多個線程 所 修改,無法保證 其 執行順序。
  • 如果一個數據庫事務 讀到 產品後,就將數據 直接鎖定,不允許其他線程讀寫,直到 當前事務完成後,才釋放這條數據,則不會出現。超發的問題。
	<!-- 獲取產品 -->
	<select id="getProduct" parameterType="long" resultType="product">
		select id, product_name as productName,
		stock, price, version, note from t_product
		where id=#{id} for update
	</select>
  • for update ,這樣 數據庫事務 執行的過程中,就會鎖定 查詢出來的數據,其他事務將 不能再對其進行讀寫(其他線程執行這行代碼的時候,就會進入等待)。
  • 不加鎖用28s,加悲觀鎖 用了 33秒。
  • 加入事務2 得到商品信息的鎖,那麼事務 1,3,n 就必須等待 持有 商品信息的 事務 2,結束後 釋放商品信息,才能去搶奪 商品信息,這樣就會有大量的線程 被 掛機 和 等待。
  • 悲觀鎖:使用數據庫內部的鎖,對記錄進行加鎖。
  • 悲觀鎖:也成 獨佔鎖 或 排他鎖

樂觀鎖

樂觀鎖設計

  • 雖然 悲觀鎖 可以解決 高併發的超發 現象,但並不是一個高效的方案

  • 樂觀鎖:是一種,不使用 數據庫鎖 和 不阻塞 線程 併發 的方案

  • 非獨佔鎖, 無阻塞鎖

  • 一個線程 先讀取 既有的商品 庫存數據,保存起來,(舊值)

  • 等到 需要對 共享數據 做修改時,會事先 將 保存的舊值庫存 與 當前數據庫的 庫存進行比較。

  • 如果 一致,就認爲沒有被修改過, 否則就認爲 已經被修改過,(當前計算不被信任,不在修改數據)

  • 保存 舊值——處理業務——舊值 與 當前數據庫存 一致

    • ——是 扣減庫存
    • ——否 不執行邏輯

ABA現象 (先A在B,又A)

  • 這個方案 就是 多線程的概念,CAS compare and swap

  • 會引發 ABA 問題

  • 線程1 讀取到 A件,保存爲A件

  • 線程2 讀取到A件,保存爲A件

  • 線程2 扣減庫存C件,剩下B件。(當前數據庫爲A件,與線程舊值一致,成功)

  • 線程1,計算剩餘商品的價格(總價),會 按照剩餘B件 計算。

  • 線程2,取消購買,庫存回退A件。

    • 此時 線程 1 的結果是錯的。
  • 線程1 計算商品總價格的時候,當前庫存 會被線程 2 所修改。

  • 稱爲 ABA問題

  • 線程1 在計算商品 總價格時,

  • 當前 庫存是一個變化的值,這樣就可能出現計算錯誤。

  • 共享值回退,導致了數據的不一致。

  • 引入版本號

解決ABA,引入版本號

  • 規定:只要操作 過程中 修改共享值,無論 業務正常 回退 還是異常

  • 版本號 只增不減

  • 線程1 讀取版本號爲1

  • 線程2 讀取版本號爲 1

  • 線程2 扣減庫存C件, 剩下B件。 版本爲2

  • 線程2 取消購買,庫存回退爲A件。 版本爲 3

  • 線程1 ,計算商品價格 記錄的是 版本爲1 ,當前已經爲 3 了。所以 取消業務。


	<!-- 減庫存 -->
	<update id="decreaseProduct">
		update t_product set stock = stock - #{quantity},
		version = version +1
		where id = #{id} and version = #{version}
	</update>

  • and version = #{version} 判斷,有沒有別的事務已經修改過數據
  • 一旦 版本號 修改失敗,則什麼數據 也不會 觸發更新

使用樂觀鎖,版本號處理

    public int decreaseProduct(@Param("id") Long id, @Param("quantity") int quantity, @Param("version") int version);
UPDATE t_product //更新這個表
SET stock = stock - 1, //設置爲:剩餘數量 -1 ,
 version = version + 1  //版本號 +1 
WHERE
	id = '1'   //id 爲 1 的值
AND version = 1 //並且 版本號,也是 1 ,才更新。
    // 啓動Spring數據庫事務機制
    @Transactional(isolation =Isolation.READ_COMMITTED)
    public boolean purchase(Long userId, Long productId, int quantity) {
        // 獲取產品(線程舊值)
        ProductPo product = productDao.getProduct(productId);
        // 比較庫存和購買數量
        if (product.getStock() < quantity) {
            // 庫存不足
            return false;
        }
        
        // 獲取當前版本號
        int version = product.getVersion();
        
        // 扣減庫存,同時將當前版本號發送給後臺去比較
        int result = productDao.decreaseProduct(productId, quantity, version);
        
        // 如果更新數據失敗,說明數據在多線程中被其他線程修改,導致失敗返回
        if (result == 0) {
            return false;
        }
        
        // 初始化購買記錄
        PurchaseRecordPo pr = this.initPurchaseRecord(userId, product, quantity);
        // 插入購買記錄
        purchaseRecordDao.insertPurchaseRecord(pr);
        return true;
    }
  • 耗時 27s ,5萬個請求過去,還有庫存。沒有超發。
  • 因爲加入了版本號的判斷,大量的請求得到失敗的結果。
  • 這個失敗率比較高。

樂觀鎖 加入 重入機制

  • 一旦更新失敗,就重新做 一次,稱樂觀鎖爲 可重入的鎖
  • 其原理:一單發現 版本號被更新,不是結束請求,而是重新做一次流程。直到成功爲止
  • 會帶來另一個問題:造成大量的SQL被執行
    • 一個請求需要執行3條SQL,重入需要 3次,那麼就要12條sql ,會給數據帶來壓力
    • 爲了克服:使用 限制 時間 或 重入次數。壓制過多的SQL

使用是時間戳 限制重入

  • 一個請求 限制 100ms的生存期

  • 100ms 內發生版本號衝突,則重試

        @Override
        // 啓動Spring數據庫事務機制
        @Transactional(isolation = Isolation.READ_COMMITTED)
        public boolean purchase(Long userId, Long productId, int quantity) {
            // 當前時間
            long start = System.currentTimeMillis();
            
            // 循環嘗試直至成功
            while (true) {
                
                // 循環時間
                long end = System.currentTimeMillis();
                
                // 如果循環時間大於100毫秒返回終止循環
                if (end - start > 100) {
                    
                    return false;
                }
                
                // 獲取產品
                ProductPo product = productDao.getProduct(productId);
                
                // 獲取當前版本號
                int version = product.getVersion();
                
                // 比較庫存和購買數量
                if (product.getStock() < quantity) {
                    
                    // 庫存不足
                    return false;
                }
    
                // 扣減庫存,同時將當前版本號發送給後臺去比較
                int result = productDao.decreaseProduct(productId, quantity, version);
                
                // 如果更新數據失敗,說明數據在多線程中被其他線程修改,
                // 導致失敗,則通過循環重入嘗試購買商品
                if (result == 0) {
                    
                    continue;
                }
                // 初始化購買記錄
                PurchaseRecordPo pr = this.initPurchaseRecord(userId, product, quantity);
                
                // 插入購買記錄
                purchaseRecordDao.insertPurchaseRecord(pr);
                return true;
            }
        }
    
    
            // 當前時間
            long start = System.currentTimeMillis();
            // 循環嘗試直至成功
            while (true) {
                // 循環時間
                long end = System.currentTimeMillis();
                
                 // 如果循環時間大於100毫秒返回終止循環
                if (end - start > 100) {
                    return false;
                }
                
                // 導致失敗,則通過循環重入嘗試購買商品
                if (result == 0) {
                    continue;
                }
                
                 return true;
            }
    
  • 按照時間戳 重入 也有一個弊端:系統會隨自身的忙碌,而大大減少重入的次數

  • 因此有時候也會採用 按照次數重入

按照限定次數 重入的樂觀鎖

    @Override
    // 啓動Spring數據庫事務機制,並將隔離級別設置爲讀寫提交
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public boolean purchase(Long userId, Long productId, int quantity) {

        // 循環嘗試直至成功
        for (int i = 0; i < 3; i++) {

            // 獲取產品
            ProductPo product = productDao.getProduct(productId);
            // 比較庫存和購買數量
            if (product.getStock() < quantity) {
                // 庫存不足
                return false;
            }
            // 獲取當前版本號
            int version = product.getVersion();
            // 扣減庫存,同時將當前版本號發送給後臺去比較
            int result = productDao.decreaseProduct(productId, quantity,version);
            // 如果更新數據失敗,說明數據在多線程中被其他線程修改,
            // 導致失敗,則通過循環重入嘗試購買商品
            if (result == 0) {
                continue;
            }
            // 初始化購買記錄
            PurchaseRecordPo pr = this.initPurchaseRecord(userId, product, quantity);
            
            // 插入購買記錄
            purchaseRecordDao.insertPurchaseRecord(pr);
            
            return true;
        }
        return false;
    }
  • 樂觀鎖:不使用 數據庫鎖的機制
    • 不會造成線程的阻塞,只是採用多版本號 機制來實現
    • 因爲版本的衝突造成了 請求失敗的概率增加 ——往往需要重入的機制機制。
    • 重入又會造成 多執行SQL,可以時間戳 或限制重入次數。
    • 或者用 redis

使用redis處理高併發

  • 數據庫 是 寫入磁盤的過程。

  • redis : 寫入內存 (是 數據庫的 幾倍 或 數十倍)

    • 其命令方式,運算能力比較薄弱(redis lua命令代替)。
    • redis lua 執行中 ,具備 原子性
    • 使用 redis 去 替代 數據庫作爲 響應用戶的數據載體
    • 要處理 redis 存儲的不穩定,還需要 有一定的機制 將redis 存儲的數據刷入數據庫中
  • 設計思路

    • 先用 redis響應高併發用戶的請求
    • 及時的將 數據保存到數據庫,啓用定時任務去查找redis,將它們保存到數據庫中

redis 配置

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-redis</artifactId>
			<exclusions>
				<!--不依賴Redis的異步客戶端lettuce -->
				<exclusion>
					<groupId>io.lettuce</groupId>
					<artifactId>lettuce-core</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<!--引入Redis的客戶端驅動jedis -->
		<dependency>
			<groupId>redis.clients</groupId>
			<artifactId>jedis</artifactId>
		</dependency>
#最小 活躍 最大 最大等待
spring.redis.jedis.pool
.min-idle=5
.max-active=10
.max-idle=10
.max-wait=2000

# port host pwd timeout
spring.redis
.port=6379
.host=192.168.2.198
.password=123456
.timeout=1000
  • 自動生成,redistTemplate,StringRedisTemplate

Redis 的 Lua編程

    @Autowired
    StringRedisTemplate stringRedisTemplate = null;

    String purchaseScript =
            // 先將產品編號保存到集合中
            " redis.call('sadd', KEYS[1], ARGV[2]) \n"
                    // 購買列表
                    + "local productPurchaseList = KEYS[2]..ARGV[2] \n"
                    // 用戶編號
                    + "local userId = ARGV[1] \n"
                    // 產品key
                    + "local product = 'product_'..ARGV[2] \n"
                    // 購買數量
                    + "local quantity = tonumber(ARGV[3]) \n"
                    // 當前庫存
                    + "local stock = tonumber(redis.call('hget', product, 'stock')) \n"
                    // 價格
                    + "local price = tonumber(redis.call('hget', product, 'price')) \n"
                    // 購買時間
                    + "local purchase_date = ARGV[4] \n"
                    // 庫存不足,返回0
                    + "if stock < quantity then return 0 end \n"
                    // 減庫存
                    + "stock = stock - quantity \n"
                    + "redis.call('hset', product, 'stock', tostring(stock)) \n"
                    // 計算價格
                    + "local sum = price * quantity \n"
                    // 合併購買記錄數據
                    + "local purchaseRecord = userId..','..quantity..','"
                    + "..sum..','..price..','..purchase_date \n"
                    // 保存到將購買記錄保存到list裏
                    + "redis.call('rpush', productPurchaseList, purchaseRecord) \n"
                    // 返回成功
                    + "return 1 \n";


    // Redis購買記錄集合前綴
    private static final String PURCHASE_PRODUCT_LIST = "purchase_list_";
    // 搶購商品集合
    private static final String PRODUCT_SCHEDULE_SET = "product_schedule_set";
    // 32位SHA1編碼,第一次執行的時候先讓Redis進行緩存腳本返回
    private String sha1 = null;

    @Override
    public boolean purchaseRedis(Long userId, Long productId, int quantity) {
        // 購買時間
        Long purchaseDate = System.currentTimeMillis();
        Jedis jedis = null;
        try {
            // 獲取原始連接
            jedis = (Jedis) stringRedisTemplate
                    .getConnectionFactory().getConnection().getNativeConnection();
            
            // 如果沒有加載過,則先將腳本加載(緩存)到Redis服務器,讓其返回sha1
            if (sha1 == null) {
                sha1 = jedis.scriptLoad(purchaseScript);
            }
            
            // 執行腳本,返回結果
            Object res = jedis.evalsha(sha1, 2, PRODUCT_SCHEDULE_SET,
                    PURCHASE_PRODUCT_LIST, userId + "", productId + "",
                    quantity + "", purchaseDate + "");
            //sha1 代表32位 的 SHA1編碼
            //2 代表 將前面 兩個參數 以鍵 的形式 傳遞到 腳本中
            //後面兩個常量是鍵 。 lua 腳本中 用 keys[index] 表示。keys[1] 第一個鍵。
            //第二個參數 之後,則都是腳本的參數 Lua argv[index]表示,同樣 keys[1] 表示
            //
            
            Long result = (Long) res;
            
            return result == 1;
            
        } finally {
            // 關閉jedis連接
            if (jedis != null && jedis.isConnected()) {
                jedis.close();
            }
        }
    }
  • 保存購買信息
  • 將購買記錄 保存到 數據庫中

    @Override
    // 當運行方法啓用新的獨立事務運行。回滾時,只會回滾 這個方法的內部事務。
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean dealRedisPurchase(List<PurchaseRecordPo> prpList) {
        for (PurchaseRecordPo prp : prpList) {
            purchaseRecordDao.insertPurchaseRecord(prp);
            productDao.decreaseProduct(prp.getProductId(), prp.getQuantity());
        }
        return true;
    }

定時任務,把redis中數據保存到數據

  • @EnableScheduling
    
@Service
public class TaskServiceImpl implements TaskService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate = null;
    @Autowired
    private PurchaseService purchaseService = null;

    private static final String PRODUCT_SCHEDULE_SET = "product_schedule_set";
    private static final String PURCHASE_PRODUCT_LIST = "purchase_list_";
    
    
    // 每次取出1000條,避免一次取出消耗太多內存
    private static final int ONE_TIME_SIZE = 1000;

    // @Override
    // 每天半夜1點鐘開始執行任務
    //    @Scheduled(cron = "0 0 1 * * ?") 秒 分 時 天 月 星期
    // 下面是用於測試的配置,每分鐘執行一次任務
    @Scheduled(fixedRate = 1000 * 60)
    public void purchaseTask() {
        System.out.println("定時任務開始......");
        Set<String> productIdList
                = stringRedisTemplate.opsForSet().members(PRODUCT_SCHEDULE_SET);
        
        List<PurchaseRecordPo> prpList = new ArrayList<>();
        for (String productIdStr : productIdList) {
            //轉換成 Long
            Long productId = Long.parseLong(productIdStr);
            //常量 + 上 long
            String purchaseKey = PURCHASE_PRODUCT_LIST + productId;
            
            //綁定這個 list 操作
            BoundListOperations<String, String> ops
                    = stringRedisTemplate.boundListOps(purchaseKey);
            
            // 計算記錄數
            long size = stringRedisTemplate.opsForList().size(purchaseKey);
            
            //如果 長度 / 1000 == 0 ,就長度/100 ,否則就 長度 +1
            Long times = size % ONE_TIME_SIZE == 0 ?
                    size / ONE_TIME_SIZE : size / ONE_TIME_SIZE + 1;
            
            for (int i = 0; i < times; i++) {
                // 獲取至多TIME_SIZE個搶紅包信息
                List<String> prList = null;
                if (i == 0) {
                    prList = ops.range(i * ONE_TIME_SIZE,
                            (i + 1) * ONE_TIME_SIZE);
                } else {
                    prList = ops.range(i * ONE_TIME_SIZE + 1,
                            (i + 1) * ONE_TIME_SIZE);
                }
                for (String prStr : prList) {
                    PurchaseRecordPo prp
                            = this.createPurchaseRecord(productId, prStr);
                    prpList.add(prp);
                }
                try {
                    // 採用該方法採用新建事務的方式,這樣不會導致全局事務回滾
                    purchaseService.dealRedisPurchase(prpList);
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
                // 清除列表爲空,等待重新寫入數據
                prpList.clear();
            }
            // 刪除購買列表
            stringRedisTemplate.delete(purchaseKey);
            // 從商品集合中刪除商品
            stringRedisTemplate.opsForSet()
                    .remove(PRODUCT_SCHEDULE_SET, productIdStr);
        }
        System.out.println("定時任務結束......");
    }

    
    private PurchaseRecordPo createPurchaseRecord(
            Long productId, String prStr) {
        String[] arr = prStr.split(",");
        Long userId = Long.parseLong(arr[0]);
        int quantity = Integer.parseInt(arr[1]);
        double sum = Double.valueOf(arr[2]);
        double price = Double.valueOf(arr[3]);
        Long time = Long.parseLong(arr[4]);
        Timestamp purchaseTime = new Timestamp(time);
        PurchaseRecordPo pr = new PurchaseRecordPo();
        pr.setProductId(productId);
        pr.setPurchaseTime(purchaseTime);
        pr.setPrice(price);
        pr.setQuantity(quantity);
        pr.setSum(sum);
        pr.setUserId(userId);
        pr.setNote("購買日誌,時間:" + purchaseTime.getTime());
        return pr;
    }
}

測試

  • rieds命令 執行 命令

  • hmset product_1 id 1 stock 3000 price 5.00

    • redis裏面會存在鍵:product_1
    • 有3列:第一列:id 1 。 stock 2997。 price 5:00
  • 從性能上來講,只需要6s的時間,比鎖 快了 數倍

  • 使用redis 建議使用 獨立的Redis 服務器,做好備份,容災。

    // Redis購買記錄集合前綴
    private static final String PURCHASE_PRODUCT_LIST = "purchase_list_";
    
    // 搶購商品集合
    private static final String PRODUCT_SCHEDULE_SET = "product_schedule_set";
  • 執行過之後redis,product_schedule_set 爲 1 (row) 1 (value)

  • purchase_list_1 爲: 1 1,1,5,5,1593581880782

  • 腳本的執行返回值爲 1

  • 每次搶購 product_1 stock會減少

  • purchase_list_1 會增加一行

  • 定時任務結束後 product_1 之外的兩個 redis 清楚

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章