在分佈式場景下,有很多種情況都需要實現最終一致性。在設計遠程上下文的領域事件的時候,爲了保證最終一致性,在通過領域事件進行通訊的方式中,可以共享存儲(領域模型和消息的持久化數據源),或者做全局XA事務(兩階段提交,數據源可分開),也可以藉助消息中間件(消費者處理需要能冪等)。通過Observer模式來發布領域事件可以提供很好的高併發性能,並且事件存儲也能追溯更小粒度的事件數據,使各個應用系統擁有更好的自治性。
本文主要探討了一種實現分佈式最終一致性的解決方案——採用分佈式鎖。基於分佈式鎖的解決方案,比如zookeeper,redis都是相較於持久化(如利用InnoDB行鎖,或事務,或version樂觀鎖)方案提供了高可用性,並且支持豐富化的使用場景。 本文通過Java版本的redis分佈式鎖開源框架——Redisson來解析一下實現分佈式鎖的思路。
Redis本身支持的事務
MULTI 、 EXEC 、 DISCARD 和 WATCH 是 Redis 事務的基礎,MULTI, EXEC, DISCARD 和 WATCH 命令是Redis事務的基石。一個Redis事務允許一組Redis命令單步執行,並提供下面兩個重要保證:一個事務中的所有命令串行執行;要麼全部命令要麼沒有任何命令被處理。具體可參開這篇文章:http://blog.xiping.me/2010/12/transaction-in-redis.html。
事務可以一次執行多個命令, 並且帶有以下兩個重要的保證:
- 事務是一個單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端發送來的命令請求所打斷。
- 事務是一個原子操作:事務中的命令要麼全部被執行,要麼全部都不執行。
EXEC 命令負責觸發並執行事務中的所有命令:
如果客戶端在使用 MULTI 開啓了一個事務之後,卻因爲斷線而沒有成功執行 EXEC ,那麼事務中的所有命令都不會被執行。另一方面,如果客戶端成功在開啓事務之後執行 EXEC ,那麼事務中的所有命令都會被執行。當使用 AOF 方式做持久化的時候, Redis 會使用單個 write(2) 命令將事務寫入到磁盤中。然而,如果 Redis 服務器因爲某些原因被管理員殺死,或者遇上某種硬件故障,那麼可能只有部分事務命令會被成功寫入到磁盤中。如果 Redis 在重新啓動時發現 AOF 文件出了這樣的問題,那麼它會退出,並彙報一個錯誤。
自己實現Redis分佈式鎖
根據一些分佈式鎖相關的文檔,開始動手根據redis提供的原子操作進行實現:
在其中主要用到了Redis中的兩條原子命令:
1.SETNX key value
Available since 1.0.0.
Time complexity: O(1)
Set key to hold string value if key does not exist. In that case, it is equal to SET. When key already holds a value, no operation is performed. SETNX is short for "SET if Not eXists".
Return value
Integer reply, specifically:
1 if the key was set
0 if the key was not set
2.GETSET key value
起始版本:1.0.0
時間複雜度:O(1)
自動將key對應到value並且返回原來key對應的value,返回之前的舊值,如果之前Key不存在將返回nil。
上一個經驗雖說可以解決這條數據該“插入還是更新”的問題,但需要知道當前操作是否針對某數據的首次操作的需求還不少。例如我的程序會在不同時間接收到同一條消息的不同分片信息包,我需要在收到該消息的首個信息包(發送是無序的)時做些特殊處理。
早些時候的做法是爲消息在MongoDB維護一個狀態對象,有信息包來的時候就走“上鎖->檢查消息狀態->根據狀態決定做不做特殊操作->解鎖” 這個流程,雖然同事已經把鎖的粒度控制得非常細了,但有鎖的程序遇上多實例部署就歇了。
Redis的GETSET是解決這個問題的終極武器,只需要把當前信息包的唯一標識對指定的狀態屬性進行一次GETSET操作,再判斷返回值是否爲空則知道是否首次操作。GETSET替我們把兩次讀寫的操作封裝成了原子操作。
實現邏輯
獲取redis針對某個鎖的時候,需要根據lockKey區進行setnx(set not exist,顧名思義,如果key值爲空,則正常設置,返回1,否則不會進行設置並返回0)操作,如果設置成功,表示已經獲得鎖,否則並沒有獲取鎖。
如果沒有獲得鎖,去Redis上拿到該key對應的值,在該key上我們存儲一個時間戳(用毫秒錶示,t1),爲了避免死鎖以及其他客戶端佔用該鎖超過一定時間(5秒),使用該客戶端當前時間戳,與存儲的時間戳作比較。
如果沒有超過該key的使用時限,返回false,表示其他人正在佔用該key,不能強制使用;如果已經超過時限,那我們就可以進行解鎖,使用我們的時間戳來代替該字段的值。使用getset來設置該字段的值,getset可以返回設置完成之前的值t2,如果t2!=t1,說明在getset之前已經有其他線程已經設置了該字段,事實上,我們還是沒有獲得該鎖(已經被其他人搶佔)。但是這會造成一定的數據錯誤,因爲我們已經用getset設置了一個新的時間戳(並不是搶佔該鎖的客戶端設置的時間戳),所幸這樣操作的誤差比較小可以忽略不計。
但是如果在setnx失敗後,get該值卻無法拿到該字段時,說明在我們操作之前該鎖已經被釋放,這個時候,最好的辦法就是重新執行一遍setnx方法來獲取其值以獲得該鎖。當然,這仍然可能失敗,但失敗的邏輯與之前的相同。雖然出現這種情況非常少見,但是爲了避免每次都出現導致StackOverflowError的錯誤,設置調用層次。
public static final int ACQUIRE_LOCK_MAX_ATTEMPTS = 10;
public static final long EXPIRE_TIME = 5000L;
/**
* 基於時間戳根據lockKey嘗試獲取鎖,需要與releaseLock成對使用;
* <p/>使用該方法的前提是必須要保證服務器之間時間同步
* <p/>如果持有鎖的時間超過 #{EXPIRE_TIME},視爲超時,其他客戶端可以對其進行重新獲取鎖的操作
*
* @param lockKey - 鎖鍵值,即爭奪的資源
* @return - 當成功獲取鎖後,返回true,否則返回false;如果沒有獲取鎖,需要客戶端進行輪詢來嘗試獲取
*/
public boolean acquireLock(String lockKey) {
return acquireLock(lockKey, 0);
}
private boolean acquireLock(String lockKey, int depth) {
long setnx = jedis.setnx(lockKey, String.valueOf(System.currentTimeMillis()));
if (setnx == 1L) {
//說明客戶端已經獲得鎖
LOG.info(String.format("lock key : %s is acquired!", lockKey));
return true;
} else {
//此時,該lockKey已經被其他客戶端加鎖
String keyTimestamp = jedis.get(lockKey);
if (keyTimestamp == null) {
//如果該值已經被清空,就嘗試去重新獲取
if (depth == ACQUIRE_LOCK_MAX_ATTEMPTS) {
//如果嘗試次數超過10次,則不再嘗試,直接返回false
LOG.info(String.format("lock key : %s exceed max attemps: %s, quit!", lockKey,
ACQUIRE_LOCK_MAX_ATTEMPTS));
return false;
}
return acquireLock(lockKey, depth + 1);
}
long intervalTime = System.currentTimeMillis() - Long.valueOf(keyTimestamp);
if (intervalTime < EXPIRE_TIME) {
//鎖在一定時間內並沒有超時,獲取鎖失敗
LOG.info("lock key : %s acquire failed! other client persist this lock!", lockKey);
return false;
} else {
//鎖已經超時,嘗試執行getset操作,設置當前時間戳
String getSetTimestamp = jedis.getSet(lockKey, String.valueOf(System.currentTimeMillis()));
if (getSetTimestamp == null) {
//考慮非常特殊的情況,有人釋放了鎖執行del操作
//此時get/set拿到的是nil值,說明已經獲得了鎖
LOG.info(String.format("lock key : %s is acquired! GETSET returns null!", lockKey));
return true;
}
if (!getSetTimestamp.equals(keyTimestamp)) {
//在設置時,說明該鎖已經被其他client加上
//此時會有對應的副作用,比如
LOG.info("lock key : %s acquire failed! other client acquire this lock!", lockKey);
return false;
} else {
//鎖已更新,可以正常返回
LOG.info(String.format("lock key : %s is acquired! origin client is time out!", lockKey));
return true;
}
}
}
}
釋放鎖的過程相對來說比較簡單,但是我們採用這種方式的話,不能控制什麼時候該釋放鎖,當然也可以採用回調的方式來實現默認釋放掉鎖,以便於控制釋放過程。
/**
* 釋放lockKey對應的鎖,注意需要與acquireLock成對使用
* <p/>不能隨意對其他人使用的鎖進行釋放操作
*
* @param lockKey
* @return - 如果釋放成功返回true
*/
public boolean releaseLock(String lockKey) {
boolean result = jedis.del(lockKey) == 1L;
LOG.info(String.format("lock key: %s is released!", lockKey));
return result;
}
在簡單對其使用Jmeter進行性能測試,發現庫存控制比較好,能夠滿足實際需求,但測試過程中也遇到了一些問題。
我們如果使用這種方式,能否讓沒有拿到鎖的線程能夠及時收到通知,重新連接?
對於使用的JedisClient時,不能每次都使用同一個實例,需要在必要的情況化對其執行回收。
四月 28, 2016 5:03:18 下午 org.apache.catalina.core.StandardWrapperValve invoke
嚴重: Servlet.service() for servlet [springmvc] in context with path [] threw exception [Request processing failed; nested exception is redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketException: Broken pipe] with root cause
java.net.SocketException: Broken pipe
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:109)
at java.net.SocketOutputStream.write(SocketOutputStream.java:153)
at redis.clients.util.RedisOutputStream.flushBuffer(RedisOutputStream.java:52)
at redis.clients.util.RedisOutputStream.flush(RedisOutputStream.java:213)
at redis.clients.jedis.Connection.flush(Connection.java:288)
at redis.clients.jedis.Connection.getBinaryBulkReply(Connection.java:214)
at redis.clients.jedis.Connection.getBulkReply(Connection.java:205)
at redis.clients.jedis.Jedis.get(Jedis.java:101)
at com.api.example.controller.UserController.decrElement(UserController.java:52)
at sun.reflect.GeneratedMethodAccessor25.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:483)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:221)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:137)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:110)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandleMethod(RequestMappingHandlerAdapter.java:777)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:706)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:85)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:943)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:877)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:966)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:857)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:620)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:842)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:727)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:303)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:241)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:88)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:241)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:220)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:122)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:501)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:171)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:102)
at org.apache.catalina.valves.AccessLogValve.invoke(AccessLogValve.java:950)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:116)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:408)
at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:1040)
at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:607)
at org.apache.tomcat.util.net.JIoEndpoint$SocketProcessor.run(JIoEndpoint.java:314)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:745)
此時就需要使用JedisPool來獲取資源,可以避免出現這個問題,注意在最後要回收資源:
Jedis jedis = jedisPool.getResource();
try {
while (true) {
String productCountString = jedis.get("product");
if (Integer.parseInt(productCountString) > 0) {
if (acquireLock(jedis, "abc")) {
int productCount = Integer.parseInt(jedis.get("product"));
System.out.println(String.format("%tT --- Get product: %s", new Date(), productCount));
// System.out.println(productCount);
jedis.decr("product");
releaseLock(jedis, "abc");
return "Success";
}
Thread.sleep(1000L);
} else {
return "Over";
}
}
} finally {
jedis.close();
}