如何理解Redis分布式鎖

本篇內(nèi)容主要講解“如何理解redis分布式鎖”,感興趣的朋友不妨來看看。本文介紹的方法操作簡單快捷,實(shí)用性強(qiáng)。下面就讓小編來帶大家學(xué)習(xí)“如何理解Redis分布式鎖”吧!

10多年的三沙網(wǎng)站建設(shè)經(jīng)驗(yàn),針對設(shè)計(jì)、前端、開發(fā)、售后、文案、推廣等六對一服務(wù),響應(yīng)快,48小時及時工作處理。營銷型網(wǎng)站的優(yōu)勢是能夠根據(jù)用戶設(shè)備顯示端的尺寸不同,自動調(diào)整三沙建站的顯示方式,使網(wǎng)站能夠適用不同顯示終端,在瀏覽器中調(diào)整網(wǎng)站的寬度,無論在任何一種瀏覽器上瀏覽網(wǎng)站,都能展現(xiàn)優(yōu)雅布局與設(shè)計(jì),從而大程度地提升瀏覽體驗(yàn)。成都創(chuàng)新互聯(lián)公司從事“三沙網(wǎng)站設(shè)計(jì)”,“三沙網(wǎng)站推廣”以來,每個客戶項(xiàng)目都認(rèn)真落實(shí)執(zhí)行。

你真的需要分布式鎖嗎?

用到分布式鎖說明遇到了多個進(jìn)程共同訪問同一個資源的問題,

一般是在兩個場景下會防止對同一個資源的重復(fù)訪問:

  • 提高效率。比如多個節(jié)點(diǎn)計(jì)算同一批任務(wù),如果某個任務(wù)已經(jīng)有節(jié)點(diǎn)在計(jì)算了,那其他節(jié)點(diǎn)就不用重復(fù)計(jì)算了,以免浪費(fèi)計(jì)算資源。不過重復(fù)計(jì)算也沒事,不會造成其他更大的損失。也就是允許偶爾的失敗。

  • 保證正確性。這種情況對鎖的要求就很高了,如果重復(fù)計(jì)算,會對正確性造成影響。這種不允許失敗。

引入分布式鎖勢必要引入一個第三方的基礎(chǔ)設(shè)施,比如MySQL,Redis,Zookeeper等,這些實(shí)現(xiàn)分布式鎖的基礎(chǔ)設(shè)施出問題了,也會影響業(yè)務(wù),所以在使用分布式鎖前可以考慮下是否可以不用加鎖的方式實(shí)現(xiàn)?

不過這個不在本文的討論范圍內(nèi),本文假設(shè)加鎖的需求是合理的,并且偏向于上面的第二種情況,為什么是偏向?因?yàn)椴淮嬖?00%靠譜的分布式鎖,看完下面的內(nèi)容就明白了。

從一個簡單的分布式鎖實(shí)現(xiàn)說起

分布式鎖的Redis實(shí)現(xiàn)很常見,自己實(shí)現(xiàn)和使用第三方庫都很簡單,至少看上去是這樣的,這里就介紹一個最簡單靠譜的Redis實(shí)現(xiàn)。

最簡單的實(shí)現(xiàn)

實(shí)現(xiàn)很經(jīng)典了,這里只提兩個要點(diǎn)?

  • 加鎖和解鎖的鎖必須是同一個,常見的解決方案是給每個鎖一個鑰匙(唯一ID),加鎖時生成,解鎖時判斷。

  • 不能讓一個資源永久加鎖。常見的解決方案是給一個鎖的過期時間。當(dāng)然了還有其他方案,后面再說。

一個可復(fù)制粘貼的實(shí)現(xiàn)方式如下:

加鎖

public static boolean tryLock(String key, String uniqueId, int seconds) {

    return 'OK'.equals(jedis.set(key, uniqueId, 'NX', 'EX', seconds));

}

這里其實(shí)是調(diào)用了 SET key value PX milliseoncds NX。

不明白這個命令的參考下SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]:https://redis.io/commands/set

解鎖

public static boolean releaseLock(String key, String uniqueId) {

    String luaScript = 'if redis.call('get', KEYS[1]) == ARGV[1] then ' +

            'return redis.call('del', KEYS[1]) else return 0 end';

    return jedis.eval(

        luaScript, 

        Collections.singletonList(key), 

        Collections.singletonList(uniqueId)

    ).equals(1L);

}

這段實(shí)現(xiàn)的精髓在那個簡單的lua腳本上,先判斷唯一ID是否相等再操作。

靠譜嗎?

這樣的實(shí)現(xiàn)有什么問題呢?

  • 單點(diǎn)問題。上面的實(shí)現(xiàn)只要一個master節(jié)點(diǎn)就能搞定,這里的單點(diǎn)指的是單master,就算是個集群,如果加鎖成功后,鎖從master復(fù)制到slave的時候掛了,也是會出現(xiàn)同一資源被多個client加鎖的。

  • 執(zhí)行時間超過了鎖的過期時間。上面寫到為了不出現(xiàn)一直上鎖的情況,加了一個兜底的過期時間,時間到了鎖自動釋放,但是,如果在這期間任務(wù)并沒有做完怎么辦?由于GC或者網(wǎng)絡(luò)延遲導(dǎo)致的任務(wù)時間變長,很難保證任務(wù)一定能在鎖的過期時間內(nèi)完成。

如何解決這兩個問題呢?試試看更復(fù)雜的實(shí)現(xiàn)吧。

Redlock算法

對于第一個單點(diǎn)問題,順著redis的思路,接下來想到的肯定是Redlock了。Redlock為了解決單機(jī)的問題,需要多個(大于2)redis的master節(jié)點(diǎn),多個master節(jié)點(diǎn)互相獨(dú)立,沒有數(shù)據(jù)同步。

Redlock的實(shí)現(xiàn)如下:

1)獲取當(dāng)前時間。

2)依次獲取N個節(jié)點(diǎn)的鎖。每個節(jié)點(diǎn)加鎖的實(shí)現(xiàn)方式同上。這里有個細(xì)節(jié),就是每次獲取鎖的時候的過期時間都不同,需要減去之前獲取鎖的操作的耗時:

  • 比如傳入的鎖的過期時間為500ms;

  • 獲取第一個節(jié)點(diǎn)的鎖花了1ms,那么第一個節(jié)點(diǎn)的鎖的過期時間就是499ms;

  • 獲取第二個節(jié)點(diǎn)的鎖花了2ms,那么第二個節(jié)點(diǎn)的鎖的過期時間就是497ms;

  • 如果鎖的過期時間小于等于0了,說明整個獲取鎖的操作超時了,整個操作失敗。

3)判斷是否獲取鎖成功。如果client在上述步驟中獲取到了(N/2 + 1)個節(jié)點(diǎn)鎖,并且每個鎖的過期時間都是大于0的,則獲取鎖成功,否則失敗。失敗時釋放鎖。

4)釋放鎖。對所有節(jié)點(diǎn)發(fā)送釋放鎖的指令,每個節(jié)點(diǎn)的實(shí)現(xiàn)邏輯和上面的簡單實(shí)現(xiàn)一樣。為什么要對所有節(jié)點(diǎn)操作?因?yàn)榉植际綀鼍跋聫囊粋€節(jié)點(diǎn)獲取鎖失敗不代表在那個節(jié)點(diǎn)上加速失敗,可能實(shí)際上加鎖已經(jīng)成功了,但是返回時因?yàn)榫W(wǎng)絡(luò)抖動超時了。

以上就是大家常見的redlock實(shí)現(xiàn)的描述了,一眼看上去就是簡單版本的多master版本,如果真是這樣就太簡單了,接下來分析下這個算法在各個場景下是怎樣被玩壞的。

分布式鎖的坑

高并發(fā)場景下的問題

以下問題不是說在并發(fā)不高的場景下不容易出現(xiàn),只是在高并發(fā)場景下出現(xiàn)的概率更高些而已。

性能問題。 性能問題來自于兩個方面。

1)獲取鎖的時間上。如果redlock運(yùn)用在高并發(fā)的場景下,存在N個master節(jié)點(diǎn),一個一個去請求,耗時會比較長,從而影響性能。這個好解決。通過上面描述不難發(fā)現(xiàn),從多個節(jié)點(diǎn)獲取鎖的操作并不是一個同步操作,可以是異步操作,這樣可以多個節(jié)點(diǎn)同時獲取。即使是并行處理的,還是得預(yù)估好獲取鎖的時間,保證鎖的TTL > 獲取鎖的時間+任務(wù)處理時間。

2)被加鎖的資源太大。加鎖的方案本身就是會為了正確性而犧牲并發(fā)的,犧牲和資源大小成正比。這個時候可以考慮對資源做拆分,拆分的方式有兩種:

  • 從業(yè)務(wù)上將鎖住的資源拆分成多段,每段分開加鎖。比如,我要對一個商戶做若干個操作,操作前要鎖住這個商戶,這時我可以將若干個操作拆成多個獨(dú)立的步驟分開加鎖,提高并發(fā)。

  • 用分桶的思想,將一個資源拆分成多個桶,一個加鎖失敗立即嘗試下一個。比如批量任務(wù)處理的場景,要處理200w個商戶的任務(wù),為了提高處理速度,用多個線程,每個線程取100個商戶處理,就得給這100個商戶加鎖,如果不加處理,很難保證同一時刻兩個線程加鎖的商戶沒有重疊,這時可以按一個維度,比如某個標(biāo)簽,對商戶進(jìn)行分桶,然后一個任務(wù)處理一個分桶,處理完這個分桶再處理下一個分桶,減少競爭。

重試的問題。無論是簡單實(shí)現(xiàn)還是redlock實(shí)現(xiàn),都會有重試的邏輯。如果直接按上面的算法實(shí)現(xiàn),是會存在多個client幾乎在同一時刻獲取同一個鎖,然后每個client都鎖住了部分節(jié)點(diǎn),但是沒有一個client獲取大多數(shù)節(jié)點(diǎn)的情況。解決的方案也很常見,在重試的時候讓多個節(jié)點(diǎn)錯開,錯開的方式就是在重試時間中加一個隨機(jī)時間。這樣并不能根治這個問題,但是可以有效緩解問題,親試有效。

節(jié)點(diǎn)宕機(jī)

對于單master節(jié)點(diǎn)且沒有做持久化的場景,宕機(jī)就掛了,這個就必須在實(shí)現(xiàn)上支持重復(fù)操作,自己做好冪等。

對于多master的場景,比如redlock,我們來看這樣一個場景:

  • 假設(shè)有5個redis的節(jié)點(diǎn):A、B、C、D、E,沒有做持久化。

  • client1從A、B、C 3個節(jié)點(diǎn)獲取鎖成功,那么client1獲取鎖成功。

  • 節(jié)點(diǎn)C掛了。

  • client2從C、D、E獲取鎖成功,client2也獲取鎖成功,那么在同一時刻client1和client2同時獲取鎖,redlock被玩壞了。

怎么解決呢?最容易想到的方案是打開持久化。持久化可以做到持久化每一條redis命令,但這對性能影響會很大,一般不會采用,如果不采用這種方式,在節(jié)點(diǎn)掛的時候肯定會損失小部分的數(shù)據(jù),可能我們的鎖就在其中。

另一個方案是延遲啟動。就是一個節(jié)點(diǎn)掛了修復(fù)后,不立即加入,而是等待一段時間再加入,等待時間要大于宕機(jī)那一刻所有鎖的最大TTL。

但這個方案依然不能解決問題,如果在上述步驟3中B和C都掛了呢,那么只剩A、D、E三個節(jié)點(diǎn),從D和E獲取鎖成功就可以了,還是會出問題。那么只能增加master節(jié)點(diǎn)的總量,緩解這個問題了。增加master節(jié)點(diǎn)會提高穩(wěn)定性,但是也增加了成本,需要在兩者之間權(quán)衡。

任務(wù)執(zhí)行時間超過鎖的TTL

之前產(chǎn)線上出現(xiàn)過因?yàn)榫W(wǎng)絡(luò)延遲導(dǎo)致任務(wù)的執(zhí)行時間遠(yuǎn)超預(yù)期,鎖過期,被多個線程執(zhí)行的情況。

這個問題是所有分布式鎖都要面臨的問題,包括基于zookeeper和DB實(shí)現(xiàn)的分布式鎖,這是鎖過期了和client不知道鎖過期了之間的矛盾。

在加鎖的時候,我們一般都會給一個鎖的TTL,這是為了防止加鎖后client宕機(jī),鎖無法被釋放的問題。但是所有這種姿勢的用法都會面臨同一個問題,就是沒法保證client的執(zhí)行時間一定小于鎖的TTL。雖然大多數(shù)程序員都會樂觀的認(rèn)為這種情況不可能發(fā)生,我也曾經(jīng)這么認(rèn)為,直到被現(xiàn)實(shí)一次又一次的打臉。

Martin Kleppmann也質(zhì)疑過這一點(diǎn)

  • Client1獲取到鎖;

  • Client1開始任務(wù),然后發(fā)生了STW的GC,時間超過了鎖的過期時間;

  • Client2 獲取到鎖,開始了任務(wù);

  • Client1的GC結(jié)束,繼續(xù)任務(wù),這個時候Client1和Client2都認(rèn)為自己獲取了鎖,都會處理任務(wù),從而發(fā)生錯誤。

Martin Kleppmann舉的是GC的例子,我碰到的是網(wǎng)絡(luò)延遲的情況。不管是哪種情況,不可否認(rèn)的是這種情況無法避免,一旦出現(xiàn)很容易懵逼。

如何解決呢?一種解決方案是不設(shè)置TTL,而是在獲取鎖成功后,給鎖加一個watchdog,watchdog會起一個定時任務(wù),在鎖沒有被釋放且快要過期的時候會續(xù)期。這樣說有些抽象,下面結(jié)合redisson源碼說下:

public class RedissonLock extends RedissonExpirable implements RLock {

    ...

    @Override

    public void lock() {

        try {

            lockInterruptibly();

        } catch (InterruptedException e) {

            Thread.currentThread().interrupt();

        }

    }

    @Override

    public void lock(long leaseTime, TimeUnit unit) {

        try {

            lockInterruptibly(leaseTime, unit);

        } catch (InterruptedException e) {

            Thread.currentThread().interrupt();

        }

    }

    ...

 }

redisson常用的加鎖api是上面兩個,一個是不傳入TTL,這時是redisson自己維護(hù),會主動續(xù)期;另外一種是自己傳入TTL,這種redisson就不會幫我們自動續(xù)期了,或者自己將leaseTime的值傳成-1,但是不建議這種方式,既然已經(jīng)有現(xiàn)成的API了,何必還要用這種奇怪的寫法呢。

接下來分析下不傳參的方法的加鎖邏輯:

public class RedissonLock extends RedissonExpirable implements RLock {

    ...

    public static final long LOCK_EXPIRATION_INTERVAL_SECONDS = 30;

    protected long internalLockLeaseTime = TimeUnit.SECONDS.toMillis(LOCK_EXPIRATION_INTERVAL_SECONDS);

    @Override

    public void lock() {

        try {

            lockInterruptibly();

        } catch (InterruptedException e) {

            Thread.currentThread().interrupt();

        }

    }

    @Override

    public void lockInterruptibly() throws InterruptedException {

        lockInterruptibly(-1, null);

    }

    @Override

    public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {

        long threadId = Thread.currentThread().getId();

        Long ttl = tryAcquire(leaseTime, unit, threadId);

        // lock acquired

        if (ttl == null) {

            return;

        }

        RFuture<RedissonLockEntry> future = subscribe(threadId);

        commandExecutor.syncSubscription(future);

        try {

            while (true) {

                ttl = tryAcquire(leaseTime, unit, threadId);

                // lock acquired

                if (ttl == null) {

                    break;

                }

                // waiting for message

                if (ttl >= 0) {

                    getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);

                } else {

                    getEntry(threadId).getLatch().acquire();

                }

            }

        } finally {

            unsubscribe(future, threadId);

        }

//        get(lockAsync(leaseTime, unit));

    }

    private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {

        return get(tryAcquireAsync(leaseTime, unit, threadId));

    }

    private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {

        if (leaseTime != -1) {

            return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);

        }

        RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS, TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG);

        ttlRemainingFuture.addListener(new FutureListener<Long>() {

            @Override

            public void operationComplete(Future<Long> future) throws Exception {

                if (!future.isSuccess()) {

                    return;

                }

                Long ttlRemaining = future.getNow();

                // lock acquired

                if (ttlRemaining == null) {

                    scheduleExpirationRenewal(threadId);

                }

            }

        });

        return ttlRemainingFuture;

    }

    private void scheduleExpirationRenewal(final long threadId) {

        if (expirationRenewalMap.containsKey(getEntryName())) {

            return;

        }

        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {

            @Override

            public void run(Timeout timeout) throws Exception {

                RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,

                        'if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then ' +

                            'redis.call('pexpire', KEYS[1], ARGV[1]); ' +

                            'return 1; ' +

                        'end; ' +

                        'return 0;',

                          Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));

                future.addListener(new FutureListener<Boolean>() {

                    @Override

                    public void operationComplete(Future<Boolean> future) throws Exception {

                        expirationRenewalMap.remove(getEntryName());

                        if (!future.isSuccess()) {

                            log.error('Can't update lock ' + getName() + ' expiration', future.cause());

                            return;

                        }

                        if (future.getNow()) {

                            // reschedule itself

                            scheduleExpirationRenewal(threadId);

                        }

                    }

                });

            }

        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

        if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {

            task.cancel();

        }

    }

    ...

}

可以看到,最后加鎖的邏輯會進(jìn)入到org.redisson.RedissonLock#tryAcquireAsync中,在獲取鎖成功后,會進(jìn)入scheduleExpirationRenewal,這里面初始化了一個定時器,dely的時間是internalLockLeaseTime / 3。在redisson中,internalLockLeaseTime是30s,也就是每隔10s續(xù)期一次,每次30s。

如果是基于zookeeper實(shí)現(xiàn)的分布式鎖,可以利用zookeeper檢查節(jié)點(diǎn)是否存活,從而實(shí)現(xiàn)續(xù)期,zookeeper分布式鎖沒用過,不詳細(xì)說。

不過這種做法也無法百分百做到同一時刻只有一個client獲取到鎖,如果續(xù)期失敗,比如發(fā)生了Martin Kleppmann所說的STW的GC,或者client和redis集群失聯(lián)了,只要續(xù)期失敗,就會造成同一時刻有多個client獲得鎖了。在我的場景下,我將鎖的粒度拆小了,redisson的續(xù)期機(jī)制已經(jīng)夠用了。

如果要做得更嚴(yán)格,得加一個續(xù)期失敗終止任務(wù)的邏輯。這種做法在以前Python的代碼中實(shí)現(xiàn)過,Java還沒有碰到這么嚴(yán)格的情況。

這里也提下Martin Kleppmann的解決方案,我自己覺得這個方案并不靠譜,原因后面會提到。

他的方案是讓加鎖的資源自己維護(hù)一套保證不會因加鎖失敗而導(dǎo)致多個client在同一時刻訪問同一個資源的情況。

在客戶端獲取鎖的同時,也獲取到一個資源的token,這個token是單調(diào)遞增的,每次在寫資源時,都檢查當(dāng)前的token是否是較老的token,如果是就不讓寫。對于上面的場景,Client1獲取鎖的同時分配一個33的token,Client2獲取鎖的時候分配一個34的token,在client1 GC期間,Client2已經(jīng)寫了資源,這時最大的token就是34了,client1 從GC中回來,再帶著33的token寫資源時,會因?yàn)閠oken過期被拒絕。這種做法需要資源那一邊提供一個token生成器。

對于這種fencing的方案,我有幾點(diǎn)問題:

  • 無法保證事務(wù)。示意圖中畫的只有34訪問了storage,但是在實(shí)際場景中,可能出現(xiàn)在一個任務(wù)內(nèi)多次訪問storage的情況,而且必須是原子的。如果client1帶著33token在GC前訪問過一次storage,然后發(fā)生了GC。client2獲取到鎖,帶著34的token也訪問了storage,這時兩個client寫入的數(shù)據(jù)是否還能保證數(shù)據(jù)正確?如果不能,那么這種方案就有缺陷,除非storage自己有其他機(jī)制可以保證,比如事務(wù)機(jī)制;如果能,那么這里的token就是多余的,fencing的方案就是多此一舉。

  • 高并發(fā)場景不實(shí)用。因?yàn)槊看沃挥凶畲蟮膖oken能寫,這樣storage的訪問就是線性的,在高并發(fā)場景下,這種方式會極大的限制吞吐量,而分布式鎖也大多是在這種場景下用的,很矛盾的設(shè)計(jì)。

  • 這是所有分布式鎖的問題。這個方案是一個通用的方案,可以和Redlock用,也可以和其他的lock用。所以我理解僅僅是一個和Redlock無關(guān)的解決方案。

系統(tǒng)時鐘漂移

這個問題只是考慮過,但在實(shí)際項(xiàng)目中并沒有碰到過,因?yàn)槔碚撋鲜强赡艹霈F(xiàn)的,這里也說下。

redis的過期時間是依賴系統(tǒng)時鐘的,如果時鐘漂移過大時會影響到過期時間的計(jì)算。

為什么系統(tǒng)時鐘會存在漂移呢?先簡單說下系統(tǒng)時間,linux提供了兩個系統(tǒng)時間:clock realtime和clock monotonic。clock realtime也就是xtime/wall time,這個時間可以被用戶改變的,被NTP改變,gettimeofday拿的就是這個時間,redis的過期計(jì)算用的也是這個時間。

clock monotonic ,直譯過來時單調(diào)時間,不會被用戶改變,但是會被NTP改變。

最理想的情況,所有系統(tǒng)的時鐘都時時刻刻和NTP服務(wù)器保持同步,但這顯然是不可能的。導(dǎo)致系統(tǒng)時鐘漂移的原因有兩個:

  • 系統(tǒng)的時鐘和NTP服務(wù)器不同步。這個目前沒有特別好的解決方案,只能相信運(yùn)維同學(xué)了。

  • clock realtime被人為修改。在實(shí)現(xiàn)分布式鎖時,不要使用clock realtime。不過很可惜,redis使用的就是這個時間,我看了下Redis 5.0源碼,使用的還是clock realtime。Antirez說過改成clock monotonic的,不過大佬還沒有改。也就是說,人為修改redis服務(wù)器的時間,就能讓redis出問題了。

總結(jié)

本文從一個簡單的基于redis的分布式鎖出發(fā),到更復(fù)雜的Redlock的實(shí)現(xiàn),介紹了在使用分布式鎖的過程中才踩過的一些坑以及解決方案。

到此,相信大家對“如何理解Redis分布式鎖”有了更深的了解,不妨來實(shí)際操作一番吧!這里是創(chuàng)新互聯(lián)網(wǎng)站,更多相關(guān)內(nèi)容可以進(jìn)入相關(guān)頻道進(jìn)行查詢,關(guān)注我們,繼續(xù)學(xué)習(xí)!

網(wǎng)站名稱:如何理解Redis分布式鎖
路徑分享:http://www.muchs.cn/article6/jpjeog.html

成都網(wǎng)站建設(shè)公司_創(chuàng)新互聯(lián),為您提供移動網(wǎng)站建設(shè)、外貿(mào)建站、域名注冊App開發(fā)、定制開發(fā)、響應(yīng)式網(wǎng)站

廣告

聲明:本網(wǎng)站發(fā)布的內(nèi)容(圖片、視頻和文字)以用戶投稿、用戶轉(zhuǎn)載內(nèi)容為主,如果涉及侵權(quán)請盡快告知,我們將會在第一時間刪除。文章觀點(diǎn)不代表本網(wǎng)站立場,如需處理請聯(lián)系客服。電話:028-86922220;郵箱:631063699@qq.com。內(nèi)容未經(jīng)允許不得轉(zhuǎn)載,或轉(zhuǎn)載時需注明來源: 創(chuàng)新互聯(lián)

網(wǎng)站優(yōu)化排名