分布式鎖的設計

什么是鎖?
在單進程的系統中,當存在多個線程可以同時改變某個變量(可變共享變量)時,就需要對變量或代碼塊做同步,使其在修改這種變量時能夠線性執行消除并發修改變量。
而同步的本質是通過鎖來實現的。為了實現多個線程在一個時刻同一個代碼塊只能有一個線程可執行,那么需要在某個地方做個標記,這個標記必須每個線程都能看到,當標記不存在時可以設置該標記,其余后續線程發現已經有標記了則等待擁有標記的線程結束同步代碼塊取消標記后再去嘗試設置標記。這個標記可以理解為鎖。
不同地方實現鎖的方式也不一樣,只要能滿足所有線程都能看得到標記即可。如 Java 中 synchronize 是在對象頭設置標記,Lock 接口的實現類基本上都只是某一個 volitile 修飾的 int 型變量其保證每個線程都能擁有對該 int 的可見性和原子修改,linux 內核中也是利用互斥量或信號量等內存數據做標記。
除了利用內存數據做鎖其實任何互斥的都能做鎖(只考慮互斥情況),如流水表中流水號與時間結合做冪等校驗可以看作是一個不會釋放的鎖,或者使用某個文件是否存在作為鎖等。只需要滿足在對標記進行修改能保證原子性和內存可見性即可。

什么是分布式?

分布式的 CAP 理論告訴我們:
任何一個分布式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多只能同時滿足兩項。

目前很多大型網站及應用都是分布式部署的,分布式場景中的數據一致性問題一直是一個比較重要的話題。基于 CAP理論,很多系統在設計之初就要對這三者做出取舍。在互聯網領域的絕大多數的場景中,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證最終一致性。

分布式場景
此處主要指集群模式下,多個相同服務同時開啟.
在許多的場景中,我們為了保證數據的最終一致性,需要很多的技術方案來支持,比如分布式事務、分布式鎖等。很多時候我們需要保證一個方法在同一時間內只能被同一個線程執行。在單機環境中,通過 Java 提供的并發 API 我們可以解決,但是在分布式環境下,就沒有那么簡單啦。
分布式與單機情況下最大的不同在于其不是多線程而是多進程。
多線程由于可以共享堆內存,因此可以簡單的采取內存作為標記存儲位置。而進程之間甚至可能都不在同一臺物理機上,因此需要將標記存儲在一個所有進程都能看到的地方。

什么是分布式鎖?
當在分布式模型下,數據只有一份(或有限制),此時需要利用鎖的技術控制某一時刻修改數據的進程數。
與單機模式下的鎖不僅需要保證進程可見,還需要考慮進程與鎖之間的網絡問題。(我覺得分布式情況下之所以問題變得復雜,主要就是需要考慮到網絡的延時和不可靠。。。一個大坑)
分布式鎖還是可以將標記存在內存,只是該內存不是某個進程分配的內存而是公共內存如 Redis、Memcache。至于利用數據庫、文件等做鎖與單機的實現是一樣的,只要保證標記能互斥就行。

我們需要怎樣的分布式鎖?

  • 可以保證在分布式部署的應用集群中,同一個方法在同一時間只能被一臺機器上的一個線程執行。
  • 這把鎖要是一把可重入鎖(避免死鎖)
  • 這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)
  • 這把鎖最好是一把公平鎖(根據業務需求考慮要不要這條)
  • 有高可用的獲取鎖和釋放鎖功能
  • 獲取鎖和釋放鎖的性能要好

一、基于數據庫做分布式鎖

1、基于樂觀鎖

(1)、基于表主鍵唯一做分布式鎖
利用主鍵唯一的特性,如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,那么我們就可以認為操作成功的那個線程獲得了該方法的鎖,當方法執行完畢之后,想要釋放鎖的話,刪除這條數據庫記錄即可。
上面這種簡單的實現有以下幾個問題:

  • 這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用。
  • 這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖。
  • 這把鎖只能是非阻塞的,因為數據的 insert 操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程并不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。
  • 這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因為數據中數據已經存在了。
  • 這把鎖是非公平鎖,所有等待鎖的線程憑運氣去爭奪鎖。
  • 在 MySQL 數據庫中采用主鍵沖突防重,在大并發情況下有可能會造成鎖表現象。
    當然,我們也可以有其他方式解決上面的問題。
  • 數據庫是單點?搞兩個數據庫,數據之前雙向同步,一旦掛掉快速切換到備庫上。
  • 沒有失效時間?只要做一個定時任務,每隔一定時間把數據庫中的超時數據清理一遍。
  • 非阻塞的?搞一個 while 循環,直到 insert 成功再返回成功。
  • 非重入的?在數據庫表中加個字段,記錄當前獲得鎖的機器的主機信息和線程信息,那么下次再獲取鎖的時候先查詢數據庫,如果當前機器的主機信息和線程信息在數據庫可以查到的話,直接把鎖分配給他就可以了。
  • 非公平的?再建一張中間表,將等待鎖的線程全記錄下來,并根據創建時間排序,只有最先創建的允許獲取鎖。比較好的辦法是在程序中生產主鍵進行防重。

(2)、基于表字段版本號做分布式鎖

這個策略源于 mysql 的 mvcc 機制,使用這個策略其實本身沒有什么問題,唯一的問題就是對數據表侵入較大,我們要為每個表設計一個版本號字段,然后寫一條判斷 sql 每次進行判斷,增加了數據庫操作的次數,在高并發的要求下,對數據庫連接的開銷也是無法忍受的。

2、基于悲觀鎖

(1)、基于數據庫排他鎖做分布式鎖
在查詢語句后面增加for update,數據庫會在查詢過程中給數據庫表增加排他鎖 (注意: InnoDB 引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這里我們希望使用行級鎖,就要給要執行的方法字段名添加索引,值得注意的是,這個索引一定要創建成唯一索引,否則會出現多個重載方法之間無法同時被訪問的問題。重載方法的話建議把參數類型也加上。)。當某條記錄被加上排他鎖之后,其他線程無法再在該行記錄上增加排他鎖。
我們可以認為獲得排他鎖的線程即可獲得分布式鎖,當獲取到鎖之后,可以執行方法的業務邏輯,執行完方法之后,通過connection.commit()操作來釋放鎖。
這種方法可以有效的解決上面提到的無法釋放鎖和阻塞鎖的問題。
阻塞鎖 for update語句會在執行成功后立即返回,在執行失敗時一直處于阻塞狀態,直到成功。
鎖定之后服務宕機,無法釋放?使用這種方式,服務宕機之后數據庫會自己把鎖釋放掉。

但是還是無法直接解決數據庫單點和可重入問題。
這里還可能存在另外一個問題,雖然我們對方法字段名使用了唯一索引,并且顯示使用 for update 來使用行級鎖。但是,MySQL 會對查詢進行優化,即便在條件中使用了索引字段,但是否使用索引來檢索數據是由 MySQL 通過判斷不同執行計劃的代價來決定的,如果 MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。如果發生這種情況就悲劇了。。。
還有一個問題,就是我們要使用排他鎖來進行分布式鎖的 lock,那么一個排他鎖長時間不提交,就會占用數據庫連接。一旦類似的連接變得多了,就可能把數據庫連接池撐爆。

優缺點
優點:簡單,易于理解
缺點:會有各種各樣的問題(操作數據庫需要一定的開銷,使用數據庫的行級鎖并不一定靠譜,性能不靠譜)

二、基于 Redis 做分布式鎖

1、基于 redis 的 setnx()、expire() 方法做分布式鎖

setnx()
setnx 的含義就是 SET if Not Exists,其主要有兩個參數 setnx(key, value)。該方法是原子的,如果 key 不存在,則設置當前 key 成功,返回 1;如果當前 key 已經存在,則設置當前 key 失敗,返回 0。
expire()
expire 設置過期時間,要注意的是 setnx 命令不能設置 key 的超時時間,只能通過 expire() 來對 key 設置。

使用步驟:

  • setnx(lockkey, 1) 如果返回 0,則說明占位失敗;如果返回 1,則說明占位成功
  • expire() 命令對 lockkey 設置超時時間,為的是避免死鎖問題。
  • 執行完業務代碼后,可以通過 delete 命令刪除 key。

這個方案其實是可以解決日常工作中的需求的,但從技術方案的探討上來說,可能還有一些可以完善的地方。比如,如果在第一步 setnx 執行成功后,在 expire() 命令執行成功前,發生了宕機的現象,那么就依然會出現死鎖的問題,所以如果要對其進行完善的話,可以使用 redis 的 setnx()、get() 和 getset() 方法來實現分布式鎖。

2、基于 redis 的 setnx()、get()、getset()方法做分布式鎖

這個方案的背景主要是在 setnx() 和 expire() 的方案上針對可能存在的死鎖問題,做了一些優化。
getset()
這個命令主要有兩個參數 getset(key,newValue)。該方法是原子的,對 key 設置 newValue 這個值,并且返回 key 原來的舊值。假設 key 原來是不存在的,那么多次執行這個命令,會出現下邊的效果:
getset(key, “value1”) 返回 null 此時 key 的值會被設置為 value1
getset(key, “value2”) 返回 value1 此時 key 的值會被設置為 value2
依次類推!

使用步驟:

  • setnx(lockkey, 當前時間+過期超時時間),如果返回 1,則獲取鎖成功;如果返回 0 則沒有獲取到鎖,轉向 2。
  • get(lockkey) 獲取值 oldExpireTime ,并將這個 value 值與當前的系統時間進行比較,如果小于當前系統時間,則認為這個鎖已經超時,可以允許別的請求重新獲取,轉向 3。
  • 計算 newExpireTime = 當前時間+過期超時時間,然后 getset(lockkey, newExpireTime) 會返回當前 lockkey 的值currentExpireTime。
  • 判斷 currentExpireTime 與 oldExpireTime 是否相等,如果相等,說明當前 getset 設置成功,獲取到了鎖。如果不相等,說明這個鎖又被別的請求獲取走了,那么當前請求可以直接返回失敗,或者繼續重試。
  • 在獲取到鎖之后,當前線程可以開始自己的業務處理,當處理完畢后,比較自己的處理時間和對于鎖設置的超時時間,如果小于鎖設置的超時時間,則直接執行 delete 釋放鎖;如果大于鎖設置的超時時間,則不需要再鎖進行處理。
import cn.com.tpig.cache.redis.RedisService;
import cn.com.tpig.utils.SpringUtils;

//redis分布式鎖
public final class RedisLockUtil {

    private static final int defaultExpire = 60;

    private RedisLockUtil() {
        //
    }

    /**
     * 加鎖
     * @param key redis key
     * @param expire 過期時間,單位秒
     * @return true:加鎖成功,false,加鎖失敗
     */
    public static boolean lock(String key, int expire) {

        RedisService redisService = SpringUtils.getBean(RedisService.class);
        long status = redisService.setnx(key, "1");

        if(status == 1) {
            redisService.expire(key, expire);
            return true;
        }

        return false;
    }

    public static boolean lock(String key) {
        return lock2(key, defaultExpire);
    }

    /**
     * 加鎖
     * @param key redis key
     * @param expire 過期時間,單位秒
     * @return true:加鎖成功,false,加鎖失敗
     */
    public static boolean lock2(String key, int expire) {

        RedisService redisService = SpringUtils.getBean(RedisService.class);

        long value = System.currentTimeMillis() + expire;
        long status = redisService.setnx(key, String.valueOf(value));

        if(status == 1) {
            return true;
        }
        long oldExpireTime = Long.parseLong(redisService.get(key, "0"));
        if(oldExpireTime < System.currentTimeMillis()) {
            //超時
            long newExpireTime = System.currentTimeMillis() + expire;
            long currentExpireTime = 
                    Long.parseLong(redisService.getSet(key, String.valueOf(newExpireTime)));
            if(currentExpireTime == oldExpireTime) {
                return true;
            }
        }
        return false;
    }

    public static void unLock1(String key) {
        RedisService redisService = SpringUtils.getBean(RedisService.class);
        redisService.del(key);
    }

    public static void unLock2(String key) {    
        RedisService redisService = SpringUtils.getBean(RedisService.class);    
        long oldExpireTime = Long.parseLong(redisService.get(key, "0"));   
        if(oldExpireTime > System.currentTimeMillis()) {        
            redisService.del(key);    
        }
   }
}

public void drawRedPacket(long userId) {
    String key = "draw.redpacket.userid:" + userId;

    boolean lock = RedisLockUtil.lock2(key, 60);
    if(lock) {
        try {
            //領取操作
        } finally {
            //釋放鎖
            RedisLockUtil.unLock(key);
        }
    } else {
        new RuntimeException("重復領取獎勵");
    }
}

3、基于 Redlock 做分布式鎖

Redlock 是 Redis 的作者 antirez 給出的集群模式的 Redis 分布式鎖,它基于 N 個完全獨立的 Redis 節點(通常情況下 N 可以設置成 5)。
算法的步驟如下:

  • 客戶端獲取當前時間,以毫秒為單位。
  • 客戶端嘗試獲取 N 個節點的鎖,(每個節點獲取鎖的方式和前面說的緩存鎖一樣),N 個節點以相同的 key 和 value 獲取鎖。客戶端需要設置接口訪問超時,接口超時時間需要遠遠小于鎖超時時間,比如鎖自動釋放的時間是 10s,那么接口超時大概設置 5-50ms。這樣可以在有 redis 節點宕機后,訪問該節點時能盡快超時,而減小鎖的正常使用。
  • 客戶端計算在獲得鎖的時候花費了多少時間,方法是用當前時間減去在步驟一獲取的時間,只有客戶端獲得了超過 3 個節點的鎖,而且獲取鎖的時間小于鎖的超時時間,客戶端才獲得了分布式鎖。
  • 客戶端獲取的鎖的時間為設置的鎖超時時間減去步驟三計算出的獲取鎖花費時間。
  • 如果客戶端獲取鎖失敗了,客戶端會依次刪除所有的鎖。

使用 Redlock 算法,可以保證在掛掉最多 2 個節點的時候,分布式鎖服務仍然能工作,這相比之前的數據庫鎖和緩存鎖大大提高了可用性,由于 redis 的高效性能,分布式緩存鎖性能并不比數據庫鎖差。
但是,有一位分布式的專家寫了一篇文章《How to do distributed locking》,質疑 Redlock 的正確性。
https://mp.weixin.qq.com/s/1bPLk_VZhZ0QYNZS8LkviA
https://blog.csdn.net/jek123456/article/details/72954106
優缺點
優點:
性能高
缺點:
失效時間設置多長時間為好?如何設置的失效時間太短,方法沒等執行完,鎖就自動釋放了,那么就會產生并發問題。如果設置的時間太長,其他獲取鎖的線程就可能要平白的多等一段時間。

4、基于 redisson 做分布式鎖

redisson 是 redis 官方的分布式鎖組件。GitHub 地址:https://github.com/redisson/redisson
上面的這個問題 ——> 失效時間設置多長時間為好?這個問題在 redisson 的做法是:每獲得一個鎖時,只設置一個很短的超時時間,同時起一個線程在每次快要到超時時間時去刷新鎖的超時時間。在釋放鎖的同時結束這個線程。

三、基于 ZooKeeper 做分布式鎖

zookeeper 鎖相關基礎知識
zk 一般由多個節點構成(單數),采用 zab 一致性協議。因此可以將 zk 看成一個單點結構,對其修改數據其內部自動將所有節點數據進行修改而后才提供查詢服務。
zk 的數據以目錄樹的形式,每個目錄稱為 znode, znode 中可存儲數據(一般不超過 1M),還可以在其中增加子節點。
子節點有三種類型。序列化節點,每在該節點下增加一個節點自動給該節點的名稱上自增。臨時節點,一旦創建這個 znode 的客戶端與服務器失去聯系,這個 znode 也將自動刪除。最后就是普通節點。
Watch 機制,client 可以監控每個節點的變化,當產生變化會給 client 產生一個事件。

zk 基本鎖
原理:利用臨時節點與 watch 機制。每個鎖占用一個普通節點 /lock,當需要獲取鎖時在 /lock 目錄下創建一個臨時節點,創建成功則表示獲取鎖成功,失敗則 watch/lock 節點,有刪除操作后再去爭鎖。臨時節點好處在于當進程掛掉后能自動上鎖的節點自動刪除即取消鎖。
缺點:所有取鎖失敗的進程都監聽父節點,很容易發生羊群效應,即當釋放鎖后所有等待進程一起來創建節點,并發量很大。

zk 鎖優化
原理:上鎖改為創建臨時有序節點,每個上鎖的節點均能創建節點成功,只是其序號不同。只有序號最小的可以擁有鎖,如果這個節點序號不是最小的則 watch 序號比本身小的前一個節點 (公平鎖)。
步驟:

  • 在 /lock 節點下創建一個有序臨時節點 (EPHEMERAL_SEQUENTIAL)。
  • 判斷創建的節點序號是否最小,如果是最小則獲取鎖成功。不是則取鎖失敗,然后 watch 序號比本身小的前一個節點。(避免很多線程watch同一個node,導致羊群效應)
  • 當取鎖失敗,設置 watch 后則等待 watch 事件到來后,再次判斷是否序號最小。
  • 取鎖成功則執行代碼,最后釋放鎖(刪除該節點)。
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.data.Stat;

public class DistributedLock implements Lock, Watcher{
    private ZooKeeper zk;
    private String root = "/locks";//根
    private String lockName;//競爭資源的標志
    private String waitNode;//等待前一個鎖
    private String myZnode;//當前鎖
    private CountDownLatch latch;//計數器
    private int sessionTimeout = 30000;
    private List<Exception> exception = new ArrayList<Exception>();

    /**
     * 創建分布式鎖,使用前請確認config配置的zookeeper服務可用
     * @param config 127.0.0.1:2181
     * @param lockName 競爭資源標志,lockName中不能包含單詞lock
     */
    public DistributedLock(String config, String lockName){
        this.lockName = lockName;
        // 創建一個與服務器的連接
        try {
            zk = new ZooKeeper(config, sessionTimeout, this);
            Stat stat = zk.exists(root, false);
            if(stat == null){
                // 創建根節點
                zk.create(root, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT);
            }
        } catch (IOException e) {
            exception.add(e);
        } catch (KeeperException e) {
            exception.add(e);
        } catch (InterruptedException e) {
            exception.add(e);
        }
    }

    /**
     * zookeeper節點的監視器
     */
    public void process(WatchedEvent event) {
        if(this.latch != null) {
            this.latch.countDown();
        }
    }

    public void lock() {
        if(exception.size() > 0){
            throw new LockException(exception.get(0));
        }
        try {
            if(this.tryLock()){
                System.out.println("Thread " + Thread.currentThread().getId() + " " +myZnode + " get lock true");
                return;
            }
            else{
                waitForLock(waitNode, sessionTimeout);//等待鎖
            }
        } catch (KeeperException e) {
            throw new LockException(e);
        } catch (InterruptedException e) {
            throw new LockException(e);
        }
    }

    public boolean tryLock() {
        try {
            String splitStr = "_lock_";
            if(lockName.contains(splitStr))
                throw new LockException("lockName can not contains \\u000B");
            //創建臨時子節點
            myZnode = zk.create(root + "/" + lockName + splitStr, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.EPHEMERAL_SEQUENTIAL);
            System.out.println(myZnode + " is created ");
            //取出所有子節點
            List<String> subNodes = zk.getChildren(root, false);
            //取出所有lockName的鎖
            List<String> lockObjNodes = new ArrayList<String>();
            for (String node : subNodes) {
                String _node = node.split(splitStr)[0];
                if(_node.equals(lockName)){
                    lockObjNodes.add(node);
                }
            }
            Collections.sort(lockObjNodes);
            System.out.println(myZnode + "==" + lockObjNodes.get(0));
            if(myZnode.equals(root+"/"+lockObjNodes.get(0))){
                //如果是最小的節點,則表示取得鎖
                return true;
            }
            //如果不是最小的節點,找到比自己小1的節點
            String subMyZnode = myZnode.substring(myZnode.lastIndexOf("/") + 1);
            waitNode = lockObjNodes.get(Collections.binarySearch(lockObjNodes, subMyZnode) - 1);
        } catch (KeeperException e) {
            throw new LockException(e);
        } catch (InterruptedException e) {
            throw new LockException(e);
        }
        return false;
    }

    public boolean tryLock(long time, TimeUnit unit) {
        try {
            if(this.tryLock()){
                return true;
            }
            return waitForLock(waitNode,time);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }

    private boolean waitForLock(String lower, long waitTime) throws InterruptedException, KeeperException {
        Stat stat = zk.exists(root + "/" + lower,true);
        //判斷比自己小一個數的節點是否存在,如果不存在則無需等待鎖,同時注冊監聽
        if(stat != null){
            System.out.println("Thread " + Thread.currentThread().getId() + " waiting for " + root + "/" + lower);
            this.latch = new CountDownLatch(1);
            this.latch.await(waitTime, TimeUnit.MILLISECONDS);
            this.latch = null;
        }
        return true;
    }

    public void unlock() {
        try {
            System.out.println("unlock " + myZnode);
            zk.delete(myZnode,-1);
            myZnode = null;
            zk.close();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        }
    }

    public void lockInterruptibly() throws InterruptedException {
        this.lock();
    }

    public Condition newCondition() {
        return null;
    }

    public class LockException extends RuntimeException {
        private static final long serialVersionUID = 1L;
        public LockException(String e){
            super(e);
        }
        public LockException(Exception e){
            super(e);
        }
    }
}

優缺點
優點:
有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實現起來較為簡單。
缺點:
性能上可能并沒有緩存服務那么高,因為每次在創建鎖和釋放鎖的過程中,都要動態創建、銷毀臨時節點來實現鎖功能。ZK 中創建和刪除節點只能通過 Leader 服務器來執行,然后將數據同步到所有的 Follower 機器上。還需要對 ZK的原理有所了解。

四、基于 Consul 做分布式鎖

DD 寫過類似文章,其實主要利用 Consul 的 Key / Value 存儲 API 中的 acquire 和 release 操作來實現。
文章地址:http://blog.didispace.com/spring-cloud-consul-lock-and-semphore/

五、使用分布式鎖的注意事項

1、注意分布式鎖的開銷
2、注意加鎖的粒度
3、加鎖的方式

六、分布式可重入鎖的設計

需記錄機器線程id(MAC地址 + jvm進程ID + 線程ID)和重入次數

七、總結

無論你身處一個什么樣的公司,最開始的工作可能都需要從最簡單的做起。不要提阿里和騰訊的業務場景 qps 如何大,因為在這樣的大場景中你未必能親自參與項目,親自參與項目未必能是核心的設計者,是核心的設計者未必能獨自設計。希望大家能根據自己公司業務場景,選擇適合自己項目的方案。

參考資料
http://www.hollischuang.com/archives/1716
http://www.spring4all.com/question/158
https://www.cnblogs.com/PurpleDream/p/5559352.html
http://www.cnblogs.com/PurpleDream/p/5573040.html
https://www.cnblogs.com/suolu/p/6588902.html

轉載:https://mp.weixin.qq.com/s/MWdkhIrXAVRU13vZAFE1oA

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容

  • 引題 比如在同一個節點上,兩個線程并發的操作A的賬戶,都是取錢,如果不加鎖,A的賬戶可能會出現負數,正確的方式是對...
    阿康8182閱讀 4,805評論 0 75
  • 關于Mongodb的全面總結 MongoDB的內部構造《MongoDB The Definitive Guide》...
    中v中閱讀 31,985評論 2 89
  • ? 一、英雄定位:爆發、遠程消耗 二、技能介紹 簡單的被動,配合普攻可以減少1技能的冷卻時間 孫尚香的最核心技能,...
    漁夫哥閱讀 401評論 2 5
  • 冷冷悲傷獨自愁,朝陽暮雨遇寒流。 怎奈多情藕絲續,孤燈蒼狼獨嘯秋。
    我滴媽耶閱讀 228評論 0 0
  • 方鴻漸在歐洲游學期間,不理學業。為了給家人一個交待,方于畢業前購買了虛構的“克萊登大學”的博士學位證書,并隨海外學...
    婷婷姑娘要美美的閱讀 752評論 0 0