原文鏈接:https://mp.weixin.qq.com/s/uyX9eRnd2xPOEr6lwax8Yw
在日常開發中有很多地方都有類似扣減庫存的操作,比如電商系統中的商品庫存,抽獎系統中的獎品庫存等。
解決方案
- 使用mysql數據庫,使用一個字段來存儲庫存,每次扣減庫存去更新這個字段。
- 還是使用數據庫,但是將庫存分層多份存到多條記錄里面,扣減庫存的時候路由一下,這樣子增大了並發量,但是還是避免不了大量的去訪問數據庫來更新庫存。
- 將庫存放到redis使用redis的incrby特性來扣減庫存。
分析
在上面的第一種和第二種方式都是基於數據來扣減庫存。
基於數據庫單庫存
第一種方式在所有請求都會在這里等待鎖,獲取鎖有去扣減庫存。
在並發量不高的情況下可以使用,但是一旦並發量大了就會有大量請求阻塞在這里,導致請求超時,進而整個系統雪崩;而且會頻繁的去訪問數據庫,大量占用數據庫資源,所以在並發高的情況下這種方式不適用。
基於數據庫多庫存
第二種方式其實是第一種方式的優化版本,在一定程度上提高了並發量,但是在還是會大量的對數據庫做更新操作大量占用數據庫資源。
基於數據庫來實現扣減庫存還存在的一些問題:
用數據庫扣減庫存的方式,扣減庫存的操作必須在一條語句中執行,不能先selec在update,這樣在並發下會出現超扣的情況。如:
update number set x=x-1 where x > 0
理論上即使是這樣由於MySQL事務的特性,這種方法只能降低超賣的數量,但是不可能完全避免超扣。
因為數據庫默認隔離級別是repeatable read,假如庫存是5,有A、B兩個請求分別創建了事務並且都沒有提交,當A事務提交了,改了庫存為4,但是因為是事務隔離級別是可重復讀的,所有B看不到A事務改的庫存。到時B看到的庫存還是5,所以B修改庫存為4,這樣就出現了超扣問題。
所以我們扣庫存的時候需要將事務隔離級別設置成read commit才可以。(我自己測試沒有出現這種情況)
-
MySQL自身對於高並發的處理性能就會出現問題,一般來說,MySQL的處理性能會隨着並發thread上升而上升,但是到了一定的並發度之后會出現明顯的拐點,之后一路下降,最終甚至會比單thread的性能還要差。
-
當減庫存和高並發碰到一起的時候,由於操作的庫存數目在同一行,就會出現爭搶InnoDB行鎖的問題,導致出現互相等待甚至死鎖,從而大大降低MySQL的處理性能,最終導致前端頁面出現超時異常。
基於redis
針對上述問題的問題我們就有了第三種方案,將庫存放到緩存,利用redis的incrby特性來扣減庫存,解決了超扣和性能問題。但是一旦緩存丟失需要考慮恢復方案。
比如抽獎系統扣獎品庫存的時候,初始庫存=總的庫存數-已經發放的獎勵數,但是如果是異步發獎,需要等到MQ消息消費完了才能重啟redis初始化庫存,否則也存在庫存不一致的問題。
Redis Incrby 命令
Redis Incrby 命令將 key 中儲存的數字加上指定的增量值。
- 如果 key 不存在,那么 key 的值會先被初始化為 0 ,然后再執行 INCRBY 命令。
- 如果值包含錯誤的類型,或字符串類型的值不能表示為數字,那么返回一個錯誤。
本操作的值限制在 64 位(bit)有符號數字表示之內。
語法
redis Incrby 命令基本語法如下:
redis 127.0.0.1:6379> INCRBY KEY_NAME INCR_AMOUNT
可用版本
>= 1.0.0
返回值
加上指定的增量值之后, key 的值。
基於redis實現扣減庫存的具體實現
- 我們使用redis的lua腳本來實現扣減庫存
- 由於是分布式環境下所以還需要一個分布式鎖來控制只能有一個服務去初始化庫存
- 需要提供一個回調函數,在初始化庫存的時候去調用這個函數獲取初始化庫存
- 庫存扣減完之后可以進行一個異步的更改數據庫數據,保證一致性
具體關於lua腳本的內容使用請移步至 redis命令參考–Script腳本 :
http://doc.redisfans.com/script/index.html
lua腳本注意點:
Lua腳本,是一種輕量級的腳本語言。設計目的是為了嵌入應用程序中,從而為應用程序提供靈活的擴展和定制功能。Lua腳本的應用也很多,比如Nginx+Lua實現的OpenResty,Redis+Lua配合使用(Redisson中大量使用了Lua腳本)。
Lua腳本具有以下好處:
1、減少網絡開銷:Lua腳本在執行的時候,是先發送到Redis服務器的,然后在服務器上執行腳本。多個命令和業務邏輯都封裝到腳本里,一次性提交到服務器。
2、原子性操作:我們都知道redis在執行命令時是單線程的,但是每個命令之間就存在並發的情況,就存在先查詢再操作時,兩個命令沒辦法保證線程安全。但使用Lua腳本時,redis把這個腳本操作當成是一個命令,那么這個腳本中的多條操作也就保證了原子性。(注意:只保證原子性,不是事務)
雖然Lua腳本有這么多優點,但是也不能亂用,使用的時候要注意:
1、Lua腳本可以在redis單機模式、主從模式、Sentinel集群模式下正常使用,但是無法在分片集群模式下使用。(腳本操作的key可能不在同一個分片)。(其實集群模式不支持問題也是可以解決的,在使用spring的RedisTemplate執行lua腳本時,報錯EvalSha is not supported in cluster environment
,不支持cluster。但是redis是支持lua腳本的,只要拿到原redis的connection對象,通過connection去執行即可,在后面會說下這個問題)
2、Lua腳本中盡量避免使用循環操作(可能引發死循環問題),盡量避免長時間運行。
3、redis在執行lua腳本時,默認最長運行時間時5秒,當腳本運行時間超過這一限制后,Redis將開始接受其他命令但不會執行(以確保腳本的原子性,因為此時腳本並沒有被終止),而是會返回“BUSY”錯誤。
初始化庫存回調函數(IStockCallback )
/** * 獲取庫存回調 * @author yuhao.wang */ public interface IStockCallback { /** * 獲取庫存 * @return */ int getStock(); }
扣減庫存服務(StockService)
ackage com.xiaolyuh.service; import com.xiaolyuh.lock.RedisLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisCluster; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; /** * 扣庫存 * * @author yuhao.wang */ @Service public class StockService { Logger logger = LoggerFactory.getLogger(StockService.class); /** * 庫存還未初始化 */ public static final long UNINITIALIZED_STOCK = -3L; /** * Redis 客戶端 */ @Autowired private RedisTemplate<String, Object> redisTemplate; /** * 執行扣庫存的腳本 */ public static final String STOCK_LUA; static { /** * * @desc 扣減庫存Lua腳本 * 庫存(stock)-1:表示不限庫存 * 庫存(stock)0:表示沒有庫存 * 庫存(stock)大於0:表示剩余庫存 * * @params 庫存key * @return * -3:庫存未初始化 * -2:庫存不足 * -1:不限庫存 * 大於等於0:剩余庫存(扣減之后剩余的庫存),直接返回-1 */ StringBuilder sb = new StringBuilder(); // exists 判斷是否存在KEY,如果存在返回1,不存在返回0 sb.append("if (redis.call('exists', KEYS[1]) == 1) then"); // get 獲取KEY的緩存值,tonumber 將redis數據轉成 lua 的整形 sb.append(" local stock = tonumber(redis.call('get', KEYS[1]));"); sb.append(" local num = tonumber(ARGV[1]);"); // 如果拿到的緩存數等於 -1,代表改商品庫存是無限的,直接返回1 sb.append(" if (stock == -1) then"); sb.append(" return -1;"); sb.append(" end;"); // incrby 特性進行庫存的扣減 sb.append(" if (stock >= num) then"); sb.append(" return redis.call('incrby', KEYS[1], 0-num);"); sb.append(" end;"); sb.append(" return -2;"); sb.append("end;"); sb.append("return -3;"); STOCK_LUA = sb.toString(); } /** * @param key 庫存key * @param expire 庫存有效時間,單位秒 * @param num 扣減數量 * @param stockCallback 初始化庫存回調函數 * @return -2:庫存不足; -1:不限庫存; 大於等於0:扣減庫存之后的剩余庫存 */ public long stock(String key, long expire, int num, IStockCallback stockCallback) { long stock = stock(key, num); // 初始化庫存 if (stock == UNINITIALIZED_STOCK) { RedisLock redisLock = new RedisLock(redisTemplate, key); try { // 獲取鎖 if (redisLock.tryLock()) { // 雙重驗證,避免並發時重復回源到數據庫 stock = stock(key, num); if (stock == UNINITIALIZED_STOCK) { // 獲取初始化庫存 final int initStock = stockCallback.getStock(); // 將庫存設置到redis redisTemplate.opsForValue().set(key, initStock, expire, TimeUnit.SECONDS); // 調一次扣庫存的操作 stock = stock(key, num); } } } catch (Exception e) { logger.error(e.getMessage(), e); } finally { redisLock.unlock(); } } return stock; } /** * 扣庫存 * * @param key 庫存key * @param num 扣減庫存數量 * @return 扣減之后剩余的庫存【-3:庫存未初始化; -2:庫存不足; -1:不限庫存; 大於等於0:扣減庫存之后的剩余庫存】 */ private Long stock(String key, int num) { // 腳本里的KEYS參數 List<String> keys = new ArrayList<>(); keys.add(key); // 腳本里的ARGV參數 List<String> args = new ArrayList<>(); args.add(Integer.toString(num)); long result = redisTemplate.execute(new RedisCallback<Long>() { @Override public Long doInRedis(RedisConnection connection) throws DataAccessException { Object nativeConnection = connection.getNativeConnection(); // 集群模式和單機模式雖然執行腳本的方法一樣,但是沒有共同的接口,所以只能分開執行 // 集群模式 if (nativeConnection instanceof JedisCluster) { return (Long) ((JedisCluster) nativeConnection).eval(STOCK_LUA, keys, args); } // 單機模式 else if (nativeConnection instanceof Jedis) { return (Long) ((Jedis) nativeConnection).eval(STOCK_LUA, keys, args); } return UNINITIALIZED_STOCK; } }); return result; } /** * 加庫存(還原庫存) * * @param key 庫存key * @param num 庫存數量 * @return */ public long addStock(String key, int num) { return addStock(key, null, num); } /** * 加庫存 * * @param key 庫存key * @param expire 過期時間(秒) * @param num 庫存數量 * @return */ public long addStock(String key, Long expire, int num) { boolean hasKey = redisTemplate.hasKey(key); // 判斷key是否存在,存在就直接更新 if (hasKey) { return redisTemplate.opsForValue().increment(key, num); } Assert.notNull(expire,"初始化庫存失敗,庫存過期時間不能為null"); RedisLock redisLock = new RedisLock(redisTemplate, key); try { if (redisLock.tryLock()) { // 獲取到鎖后再次判斷一下是否有key hasKey = redisTemplate.hasKey(key); if (!hasKey) { // 初始化庫存 redisTemplate.opsForValue().set(key, num, expire, TimeUnit.SECONDS); } } } catch (Exception e) { logger.error(e.getMessage(), e); } finally { redisLock.unlock(); } return num; } /** * 獲取庫存 * * @param key 庫存key * @return -1:不限庫存; 大於等於0:剩余庫存 */ public int getStock(String key) { Integer stock = (Integer) redisTemplate.opsForValue().get(key); return stock == null ? -1 : stock; } }
調用
@RestController public class StockController { @Autowired private StockService stockService; @RequestMapping(value = "stock", produces = MediaType.APPLICATION_JSON_UTF8_VALUE) public Object stock() { // 商品ID long commodityId = 1; // 庫存ID String redisKey = "redis_key:stock:" + commodityId; long stock = stockService.stock(redisKey, 60 * 60, 2, () -> initStock(commodityId)); return stock >= 0; } /** * 獲取初始的庫存 * * @return */ private int initStock(long commodityId) { // TODO 這里做一些初始化庫存的操作 return 1000; } @RequestMapping(value = "getStock", produces = MediaType.APPLICATION_JSON_UTF8_VALUE) public Object getStock() { // 商品ID long commodityId = 1; // 庫存ID String redisKey = "redis_key:stock:" + commodityId; return stockService.getStock(redisKey); } @RequestMapping(value = "addStock", produces = MediaType.APPLICATION_JSON_UTF8_VALUE) public Object addStock() { // 商品ID long commodityId = 2; // 庫存ID String redisKey = "redis_key:stock:" + commodityId; return stockService.addStock(redisKey, 2); } }
思路理解
庫存新增思路
庫存新增的操作一般不存在高並發的情況,因為不可能某一種商品一直在新增庫存,這屬於管理員后台管理的一種操作。
這里新增庫存采用了redis的
1.庫存發生新增操作,調用層一般傳過來商品的id標識和新增量,調用庫存新增服務
2.庫存新增服務
/** * 加庫存(還原庫存) * @param key 庫存key * @param num 庫存數量 * @return */ public long addStock(String key, int num) { return addStock(key, null, num); }
庫存新增服務主要是使用了redis的increment自增操作。
3.辛苦新增服務
boolean hasKey = redisTemplate.hasKey(key); // 判斷key是否存在,存在就直接更新 if (hasKey) { return redisTemplate.opsForValue().increment(key, num); }
第一種情況是先判斷redis中是否有這個商品庫存的緩存,如果存在該商品庫存,就直接進行增加操作;
Assert.notNull(expire,"初始化庫存失敗,庫存過期時間不能為null"); RedisLock redisLock = new RedisLock(redisTemplate, key); try { if (redisLock.tryLock()) { // 獲取到鎖后再次判斷一下是否有key hasKey = redisTemplate.hasKey(key); if (!hasKey) { // 初始化庫存 redisTemplate.opsForValue().set(key, num, expire, TimeUnit.SECONDS); } } } catch (Exception e) { logger.error(e.getMessage(), e); } finally { redisLock.unlock(); } return num;
然后是第二種情況了,就是redis中沒有庫存緩存了。所以就需要去初始化庫存。因為初始化庫存有一些非原子的操作,在分布式環境下不安全,所以這里先通過這個商品id獲取分布式鎖,拿到鎖之后,再去判斷一下redis中是否有這個緩存,確認沒有,則可以進行初始化操作,然會返回數量,初始化操作可以從數據庫查出真實庫存的值,然后更新到緩存。
我這里的案列是直接把第一次傳進來的庫存數量進行初始化。
可能設計的問題
在對某key進行increment()方法時,可能會報錯:
redis ERR value is not an integer or out of range
這里庫存新增我們使用的是RedisTemplate
的increment
的自增方法。
Spring對Redis序列化的策略有兩種,分別是StringRedisTemplate
和RedisTemplate
,其中StringRedisTemplate
用於操作字符串,RedisTemplate
使用的是JDK默認的二進制序列化。
大家都知道redis序列化是將key,value值先轉換為流的形式,再存儲到redis中。
RedisTemplate
是使用的JdkSerializationRedisSerializer
序列化,序列化后的值包含了對象信息,版本號,類信息等,是一串字符串,所以無法進行數值自增操作。
而StringRedisTemplate
序列化策略是字符串的值直接轉為字節數組,所以存儲到redis中是數值,所以可以進行自增操作。
所以問題出在這里,我們需要自定義序列化策略,在application啟動類中添加如下:
@Bean public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) { StringRedisTemplate template = new StringRedisTemplate(factory); //定義key序列化方式 //RedisSerializer<String> redisSerializer = new StringRedisSerializer();//Long類型會出現異常信息;需要我們上面的自定義key生成策略,一般沒必要 //定義value的序列化方式 Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper om = new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); jackson2JsonRedisSerializer.setObjectMapper(om); // template.setKeySerializer(redisSerializer); template.setValueSerializer(jackson2JsonRedisSerializer); template.setHashValueSerializer(jackson2JsonRedisSerializer); template.afterPropertiesSet(); return template;
庫存扣減思路
利用redis的incrby特性來扣減庫存,解決了超扣和性能問題。但是一旦緩存丟失需要考慮恢復方案。
庫存發生扣減操作,調用層一般傳過來商品的id標識和扣減量,調用庫存扣減服務
long stock = stock(key, num);
第一步是進行扣減操作,在正常情況下,如果緩存中存在庫存數據,則會進行正常的扣減操作,並且返回結果。
// 初始化庫存 if (stock == UNINITIALIZED_STOCK) { RedisLock redisLock = new RedisLock(redisTemplate, key); try { // 獲取鎖 if (redisLock.tryLock()) { // 雙重驗證,避免並發時重復回源到數據庫 stock = stock(key, num); if (stock == UNINITIALIZED_STOCK) { // 獲取初始化庫存 final int initStock = stockCallback.getStock(); // 將庫存設置到redis redisTemplate.opsForValue().set(key, initStock, expire, TimeUnit.SECONDS); // 調一次扣庫存的操作 stock = stock(key, num); } } } catch (Exception e) { logger.error(e.getMessage(), e); } finally { redisLock.unlock(); } }
第二種是緩存中還沒有數據的情況,則需要進行初始化操作。初始化庫存存在非原子操作,所以需要使用分布式鎖來實現。拿到鎖之后,在進行一次庫存扣減操作,看返回的結果還是不是沒有緩存,這是進行一次雙重驗證,避免並發時重復回源到數據庫。第二次驗證的結果還是沒有緩存的話,則需要進行一次初始化緩存操作。初始化操作可以從數據庫查出真實庫存的值,然后更新到緩存。然后再進行一次扣減操作。
可能存在的問題:
RedisTemplate執行lua腳本,集群模式下報錯解決
在使用spring的RedisTemplate執行lua腳本時,報錯EvalSha is not supported in cluster environment
,不支持cluster。
但是redis是支持lua腳本的,只要拿到原redis的connection對象,通過connection去執行即可:
//spring自帶的執行腳本方法中,集群模式直接拋出不支持執行腳本異常,此處拿到原redis的connection執行腳本 String result = (String)redisTemplate.execute(new RedisCallback<String>() { public String doInRedis(RedisConnection connection) throws DataAccessException { Object nativeConnection = connection.getNativeConnection(); // 集群模式和單點模式雖然執行腳本的方法一樣,但是沒有共同的接口,所以只能分開執行 // 集群 if (nativeConnection instanceof JedisCluster) { return (String) ((JedisCluster) nativeConnection).eval(LUA, keys, args); } // 單點 else if (nativeConnection instanceof Jedis) { return (String) ((Jedis) nativeConnection).eval(LUA, keys, args); } return null; } });