Java 基于 Redis 實(shí)現(xiàn)分布式鎖需要注意什么祥诽?

在如今這樣一個(gè)張口分布式,閉口微服務(wù)的軟件開(kāi)發(fā)趨勢(shì)下瓮恭,多實(shí)例似乎已經(jīng)不是某種選擇而是一個(gè)無(wú)需多說(shuō)的基本技術(shù)要求了雄坪。

多實(shí)例為我們帶來(lái)穩(wěn)定性提升的同時(shí),也伴隨著更復(fù)雜的技術(shù)要求偎血,原先在本地即可處理的問(wèn)題,全部擴(kuò)展為分布式問(wèn)題盯漂,其中就包含我們今天會(huì)聊到的多實(shí)例同步即分布式鎖問(wèn)題颇玷。JDK 提供的鎖實(shí)現(xiàn)已經(jīng)能夠非常好的解決本地同步問(wèn)題,而擴(kuò)展到多實(shí)例環(huán)境下就缆,Redis帖渠、ZooKeeper 等優(yōu)秀的實(shí)現(xiàn)也使得我們使用分布式鎖變得更加簡(jiǎn)單。

其實(shí)對(duì)于分布式鎖的原理竭宰、分布式鎖的 Redis 實(shí)現(xiàn)空郊、ZK 實(shí)現(xiàn)等等各類文章不計(jì)其數(shù),然而只要簡(jiǎn)單一搜就會(huì)發(fā)現(xiàn)切揭,大多數(shù)文章都在教大家 Redis 分布式鎖的原理和實(shí)現(xiàn)方法狞甚,但卻沒(méi)有幾篇會(huì)寫(xiě)什么實(shí)現(xiàn)是好的實(shí)現(xiàn),是適合用于生產(chǎn)環(huán)境廓旬,高效而考慮全面的實(shí)現(xiàn)哼审。這將是本文討論的內(nèi)容。

分布式鎖的要求

本節(jié)簡(jiǎn)單闡述分布式鎖的基本要求旗吁,通常滿足下述要求便可以說(shuō)是比較完整的實(shí)現(xiàn)了废累。

  1. 操作原子性
    • 與本地鎖一樣,加鎖的過(guò)程必須保證原子性瑟枫,否則失去鎖的意義
    • Redis 的單線程模型幫我們解決了大部分原子性的問(wèn)題春霍,但仍然要考慮客戶端代碼的原子性
  2. 可重入性
    • 分布式鎖一樣要考慮可重入的問(wèn)題
    • Redis 通常能解決實(shí)例間的可重入問(wèn)題砸西,那么實(shí)例內(nèi)線程間的可重入怎么辦?
  3. 效率
    • Redis 作為通過(guò) TCP 通信的外部服務(wù)址儒,網(wǎng)絡(luò)延遲不可避免芹枷,因此相比本地鎖操作時(shí)間更久
    • 分布式鎖獲取失敗的通常做法是線程休眠一段時(shí)間
    • 如何才能盡可能減少不必要的通信與休眠?

Local + Remote 結(jié)合實(shí)現(xiàn)分布式鎖

正如上一節(jié)所述离福,采用 Redis杖狼,我們能很好的實(shí)現(xiàn)實(shí)例間的原子性(單線程模型),可重入性(各實(shí)例分配 UUID)妖爷。

而 JDK 的本地鎖(如 ReentrantLock)又能非常完善的解決線程間同步的原子性蝶涩、可重入性。

此外絮识,對(duì)于實(shí)例內(nèi)不同線程間的同步绿聘,JDK 通過(guò) AQS 中一系列的方法確保高效穩(wěn)定,因此省去了與 Redis 通信的消耗次舌。

綜上熄攘,如果將本地鎖與遠(yuǎn)程鎖結(jié)合在一起,便可以分別實(shí)現(xiàn)分布式鎖在實(shí)例內(nèi)與實(shí)例間的各項(xiàng)要求了彼念。

代碼實(shí)現(xiàn)

下文代碼中挪圾,本地鎖使用 ReentrantLock, Redis client 使用 Jedis逐沙。如替換其他方案哲思,按照流程也很簡(jiǎn)單。

整體架構(gòu)

  1. 初始化鎖
.
└── 初始化鎖
    └── new instance       
  1. 獲取鎖
.
└── 獲取鎖
    └── 嘗試獲取本地鎖
        ├── 成功
        │   └── 嘗試獲取遠(yuǎn)程鎖
        │       ├── 成功
        │       │   └── 加鎖完成
        │       ├── 失敗
        │       │   └── 輪詢遠(yuǎn)程鎖
        │       └── 超時(shí)
        │           ├── 釋放本地鎖
        │           └── 退出
        ├── 失敗
        │   └── 阻塞等待
        └── 超時(shí)
            └── 退出
  1. 釋放鎖
.
└── 釋放鎖
    ├── 當(dāng)前線程持有本地鎖吩案?
    │   ├── 是重入狀態(tài)棚赔?(hold count > 1)
    │   │   └── 釋放本地鎖
    │   └── 非重入狀態(tài)
    │       ├── 釋放遠(yuǎn)程鎖
    │       └── 釋放本地鎖
    └── 未持有本地鎖
        └── 無(wú)法釋放,拋出錯(cuò)誤

代碼框架

public class RedisDistributedLock implements Lock {
    private static final String OBTAIN_LOCK_SCRIPT = ...
    private static final String clientId = UUID.randomUUID().toString();
    private static final int EXPIRE_SECONDS = ...;

    private final String lockKey;
    private final ReentrantLock localLock = new ReentrantLock();

    public RedisDistributedLock(String lockKey) {
        this.lockKey = lockKey;
    }

    @Override
    public void lock() {
        ...
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean tryLock() {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        ...
    }

    @Override
    public void unlock() {
        ...
    }

    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException();
    }
}

由上述代碼可見(jiàn)徘郭,我們的分布式鎖實(shí)現(xiàn)了 Lock 接口靠益,來(lái)確保依賴倒置,用戶可以方便的在本地鎖與分布式鎖之前切換而無(wú)需改動(dòng)邏輯残揉。

在 class field 中胧后,

  • OBTAIN_LOCK_SCRIPT 是用于執(zhí)行 redis 獲取鎖操作的 lua script,詳情見(jiàn)后文抱环。
  • clientId用于唯一標(biāo)識(shí)當(dāng)前所在實(shí)例绩卤,是分布式鎖進(jìn)行重入的重要屬性途样,注意該 field 為 static,因此僅此一份濒憋。
  • lockKey 為鎖 key何暇,用于標(biāo)識(shí)一個(gè)鎖,在構(gòu)造函數(shù)中初始化凛驮。
  • localLock 即本地鎖裆站,代碼中采用 ReentrantLock 用作本地鎖。

出于演示性質(zhì)考慮黔夭,只實(shí)現(xiàn)了 Lock 中定義的三個(gè)方法:lock(), tryLock(long time, TimeUnit unit), unlock()宏胯,其他方法可以自由發(fā)散。

接下來(lái)我們將主要介紹獲取鎖本姥、釋放鎖這兩部分代碼肩袍。

lock

private static final String OBTAIN_LOCK_SCRIPT =
            "local lockClientId = redis.call('GET', KEYS[1])\n" +
            "if lockClientId == ARGV[1] then\n" +
            "  redis.call('EXPIRE', ARGV[2])\n" +
            "  return true\n" +
            "else if not lockClientId then\n" +
            "  redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])\n" +
            "  return true\n" +
            "end\n" +
            "return false";

@Override
public void lock() {
    localLock.lock();
    boolean acquired = false;
    try {
        while (!(acquired = obtainRemoteLock())) {
            sleep();
        }
    } finally {
        if (!acquired) {
            localLock.unlock();
        }
    }
}

private boolean obtainRemoteLock() {
    return Boolean.parseBoolean((String) getJedis().eval(
            OBTAIN_LOCK_SCRIPT, 1, lockKey, clientId, String.valueOf(EXPIRE_SECONDS)));
}

private void sleep() {
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {
        // do not response interrupt
    }
}

lock()中包含了絕大多數(shù)的核心邏輯,可以看到其主要流程如下:

  • 獲取本地鎖
  • 循環(huán)調(diào)用 obtainRemoteLock() 直至其返回 true婚惫,或拋出異常
  • 假如跳出循環(huán)后仍未能獲取到鎖氛赐,則釋放本地鎖

以上流程中,需要細(xì)說(shuō)的正是 obtainRemoteLock()
該方法直接通過(guò) eval 來(lái)執(zhí)行了前面提到的 lua 腳本先舷,我們來(lái)看看腳本的內(nèi)容:

  1. local lockClientId = redis.call('GET', KEYS[1])
    • 此處是通過(guò) get 獲取到了 key 值艰管,并賦值為 lockClientId,其中 KEYS[1] 是 eval 傳入的 key 參數(shù)
  2. if lockClientId == ARGV[1] then
    • 這里將拿到的值與參數(shù) ARGV[1] 進(jìn)行判斷蒋川,結(jié)合 obtainRemoteLock()的邏輯我們發(fā)現(xiàn) ARGV[1] 其實(shí)是 clientId牲芋,所以假如獲取的值與 clientId 相等,則代表一種情況:獲取鎖的線程與鎖處于同一個(gè)實(shí)例
    • 又因?yàn)椋好看潍@取遠(yuǎn)程鎖之前需要先獲取本地鎖捺球,在同一實(shí)例下缸浦,本地鎖確保了同一時(shí)間只能有一個(gè)線程嘗試獲取遠(yuǎn)程鎖
    • 結(jié)合上述兩點(diǎn),可以確定:當(dāng) lockClientId 等于 clientId 的時(shí)候氮兵,是同一實(shí)例下的同一線程重入了代碼段裂逐。
    • redis.call('EXPIRE', ARGV[2]) 在重入之后刷新鎖超時(shí)時(shí)間,ARGV[2] 即我們傳入的 EXPIRE_SECONDS
    • 最后直接返回 true胆剧,結(jié)束邏輯
  3. else if not lockClientId then
    • 假如 get 的結(jié)果為 null(nil) 表明鎖還沒(méi)有被任何人獲取絮姆,直接獲取后返回 true
    • 這里用到了 redis 的 set 命令 redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
  4. return false
    • 即不是重入醉冤,鎖又存在秩霍,證明鎖被其他實(shí)例持有了,返回 false

上述一連串判斷邏輯蚁阳,因?yàn)槿慷际窃?Redis 內(nèi)執(zhí)行的铃绒,我們完全不用考慮原子性問(wèn)題,因此可以放心大膽的相信執(zhí)行結(jié)果螺捐。

tryLock

@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
    if ( !localLock.tryLock(time, unit)) {
        return false;
    }

    boolean acquired = false;
    try {
        long expire = System.currentTimeMillis() + TimeUnit.MILLISECONDS.convert(time, unit);
        while (!(acquired = obtainRemoteLock()) && System.currentTimeMillis() < expire) {
            sleep();
        }
        return acquired;
    } finally {
        if (!acquired) {
            localLock.unlock();
        }
    }
}

結(jié)合 lock()的邏輯颠悬,tryLock()看起來(lái)只是增加了超時(shí)邏輯矮燎,并沒(méi)有本質(zhì)的區(qū)別。

unlock

@Override
public void unlock() {
    if (!localLock.isHeldByCurrentThread()) {
        throw new IllegalStateException("You do not own lock at " + lockKey);
    }

    if (localLock.getHoldCount() > 1) {
        localLock.unlock();
        return;
    }

    try {
        if (clientId.equals(getJedis().get(lockKey))) {
            throw new IllegalStateException("Lock was released in the store due to expiration. " +
                    "The integrity of data protected by this lock may have been compromised.");
        }
        getJedis().del(lockKey);
    } finally {
        localLock.unlock();
    }
}

相比本地鎖赔癌,分布式鎖的解鎖過(guò)程需要考慮的多一些:

  1. 先判斷嘗試解鎖的線程與持有本地鎖的線程是否一致诞外,實(shí)際上 ReentrantLock.unlock() 原生即有相關(guān)判斷,但是目前我們還暫時(shí)不想讓本地鎖直接被解鎖灾票,因此手動(dòng)判斷一下峡谊。
  2. 當(dāng)本地鎖重入計(jì)數(shù)大于 1 時(shí),本地鎖解鎖后直接返回刊苍。由于我們的遠(yuǎn)程鎖并沒(méi)有記錄重入計(jì)數(shù)這一參數(shù)既们,因此對(duì)于重入線程的解鎖,只解鎖本地正什。
  3. 先判斷當(dāng)前遠(yuǎn)程鎖的值是否與本實(shí)例 clientId 相等啥纸,如果不等則認(rèn)為是遠(yuǎn)程鎖超時(shí)被釋放,因此分布式鎖的邏輯已經(jīng)被破壞婴氮,只能拋出異常斯棒。
    • 這里涉及到遠(yuǎn)程鎖超時(shí)時(shí)間的設(shè)定問(wèn)題,設(shè)定過(guò)長(zhǎng)可能會(huì)導(dǎo)致死鎖時(shí)間過(guò)長(zhǎng)莹妒,設(shè)定過(guò)短則容易在邏輯未執(zhí)行完便自動(dòng)釋放名船,因此實(shí)際上應(yīng)該結(jié)合業(yè)務(wù)來(lái)設(shè)定。
  4. 假如一切正常旨怠,則釋放遠(yuǎn)程鎖渠驼,之后再釋放本地鎖。

RedisLockRegistry

對(duì) Spring Integration Redis 熟悉的同學(xué)鉴腻,一定已經(jīng)發(fā)現(xiàn)迷扇,前面的代碼完全就是 RedisLockRegistry 的簡(jiǎn)化版,許多變量名都沒(méi)改爽哎。

是的蜓席,其實(shí)前文所述的代碼就是 RedisLockRegistry 的核心邏輯。RedisLockRegistry 是 Redis 分布式鎖中代碼比較簡(jiǎn)單课锌、功能比較完善的一種實(shí)現(xiàn)厨内,可以很好的滿足常見(jiàn)的分布式鎖要求。(由于采用 sleep-retry 的方式嘗試獲取鎖渺贤,在低時(shí)延或高并發(fā)要求下并不適用)

RedisLockRegistry 對(duì)外部庫(kù)的依賴較少雏胃,雖然執(zhí)行 redis 命令主要使用的 Spring Redis Template,不過(guò)也很容易遷移為類似 Jedis 的方案志鞍。

不過(guò)截至目前 Spring-Integration-Redis 在 github 上面并沒(méi)有放置任何 licence瞭亮,按照 github 的規(guī)定,沒(méi)有 licence 的代碼版權(quán)默認(rèn)受到保護(hù)固棚,因此我們可以學(xué)習(xí)其設(shè)計(jì)思想并自己嘗試實(shí)現(xiàn)统翩,但是最好不要直接移植代碼仙蚜。

Redis 多實(shí)例

通過(guò)上述方法,我們似乎可以成功的將實(shí)例間同步的問(wèn)題轉(zhuǎn)交給 Redis 來(lái)處理厂汗。然而就存在兩種情況:

  1. 采用單實(shí)例 Redis -- Redis 存在單點(diǎn)風(fēng)險(xiǎn)委粉,應(yīng)用服務(wù)都依賴 Redis, 一旦宕機(jī)業(yè)務(wù)全掛
  2. 采用 Redis 集群 -- 應(yīng)用服務(wù)實(shí)例間的同步問(wèn)題轉(zhuǎn)化為了 Redis 實(shí)例間的同步問(wèn)題

單實(shí)例 Redis 一定是不可接受的娶桦,所以似乎允許上生產(chǎn)環(huán)境的唯一方案就是 Redis 集群了艳丛。那么如何保證 Redis 實(shí)例間的同步呢?

我們知道趟紊,Redis 集群的數(shù)據(jù)冗余策略不同于類似 HDFS 的 3 Replica氮双,而是采用一對(duì)一主從的形式,每個(gè)節(jié)點(diǎn)一主一從霎匈,主節(jié)點(diǎn)宕機(jī)備節(jié)點(diǎn)上戴差,備節(jié)點(diǎn)也宕機(jī)就全完。同時(shí)铛嘱,主從之間的數(shù)據(jù)同步是異步的暖释。以上這些都是為了超高吞吐量而做出的妥協(xié)。

所以墨吓,設(shè)想會(huì)有這種情況:

當(dāng)應(yīng)用服務(wù)節(jié)點(diǎn) App-A 從 Redis 某主節(jié)點(diǎn) R-Master 獲取到鎖后球匕,R-Master 宕機(jī),此時(shí) R-Master 的數(shù)據(jù)還沒(méi)來(lái)得及同步到 R-Slave√妫現(xiàn)在 R-Slave 成為了主節(jié)點(diǎn)亮曹,這時(shí)候 App-B 嘗試獲取鎖,不出意外的也獲取成功了秘症。

基于以上問(wèn)題照卦,Redis 給出了 RedLock 方案,該方案采用相互孤立的奇數(shù)個(gè) Redis 節(jié)點(diǎn)來(lái)共同存儲(chǔ)鎖乡摹,對(duì)于獲取鎖的操作役耕,只有當(dāng) (N-1)/2 + 1 個(gè) Redis 實(shí)例都獲取成功且獲取時(shí)間不超過(guò)鎖失效時(shí)間的前提下,才真正被判定為獲取到了鎖聪廉,這種場(chǎng)景下鎖的爭(zhēng)搶就看誰(shuí)能先成功操作超過(guò)半數(shù)的 Redis 實(shí)例瞬痘。Redisson 實(shí)現(xiàn)了 RedLock 的客戶端方案

當(dāng)然板熊,在 Redis 官網(wǎng)上也貼出了各方對(duì)于 RedLock 方案的爭(zhēng)論框全,這里不再贅述。

總之邻邮,對(duì)于問(wèn)題的處理終歸是結(jié)合實(shí)際情況來(lái)權(quán)衡的竣况,

  • 假如小概率(但幾乎一定會(huì)發(fā)生)的 Redis 宕機(jī)未同步導(dǎo)致鎖失效的問(wèn)題克婶,業(yè)務(wù)可以承受筒严,那么 RedisLockRegistry + Redis 集群的方案就沒(méi)問(wèn)題
  • 對(duì)性能和可靠性都有更高要求的情況下丹泉,不妨使用 RedLock 方案
  • 業(yè)務(wù)非常關(guān)鍵,一定要求強(qiáng)一致的分布式鎖鸭蛙,使用 ZooKeeper 的方案會(huì)更好(性能沒(méi)法和 Redis 比)

參考

RedisLockRegistry at Github

Redis Documentaion

原創(chuàng)文章摹恨,作者 LENSHOOD, 首發(fā)自:https://lenshood.github.io/2020/02/04/redis-distributed-lock/

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末娶视,一起剝皮案震驚了整個(gè)濱河市晒哄,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌肪获,老刑警劉巖寝凌,帶你破解...
    沈念sama閱讀 218,122評(píng)論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異孝赫,居然都是意外死亡较木,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,070評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén)青柄,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)伐债,“玉大人,你說(shuō)我怎么就攤上這事致开》逅” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 164,491評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵双戳,是天一觀的道長(zhǎng)虹蒋。 經(jīng)常有香客問(wèn)我,道長(zhǎng)飒货,這世上最難降的妖魔是什么千诬? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,636評(píng)論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮膏斤,結(jié)果婚禮上徐绑,老公的妹妹穿的比我還像新娘。我一直安慰自己莫辨,他們只是感情好傲茄,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,676評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著沮榜,像睡著了一般盘榨。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上蟆融,一...
    開(kāi)封第一講書(shū)人閱讀 51,541評(píng)論 1 305
  • 那天草巡,我揣著相機(jī)與錄音,去河邊找鬼型酥。 笑死山憨,一個(gè)胖子當(dāng)著我的面吹牛查乒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播郁竟,決...
    沈念sama閱讀 40,292評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼玛迄,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了棚亩?” 一聲冷哼從身側(cè)響起蓖议,我...
    開(kāi)封第一講書(shū)人閱讀 39,211評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎讥蟆,沒(méi)想到半個(gè)月后勒虾,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,655評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡瘸彤,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,846評(píng)論 3 336
  • 正文 我和宋清朗相戀三年从撼,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片钧栖。...
    茶點(diǎn)故事閱讀 39,965評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡低零,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出拯杠,到底是詐尸還是另有隱情掏婶,我是刑警寧澤,帶...
    沈念sama閱讀 35,684評(píng)論 5 347
  • 正文 年R本政府宣布潭陪,位于F島的核電站雄妥,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏依溯。R本人自食惡果不足惜老厌,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,295評(píng)論 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望黎炉。 院中可真熱鬧枝秤,春花似錦、人聲如沸慷嗜。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,894評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)庆械。三九已至薇溃,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間缭乘,已是汗流浹背沐序。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,012評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人策幼。 一個(gè)月前我還...
    沈念sama閱讀 48,126評(píng)論 3 370
  • 正文 我出身青樓邑时,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親垄惧。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,914評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容