1. 概述
快取與資料庫的強一致性,也稱線性一致性,核心要求是:資料庫中的值發生變更,快取資料要實現同步複製,並且一旦操作完成,隨後任意客戶端的查詢都必須返回這一新值。以下圖為例,一旦寫入b
完成,必須保證讀到;而寫入過程中,認為值的跳變可能發生在某一瞬間,因此讀到a或b都是可能的。資料庫與快取作為一個整體,在向外提供服務的過程中,無論資料是否變更過,都時刻保持資料一致,因為它內部的資料彷彿
只有一份,即使併發訪問不同節點。
2. 場景
秒殺是一個比較典型的強一致場景,一般秒殺系統的庫存同時保持在資料庫與快取中,如果查詢快取有資料,直接可以走秒殺流程,將資料庫中的庫存數量進行扣減,同時將最新的資料更新到快取,使快取中資料與資料庫中資料保持強一致,這裡只是拿秒殺的場景來舉例,類似秒殺的場景有很多,像搶門票系統、12306搶火車票等,資源比較少使用者比較多,需要在特定時間內進行搶購的業務場景。真實秒殺場景的設計,是在快取中扣庫存,不會直接在資料庫中進行扣庫存,因為資料庫的效能遠遠比快取差,所以本篇也只是拿類似秒殺這樣的場景,來闡述強一致下的設計思想與相關實現。
3. 方案
分散式系統裡面,有個眾所周知的理論,就是CAP理論
,CAP即:
Consistency(一致性)
Availability(可用性)
Partition tolerance(分割槽容忍性)
這三個性質對應了分散式系統的三個指標。
而CAP理論說的就是一個分散式系統,不可能同時做到這三點。如果預設是分割槽的,那麼只能選擇P的情況下,出現了兩種選擇組合,AP與CP,AP保證可用性則犧牲了一致性,CP保證了一致性則犧牲了可用性,所以我們在講快取與資料庫強一致的同時,不可避免犧牲了系統可用性的指標,所以看到12306網站這種體驗不好,總是搶不到票,或者在一直提示排隊中這種情況,就是系統可用性不佳的表現,因為火車站的票源是個稀缺資源,而且在各個站點之間查到的數量又是動態的,在這種強一致性下的業務場景,可用性必然會出現問題。這裡不深入討論12306網站具體是如何實現的,只是拿該場景做個引入。
假設現有一般搶購系統,某些商品搞促銷活動,庫存也就1000,搶完為止,在開槍時間未到來前,頁面顯示初始庫存,在搶購過程中,只要重新整理頁面庫存還有,按鈕就不會置灰,還可以接著點選搶購,直到頁面顯示庫存為0,活動結束。
這是個比較典型的讀多寫少
場景,大量請求來集中訪問,少部分請求能真正完成下單,我們很容易想到做讀寫分離
,將商品的庫存提前從資料庫預載入到快取,使用者讀的時候,從快取讀取數量,只要能看到數量,也就可以直接下單,至於使用者能否搶到,得看使用者運氣了,讓真正下單成功的使用者去走後續付款操作。注意,這裡對於某個使用者下單成功後,後臺要做的操作是先扣資料庫庫存數量,隨後實時同步
更新庫存到快取中。如果這一步更新不及時,很有可能資料庫與快取庫存不一致,導致快取中的數量比實際資料庫庫存還多,最終快取庫存減為零,而資料庫已經是負數,結果導致超賣。
3.1 資料庫與快取雙寫+讀取操作非同步序列化
當庫存發生變化後,更新資料庫,同時更新快取,如果在讀併發高的情況下,更新資料庫與更新快取的時間間隔中,被讀操作打斷,那麼讀到的將是快取中舊的庫存,資料庫已經是新庫存,此時會出現不一致;
為了解決這種問題,應先更新資料庫後,立即刪除快取,這也是上一篇分散式快取--快取與資料庫一致性方案中極力推薦的cache aside pattern
(旁路快取)的經典模式。
更新資料的時候,根據資料的唯一標識,將操作路由之後,傳送到一個jvm內部的記憶體佇列中,同時刪除快取。
讀取資料的時候,那麼將重新讀取資料,並更新快取的操作,根據唯一標識路由之後,也傳送同一個jvm內部的佇列中。
一個佇列對應一個工作執行緒,每個工作執行緒序列拿到對應的操作,然後一條一條的執行,這樣的話,一個資料變更的操作先執行,刪除快取。如果一個讀請求過來,讀到了空的快取,就從資料庫將更新後的值載入到快取。如果併發高的情況下,會出現多個讀操作併發的讀資料庫並載入快取,可以先將快取更新的請求傳送到佇列中,此時會在佇列中積壓,然後同步等待快取更新完成。
這裡有一個優化點,一個佇列中,其實多個更新快取請求串在一起是沒意義的,因此可以做過濾,如果發現佇列中已經有一個更新快取的請求了,那麼就不用再放個更新請求操作進去了,直接等待前面的更新操作請求完成即可;
如果請求還在等待時間範圍內,不斷輪詢發現可以取到值了,那麼就直接返回; 如果請求等待的時間超過一定時長,那麼直接嘗試從資料庫中讀取資料,並寫入快取。
實現程式碼如下:
step1
: 註冊監聽器,初始化工作執行緒池和記憶體佇列
在SpringBoot的啟動類中註冊如下監聽器類InitListener
@EnableAutoConfiguration
@SpringBootApplication
@ComponentScan
@MapperScan("com.roncoo.eshop.inventory.mapper")
public class Application {
/**
* 註冊監聽器
* @return
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
@Bean
public ServletListenerRegistrationBean servletListenerRegistrationBean() {
ServletListenerRegistrationBean servletListenerRegistrationBean =
new ServletListenerRegistrationBean();
servletListenerRegistrationBean.setListener(new InitListener());
return servletListenerRegistrationBean;
}
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
監聽器的實現類如下如下:
/**
* 系統初始化監聽器
*
*/
public class InitListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent sce) {
// 初始化工作執行緒池和記憶體佇列
RequestProcessorThreadPool.init();
}
}
請求處理執行緒池的類如下,完成執行緒池與記憶體佇列的初始化:
/**
* 請求處理執行緒池:單例
*
*/
public class RequestProcessorThreadPool {
/**
* 執行緒池
*/
private ExecutorService threadPool = Executors.newFixedThreadPool(10);
public RequestProcessorThreadPool() {
RequestQueue requestQueue = RequestQueue.getInstance();
for(int i = 0; i < 10; i++) {
ArrayBlockingQueue<Request> queue = new ArrayBlockingQueue<Request>(100);
requestQueue.addQueue(queue);
threadPool.submit(new RequestProcessorThread(queue));
}
}
/**
*
* 靜態內部類的方式,去初始化單例
*
*
*/
private static class Singleton {
private static RequestProcessorThreadPool instance;
static {
instance = new RequestProcessorThreadPool();
}
public static RequestProcessorThreadPool getInstance() {
return instance;
}
}
public static RequestProcessorThreadPool getInstance() {
return Singleton.getInstance();
}
/**
* 初始化方法
*/
public static void init() {
getInstance();
}
}
請求記憶體佇列
/**
* 請求記憶體佇列
*
*/
public class RequestQueue {
/**
* 記憶體佇列
*/
private List<ArrayBlockingQueue<Request>> queues =
new ArrayList<ArrayBlockingQueue<Request>>();
/**
* 標識位map
*/
private Map<Integer, Boolean> flagMap = new ConcurrentHashMap<Integer, Boolean>();
/**
*
* 靜態內部類的方式,去初始化單例
*
*
*/
private static class Singleton {
private static RequestQueue instance;
static {
instance = new RequestQueue();
}
public static RequestQueue getInstance() {
return instance;
}
}
public static RequestQueue getInstance() {
return Singleton.getInstance();
}
/**
* 新增一個記憶體佇列
* @param queue
*/
public void addQueue(ArrayBlockingQueue<Request> queue) {
this.queues.add(queue);
}
/**
* 獲取記憶體佇列的數量
* @return
*/
public int queueSize() {
return queues.size();
}
/**
* 獲取記憶體佇列
* @param index
* @return
*/
public ArrayBlockingQueue<Request> getQueue(int index) {
return queues.get(index);
}
public Map<Integer, Boolean> getFlagMap() {
return flagMap;
}
}
每個工作執行緒如下:
/**
* 執行請求的工作執行緒
*
*/
public class RequestProcessorThread implements Callable<Boolean> {
/**
* 自己監控的記憶體佇列
*/
private ArrayBlockingQueue<Request> queue;
public RequestProcessorThread(ArrayBlockingQueue<Request> queue) {
this.queue = queue;
}
@Override
public Boolean call() throws Exception {
try {
while(true) {
// ArrayBlockingQueue,執行緒安全的記憶體佇列
// Blocking就是說明,如果佇列滿了,或者是空的,那麼都會在執行操作的時候,阻塞住
Request request = queue.take();
boolean forceRfresh = request.isForceRefresh();
// 先做讀請求的去重
if(!forceRfresh) {
RequestQueue requestQueue = RequestQueue.getInstance();
Map<Integer, Boolean> flagMap = requestQueue.getFlagMap();
if(request instanceof ProductInventoryDBUpdateRequest) {
// 如果是一個更新資料庫的請求,那麼就將那個productId對應的標識設定為true
flagMap.put(request.getProductId(), true);
} else if(request instanceof ProductInventoryCacheRefreshRequest) {
Boolean flag = flagMap.get(request.getProductId());
// 如果flag是null
if(flag == null) {
flagMap.put(request.getProductId(), false);
}
// 如果是快取重新整理的請求,那麼就判斷,如果標識不為空,而且是true,就說明之前有一個這個商品的資料庫更新請求
if(flag != null && flag) {
flagMap.put(request.getProductId(), false);
}
// 如果是快取重新整理的請求,而且發現標識不為空,但是標識是false
// 說明前面已經有一個資料庫更新請求與一個快取重新整理請求了
if(flag != null && !flag) {
// 對於這種讀請求,直接就過濾掉,不要放到後面的記憶體佇列裡面去了
return true;
}
}
}
System.out.println("===========日誌===========: 工作執行緒處理請求,商品id=" + request.getProductId());
// 執行這個request操作
request.process();
}
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
}
step2
: 封裝兩種請求介面
/**
* 請求介面
*
*/
public interface Request {
void process();
Integer getProductId();
boolean isForceRefresh();
}
更新資料庫請求實現類
public class ProductInventoryDBUpdateRequest implements Request {
/**
* 商品庫存
*/
private ProductInventory productInventory;
/**
* 商品庫存Service
*/
private ProductInventoryService productInventoryService;
public ProductInventoryDBUpdateRequest(ProductInventory productInventory,
ProductInventoryService productInventoryService) {
this.productInventory = productInventory;
this.productInventoryService = productInventoryService;
}
@Override
public void process() {
System.out.println("===========日誌===========: 資料庫更新請求開始執行,商品id=" + productInventory.getProductId() + ", 商品庫存數量=" + productInventory.getInventoryCnt());
// 修改資料庫中的庫存
productInventoryService.updateProductInventory(productInventory);
// 刪除redis中的快取
productInventoryService.removeProductInventoryCache(productInventory);
}
/**
* 獲取商品id
*/
public Integer getProductId() {
return productInventory.getProductId();
}
@Override
public boolean isForceRefresh() {
return false;
}
}
更新快取類請求類
/**
* 重新載入商品庫存的快取
* @author Administrator
*
*/
public class ProductInventoryCacheRefreshRequest implements Request {
/**
* 商品id
*/
private Integer productId;
/**
* 商品庫存Service
*/
private ProductInventoryService productInventoryService;
/**
* 是否強制重新整理快取
*/
private boolean forceRefresh;
public ProductInventoryCacheRefreshRequest(Integer productId,
ProductInventoryService productInventoryService,
boolean forceRefresh) {
this.productId = productId;
this.productInventoryService = productInventoryService;
this.forceRefresh = forceRefresh;
}
@Override
public void process() {
// 從資料庫中查詢最新的商品庫存數量
ProductInventory productInventory = productInventoryService.findProductInventory(productId);
System.out.println("===========日誌===========: 已查詢到商品最新的庫存數量,商品id=" + productId + ", 商品庫存數量=" + productInventory.getInventoryCnt());
// 將最新的商品庫存數量,重新整理到redis快取中去
productInventoryService.setProductInventoryCache(productInventory);
}
public Integer getProductId() {
return productId;
}
public boolean isForceRefresh() {
return forceRefresh;
}
}
step3
: 請求非同步執行Service封裝
/**
* 請求非同步執行的service
*
*/
public interface RequestAsyncProcessService {
void process(Request request);
}
實現類:
/**
* 請求非同步處理的service實現
* @author Administrator
*
*/
@Service("requestAsyncProcessService")
public class RequestAsyncProcessServiceImpl implements RequestAsyncProcessService {
@Override
public void process(Request request) {
try {
// 做請求的路由,根據每個請求的商品id,路由到對應的記憶體佇列中去
ArrayBlockingQueue<Request> queue = getRoutingQueue(request.getProductId());
// 將請求放入對應的佇列中,完成路由操作
queue.put(request);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 獲取路由到的記憶體佇列
* @param productId 商品id
* @return 記憶體佇列
*/
private ArrayBlockingQueue<Request> getRoutingQueue(Integer productId) {
RequestQueue requestQueue = RequestQueue.getInstance();
// 先獲取productId的hash值
String key = String.valueOf(productId);
int h;
int hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
// 對hash值取模,將hash值路由到指定的記憶體佇列中,比如記憶體佇列大小8
// 用記憶體佇列的數量對hash值取模之後,結果一定是在0~7之間
// 所以任何一個商品id都會被固定路由到同樣的一個記憶體佇列中去的
int index = (requestQueue.queueSize() - 1) & hash;
System.out.println("===========日誌===========: 路由記憶體佇列,商品id=" + productId + ", 佇列索引=" + index);
return requestQueue.getQueue(index);
}
}
step4
: 兩種請求controller封裝
@Controller
public class ProductInventoryController {
@Resource
private RequestAsyncProcessService requestAsyncProcessService;
@Resource
private ProductInventoryService productInventoryService;
/**
* 更新商品庫存
*/
@RequestMapping("/updateProductInventory")
@ResponseBody
public Response updateProductInventory(ProductInventory productInventory) {
System.out.println("===========日誌===========: 接收到更新商品庫存的請求,商品id=" + productInventory.getProductId() + ", 商品庫存數量=" + productInventory.getInventoryCnt());
Response response = null;
try {
Request request = new ProductInventoryDBUpdateRequest(
productInventory, productInventoryService);
requestAsyncProcessService.process(request);
response = new Response(Response.SUCCESS);
} catch (Exception e) {
e.printStackTrace();
response = new Response(Response.FAILURE);
}
return response;
}
/**
* 獲取商品庫存
*/
@RequestMapping("/getProductInventory")
@ResponseBody
public ProductInventory getProductInventory(Integer productId) {
System.out.println("===========日誌===========: 接收到一個商品庫存的讀請求,商品id=" + productId);
ProductInventory productInventory = null;
try {
Request request = new ProductInventoryCacheRefreshRequest(
productId, productInventoryService, false);
requestAsyncProcessService.process(request);
// 將請求扔給service非同步去處理以後,就需要while(true)一會兒,在這裡hang住
// 去嘗試等待前面有商品庫存更新的操作,同時快取重新整理的操作,將最新的資料重新整理到快取中
long startTime = System.currentTimeMillis();
long endTime = 0L;
long waitTime = 0L;
// 等待超過200ms沒有從快取中獲取到結果
while(true) {
// 一般公司裡面,面向使用者的讀請求控制在200ms就可以了
if(waitTime > 200) {
break;
}
// 嘗試去redis中讀取一次商品庫存的快取資料
productInventory = productInventoryService.getProductInventoryCache(productId);
// 如果讀取到了結果,那麼就返回
if(productInventory != null) {
System.out.println("===========日誌===========: 在200ms內讀取到了redis中的庫存快取,商品id=" + productInventory.getProductId() + ", 商品庫存數量=" + productInventory.getInventoryCnt());
return productInventory;
}
// 如果沒有讀取到結果,那麼等待一段時間
else {
Thread.sleep(20);
endTime = System.currentTimeMillis();
waitTime = endTime - startTime;
}
}
// 直接嘗試從資料庫中讀取資料
productInventory = productInventoryService.findProductInventory(productId);
if(productInventory != null) {
// 將快取重新整理一下
// 這個過程,實際上是一個讀操作的過程,但是沒有放在佇列中序列去處理,還是有資料不一致的問題
request = new ProductInventoryCacheRefreshRequest(
productId, productInventoryService, true);
requestAsyncProcessService.process(request);
// 程式碼會執行到這裡,只有三種情況:
// 1、就是說,上一次也是讀請求,資料刷入了redis,但是redis LRU演算法給清理掉了,標誌位還是false
// 所以此時下一個讀請求是從快取中拿不到資料的,再放一個讀Request進佇列,讓資料去重新整理一下
// 2、可能在200ms內,就是讀請求在佇列中一直積壓著,沒有等待到它執行
// 所以就直接查一次庫,然後給佇列裡塞進去一個重新整理快取的請求
// 3、資料庫裡本身就沒有,快取穿透,穿透redis,請求到達mysql庫
return productInventory;
}
} catch (Exception e) {
e.printStackTrace();
}
return new ProductInventory(productId, -1L);
}
}
上述實現過程中有兩個優化點
優化點1
: 讀請求去重優化
因為那個寫請求肯定會更新資料庫,然後那個讀請求肯定會從資料庫中讀取最新資料,然後重新整理到快取中,自己只要hang一會兒就可以從快取中讀到資料了。
優化點2
: 空資料讀請求過濾優化
可能某個資料,在資料庫裡面壓根兒就沒有,那麼那個讀請求是不需要放入記憶體佇列的,而且讀請求在controller那一層,直接就可以返回了,不需要等待。
如果快取裡沒資料,就兩個情況,第一個是資料庫裡就沒資料,快取肯定也沒資料;第二個是資料庫更新操作過來了,先刪除了快取,此時快取是空的,但是資料庫裡是有的。我們做了之前的讀請求去重優化,用了一個flag map,只要前面有資料庫更新操作,flag就肯定是存在的,你只不過可以根據true或false,判斷你前面執行的是寫請求還是讀請求。但是如果flag壓根兒就沒有呢,就說明這個資料,無論是寫請求,還是讀請求,都沒有過。那這個時候過來的讀請求,發現flag是null,就可以認為資料庫裡肯定也是空的,那就不會去讀取了。或者說,我們也可以認為每個商品有一個最初始的庫存,但是因為最初始的庫存肯定會同步到快取中去的,有一種特殊的情況,就是說,商品庫存本來在redis中是有快取的,但是因為redis記憶體滿了,就給幹掉了,但是此時資料庫中是有值的,那麼在這種情況下,可能就是之前沒有任何的寫請求和讀請求的flag的值,此時還是需要從資料庫中重新載入一次資料到快取中的。
3.2 方案改進
上述方案是筆者的朋友在網際網路大廠的經驗總結,在思路上是沒有問題的,但是在工業級專案的落地過程中,會有不少問題。
比如機器突然掛了,那記憶體佇列就會丟資料
,再比如,如果併發讀的數量很大,那麼記憶體佇列積壓
的資料為會越來越多,導致後面的請求也有可能hang在那很長時間,一直讀不到資料。
問題1: 如果記憶體佇列丟資料,怎麼辦?
這種情況比較常見,比如機器突然掛了,記憶體佇列資料丟了,該如何處理?首先明確一點,這裡的請求都是同步阻塞
的,如果業務系統掛了,那上游的路由閘道器會出現請求異常或者超時,外部系統或者外部使用者請求也會異常或者超時,那呼叫端會重試請求,直到機器重啟ok,請求會再次進佇列,資料只不過重新進入佇列。
問題2:資料積壓如何處理?
這裡確實會存在記憶體佇列積壓大量的讀請求,導致後續的請求hang在那幾秒甚至十幾秒都沒有得到處理。
問題3:業務系統需要完成強一致的需求,需要引入記憶體佇列,路由閘道器,導致大量的開發成本,並且稍微控制不好,就會出現隱藏的bug。
針對以上問題,作如下改進:
改進點1:封裝快取代理客戶端與快取服務端,引入RocketMQ,將訊息寫入帶上時間戳版本。
封裝快取客戶端,一方面是省去路由閘道器,另一方面是充當訊息寫入的角色。RocketMQ替換原來的記憶體佇列,因為訊息本身按照key分割槽寫入,就能保證相同的key會寫到相同的分割槽佇列裡面。然後換成代理服務端按照訊息有序消費,再寫入快取叢集,架構設計如下:
上圖中,首先事務寫入保證訊息不會丟,其次寫入時帶上時間戳作為版本號,防止讀取的舊值後寫入 ,更新的新值先寫入,當寫入快取叢集中時,比較時間戳是否是較新的,防止舊值覆蓋新值。
改進點2:解決MQ吞吐問量問題,快取代理服務端使用記憶體佇列。
針對改點1中的情形,為保證訊息絕對有序,只有一個執行緒消費MQ中一個分割槽的訊息,再寫入快取,會帶來吞吐量的下降,因此在快取代理服務端使用多個記憶體佇列,讓多個執行緒依次消費多個佇列,增加吞吐量。
改進點3: 增加手動ack,增加訊息進入重試佇列與死信佇列的機制。
如果Redis快取掛了,此時需要通過更新快取後拿到結果,並手動通知ack到訊息中介軟體,確保訊息消費者處理完後,才丟棄該訊息,防止訊息在消費者端丟失,時間戳來保證更新快取冪等性,此外一直更新快取失敗的訊息進入訊息中介軟體進入重試佇列死信佇列,待下次發訊息後再消費。
3.3 終極方案
通過上述改進,一套思想完備,可落地到生產級的方案基本完成,有人會說這不是分散式鎖
的思想麼?說對了,多年前,分散式鎖還沒有發展成熟的時候,就是通過類似的這種訊息正中間將寫入分割槽的操作序列化,進行消費,再通過冪等性保證最終寫入不會被亂序覆蓋,現在分散式鎖的實現已經比較成熟,完全可以用分散式鎖來解決,比如用Redis的提供的客戶端Redission來實現,不但簡化流程,而且保證只有搶到鎖的執行緒才可以更新資料庫與快取,再釋放鎖,當然加鎖與釋放鎖的佔用時間也是較快的,因為更新資料與寫一條快取也就幾毫秒或者是十幾毫秒的時間,可以保證後續更新的操作在很快時間內可以再次搶到鎖。
4. 總結
本篇講述了資料庫與快取雙寫在強一致下的實現思路與方案,從一開始的方案設計到落地,再到落地後的優化改進,最後到比較可行又簡單的方式,你會發現,好的架構不是一步到位,而是逐步演進
而來;其次幾年前的方案,也許比較合適
,但是現在看起來就會顯得過於複雜,原因是解決特定問題的專業技術已經出現,專門解決了該類問題,最終通過重構來解決之前過於複雜又容易出問題的方案,由此也不難發現,好的架構是比較簡單
的。所以我也比較贊同原阿里P9李運華在極客時間的架構專欄中提到的架構三原則
,即合適,簡單,演進
,就是在特定的場景下做適合該場景的合適架構,並且儘可能設計簡單,並通過不斷演進,來優化之前方案中的缺陷。