基於Redis實現簡單的分佈式鎖

 
在分佈式場景下,有很多種情況都需要實現最終一致性。在設計遠程上下文的領域事件的時候,爲了保證最終一致性,在通過領域事件進行通訊的方式中,可以共享存儲(領域模型和消息的持久化數據源),或者做全局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。
 
事務可以一次執行多個命令, 並且帶有以下兩個重要的保證:
 
  1. 事務是一個單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端發送來的命令請求所打斷。
  2. 事務是一個原子操作:事務中的命令要麼全部被執行,要麼全部都不執行。
 
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。
 
 
 
摘自文章:http://www.jeffkit.info/2011/05/994/,其中有一節:GETSET的妙用,其中有一段:
 
上一個經驗雖說可以解決這條數據該“插入還是更新”的問題,但需要知道當前操作是否針對某數據的首次操作的需求還不少。例如我的程序會在不同時間接收到同一條消息的不同分片信息包,我需要在收到該消息的首個信息包(發送是無序的)時做些特殊處理。
 
早些時候的做法是爲消息在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;
    }
 
 
但這套畢竟是自己實現的,還會有很多漏洞,在github發現了一套開源的實現:https://github.com/mrniko/redisson/wiki,可以對其進行深入研究並應用到我們的系統中,這樣系統會更加健壯。
 
在簡單對其使用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();
        }
 
 
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章