Spring事物認識不清插入唯一數據的各種問題

上篇文章中解決了死鎖問題,但是新問題出現,我發現user_data表中有重複的user_id記錄。原因分析不言而喻,併發插入引起。上次代碼:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void update(userId, numData){
   D userData = selectByUserId(userId);
    if(null == userData){
        // 初始化且合併數據
        merge(userData,numData);
        save(userData);
    }
    // 合併數據
    merge(userData,numData);
    update(userData);
}

當兩個請求同時到達update方法的時候,都發現不存在,在沒有唯一索引的情況下,就會同時插入兩條一模一樣user_id的數據。說一下解決問題的過程,很曲折。在說解決問題過程中,交代一個背景就是我們系統還是單服,不用考慮分佈式併發問題的解決方案,數據庫的隔離級別是讀提交(READ-COMMITTED)。

簡單啊,併發問題而已,方法加上關鍵字synchronized

有人肯定會說整個方法加鎖,併發度低的問題,暫時我們不去考慮這個問題,因爲這段代碼執行操作不多,速度很快。加上關鍵字synchronized後,代碼如下:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public synchronized void update(userId, numData){
   D userData = selectByUserId(userId);
    if(null == userData){
        // 初始化且合併數據
        merge(userData,numData);
        save(userData);
    }
    // 合併數據
    merge(userData,numData);
    update(userData);
}

當時的自己是如此的瀟灑自如啊,洋洋自得。然後測試一下,第一次沒問題,第二次,一首涼涼送給自己。爲什麼?一樣出現重複數據,這是爲什麼呢?因爲自己對Spring事物的理解不透徹。後來百度一下,查到原因了。

原因:Spring的事物是基於動態代理AOP實現的,所以鎖會在事物提交之前釋放。

想想,如果事物沒有提交,但是鎖釋放了,就會有第二個線程的select還是不存在,所以插入重複的user_id數據記錄了。那怎麼辦呢?後來終於找到了一個方法(神坑方法)

註冊Spring事物的生命回調

方法就是:註冊Spring的事物的生命回調,使用ReentrantLock,在回調裏unlock;兩個類:TransactionSynchronizationManagerTransactionSynchronizationAdapter不知道怎麼使用,自行百度,改完之後代碼如下:

private final Lock lock = new ReentrantLock( );
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
    public void update(Long userId, UserRoleData data) {
        // 方法1
        UserData userData = selectByUserId(userId);
        if (null == userData) {
            lock.lock( );
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter( ) {
                @Override
                public void afterCompletion(int status) {
                    try {
                        super.afterCompletion(status);
                    } finally {
                        lock.unlock( );
                    }
                }
            });
            // 方法2
            userData = selectByUserId(userId);
            if (null == userData) {
                // 初始化且合併數據
        		merge(userData,numData);
        		save(userData);
                return;
            }
        }
        // 合併數據
    	merge(userData,numData);
    	update(userData);
    }

以上代碼,我想了一下,鎖整個方法,併發性低,其實大部分情況都是更新,插入情況很少,這不禁讓我想起來單例模式的雙重鎖檢查的寫法,於是改成上述上代碼。

再說一下,我此時根據百度的認識,TransactionSynchronizationAdapter中的方法,各個方法的使用時機,認識如下:

  1. afterCommit 在事物提交之後被調用;
  2. afterCompletion 在事物完成之後會被調用。

先說一下結局吧,結局就是併發執行,還是會有重複user_id的記錄。瞬間淚奔了,我也在此處卡了很久。因爲從目前的認識來說,不應該存在這種情況。我們分析一下,爲啥不會啊?

  1. 第一個線程判斷不存在數據所以會在進入第二個判斷之前,lock住代碼,在事物完成之後unlock;
  2. 就算有一個線程進入了第一次判空裏,在上一個線程釋放鎖後,進入lock塊,第二次查詢肯定是存在數據的(因爲事物讀提交,所以上個線程提交之後,這裏是可以查詢到數據的),因此不會進入,而是走到最後進行更新。

但是爲啥結局不是這樣的呢?我瞬間沒有了放向,問了很多人,很多人也一時間不知道什麼原因,反正各種說法。

說法如下:

  1. Spring的事物傳播特性是REQUIRES_NEW的時候,會有危險的事情發生;大部分都是講另起事物,將上個事物掛起,所以可能發生鎖等待事情;子事物發生異常不會讓父事物回滾。這麼多好像跟我這個沒關係。
  2. 還有就是事物隔離級別確定有沒有錯,結果我反覆確認,沒有錯(就是讀提交,代碼和數據庫都標記上)。
  3. 竟然有人說ReentrantLock有問題,它的實現是阻塞對列。
  4. 還有人說我寫的狗屎代碼,好吧,我狗屎了,我只是想知道原理而已,並且解決問題。

反正說法各一,於是我就把實際的代碼改寫成一個demo並且在各個要點打上日誌,不停的各種調試。改寫的代碼如下:

    private final Lock lock = new ReentrantLock();

    @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
    public void update(User user) {
        // 根據username查詢用戶,username普通索引
        User top = userMapper.findTopByUsername(user);
        if (null == top) {
            System.out.println("Lock 之前 = " + Thread.currentThread().getName());
            lock.lock();
            System.out.println("Lock 進入 = " + Thread.currentThread().getName());
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                @Override
                public void afterCompletion(int status) {
                    super.afterCompletion(status);
                    System.out.println("==== afterCompletion = " + Thread.currentThread().getName());
                    lock.unlock();
                }
            });
            top = userMapper.findTopByUsername(user);
            System.out.println("second top = " + top);
            if (null == top) {
                top = new User();
                top.setUsername(user.getUsername())
                        .setNum(user.getNum())
                ;
                userMapper.save(top);
                return;
            }
        }
        userMapper.update(user);
    }

在這裏插入圖片描述

日誌的現象是:併發進入第一個判斷裏的時候的線程會等待在前一個線程的鎖釋放後才繼續執行,但是第二個查詢依然查詢不到值。白話就是:查詢不到已提交事務的結果。還有一個就是併發進入第一個空判斷的線程似乎必然查詢不到前一個線程save的記錄

結果跟現象一致,多少個線程併發進入了第一個空判斷,數據庫就有多少條相同username的記錄。

根據現象發生的可能就是隻有兩種:一個是執行afterCompletion方法的時候,事務並沒有提交;還有一個就是事務的隔離級別不對。

針對第一個猜想,同特意翻了一下Spring事務相關的源碼,這裏簡要說一下源碼吧,類調用如下:
TransactionInterceptor#invoke ==========> TransactionAspectSupport#invokeWithinTransaction ==========> AbstractPlatformTransactionManager

大致的調用流程,這過程我發現,很多框架都是繼承AbstractPlatformTransactionManager實現自己定製的一些事務管理。

最終在找到提交事務的方法AbstractPlatformTransactionManager#commit,代碼如下:

@Override
	public final void commit(TransactionStatus status) throws TransactionException {
		if (status.isCompleted()) {
			throw new IllegalTransactionStateException(
					"Transaction is already completed - do not call commit or rollback more than once per transaction");
		}

		DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
		if (defStatus.isLocalRollbackOnly()) {
			if (defStatus.isDebug()) {
				logger.debug("Transactional code has requested rollback");
			}
			processRollback(defStatus, false);
			return;
		}

		if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
			if (defStatus.isDebug()) {
				logger.debug("Global transaction is marked as rollback-only but transactional code requested commit");
			}
			processRollback(defStatus, true);
			return;
		}

		processCommit(defStatus);
	}

private void processCommit(DefaultTransactionStatus status) throws TransactionException {
		try {
			boolean beforeCompletionInvoked = false;

			try {
				boolean unexpectedRollback = false;
				prepareForCommit(status);
				triggerBeforeCommit(status);
				triggerBeforeCompletion(status);
				beforeCompletionInvoked = true;

				if (status.hasSavepoint()) {
					if (status.isDebug()) {
						logger.debug("Releasing transaction savepoint");
					}
					unexpectedRollback = status.isGlobalRollbackOnly();
					status.releaseHeldSavepoint();
				}
				else if (status.isNewTransaction()) {
					if (status.isDebug()) {
						logger.debug("Initiating transaction commit");
					}
					unexpectedRollback = status.isGlobalRollbackOnly();
                    //TODO 提交事務
					doCommit(status);
				}
				else if (isFailEarlyOnGlobalRollbackOnly()) {
					unexpectedRollback = status.isGlobalRollbackOnly();
				}

				// Throw UnexpectedRollbackException if we have a global rollback-only
				// marker but still didn't get a corresponding exception from commit.
				if (unexpectedRollback) {
					throw new UnexpectedRollbackException(
							"Transaction silently rolled back because it has been marked as rollback-only");
				}
			}
			catch (UnexpectedRollbackException ex) {
				// can only be caused by doCommit
				triggerAfterCompletion(status, TransactionSynchronization.STATUS_ROLLED_BACK);
				throw ex;
			}
			catch (TransactionException ex) {
				// can only be caused by doCommit
				if (isRollbackOnCommitFailure()) {
					doRollbackOnCommitException(status, ex);
				}
				else {
					triggerAfterCompletion(status, TransactionSynchronization.STATUS_UNKNOWN);
				}
				throw ex;
			}
			catch (RuntimeException | Error ex) {
				if (!beforeCompletionInvoked) {
					triggerBeforeCompletion(status);
				}
				doRollbackOnCommitException(status, ex);
				throw ex;
			}

			// Trigger afterCommit callbacks, with an exception thrown there
			// propagated to callers but the transaction still considered as committed.
			try {
                //TODO 執行afterCommit方法
				triggerAfterCommit(status);
			}
			finally {
                //TODO 這裏就是調用事務回調的afterCompletion方法
				triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
			}

		}
		finally {
			cleanupAfterCompletion(status);
		}
	}

private void triggerAfterCompletion(DefaultTransactionStatus status, int completionStatus) {
		if (status.isNewSynchronization()) {
			List<TransactionSynchronization> synchronizations = TransactionSynchronizationManager.getSynchronizations();
			TransactionSynchronizationManager.clearSynchronization();
			if (!status.hasTransaction() || status.isNewTransaction()) {
				if (status.isDebug()) {
					logger.trace("Triggering afterCompletion synchronization");
				}
				// No transaction or new transaction for the current scope ->
				// invoke the afterCompletion callbacks immediately
                //TODO 執行回調列表
				invokeAfterCompletion(synchronizations, completionStatus);
			}
			else if (!synchronizations.isEmpty()) {
				// Existing transaction that we participate in, controlled outside
				// of the scope of this Spring transaction manager -> try to register
				// an afterCompletion callback with the existing (JTA) transaction.
				registerAfterCompletionWithExistingTransaction(status.getTransaction(), synchronizations);
			}
		}
	}

看整個源碼發現:那個回調方法的確是在代碼調用了doCommit之後調用的,並且是回調生命週期中最後執行的方法。

到此我就覺得很詭異了,並且我懷疑過這個註冊的回調方法的時機,我還特意問了很多人包括查資料,都說保證事務提交纔會調用,當然從源碼看也是這樣的。如果是這樣,那上面出問題就是第二個,就是事務的隔離級別的問題,但是各種校驗,事務隔離級別是讀提交啊,簡直是見鬼了。後來我懷疑是不是框架某個配置的原因,導致中間修改了隔離級別,我做了一個實驗,那就是代碼裏掛起一個事務,去數據查詢當前的事務信息。結果查詢當前正在執行的事務是讀提交,結果如下:
在這裏插入圖片描述
到此,我開始懷疑人生了,我心中頓時一萬隻草泥馬奔騰而過,問別人,別人也不知道原因。只是說了一下解決唯一的方法,其實解決唯一的方法很簡單,只要加上唯一索引就好了,然後在代碼的異常中處理更新。但是我就要想知道原理。

後來睡了一覺,腦子清醒了一點,發現是因爲讀不到數據,那就把第二個查詢改成當前讀(因爲這樣除了串行化事務隔離級別,其他都能讀到)試試。代碼如下:

    @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
    public void update(User user) {
        // 根據username查詢用戶,username普通索引
        // SQL: select * from user where username = #{username} limit 1
        User top = userMapper.findTopByUsername(user);
        if (null == top) {
            System.out.println("Lock 之前 = " + Thread.currentThread().getName());
            lock.lock();
            System.out.println("Lock 進入 = " + Thread.currentThread().getName());
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                @Override
                public void afterCompletion(int status) {
                    super.afterCompletion(status);
                    System.out.println("==== afterCompletion = " + Thread.currentThread().getName());
                    lock.unlock();
                }
            });
            // SQL: select * from user where username = #{username} limit 1 for update
            top = userMapper.findCCTopByUsername(user);
            System.out.println("second top = " + top);
            if (null == top) {
                top = new User();
                top.setUsername(user.getUsername())
                        .setNum(user.getNum())
                ;
                userMapper.save(top);
                return;
            }
        }
        userMapper.update(user);
    }

這次結果完整無誤,果然只有一條username的記錄了。此時的我還是一臉矇蔽,不知道爲啥這樣就沒問題了,此時我有個疑惑,for update 在讀提交是怎麼加鎖的呢?在此時,我知道在可重複讀級別下:數據記錄存在的時候會加行鎖和間隙鎖,當然如果索引是唯一索引會退化成行鎖;在數據記錄不存的時候,會對整個表加上間隙鎖,所以就會出現網上都說的發生死鎖的情況。但是讀提交事務隔離級別是沒有間隙鎖的,只有行鎖和mdl鎖,於是我在客戶端模擬了一下,發現for update 在記錄存在的時候加的是行鎖,只會和相關記錄衝突。比如:當前事務正在更新或者插入一條id爲1 的記錄,for update就會阻塞等待,否則不會阻塞等待。

情況如下:在這裏插入圖片描述

注意:但是如果之前沒有username爲xcf,for update 比插入先執行的話,插入和for update是不會阻塞對方的。

到此時就明白爲啥上面代碼沒問題了,原因分析如下:如果一個線程執行插入的時候,即使事務沒有提交,其他併發線程select for update 上一個相同的username時,會阻塞等待上個線程執行commit, 上個線程commit了,此時查詢的select for update是不爲null的就不會執行插入,而是執行更新操作

最後,爲啥Spring註冊回調那種方法沒有用,後來也翻源碼,找到一些蛛絲馬跡,在註冊實例類TransactionSynchronization中,代碼如下:

/**
	 * Invoked after transaction commit/rollback.
	 * Can perform resource cleanup <i>after</i> transaction completion.
	 * <p><b>NOTE:</b> The transaction will have been committed or rolled back already,
	 * but the transactional resources might still be active and accessible. As a
	 * consequence, any data access code triggered at this point will still "participate"
	 * in the original transaction, allowing to perform some cleanup (with no commit
	 * following anymore!), unless it explicitly declares that it needs to run in a
	 * separate transaction. Hence: <b>Use {@code PROPAGATION_REQUIRES_NEW}
	 * for any transactional operation that is called from here.</b>
	 * @param status completion status according to the {@code STATUS_*} constants
	 * @throws RuntimeException in case of errors; will be <b>logged but not propagated</b>
	 * (note: do not throw TransactionException subclasses here!)
	 * @see #STATUS_COMMITTED
	 * @see #STATUS_ROLLED_BACK
	 * @see #STATUS_UNKNOWN
	 * @see #beforeCompletion
	 */
	default void afterCompletion(int status) {
	}

看註釋,描述一下大致意思:在事務提交或回滾之前執行,可以使用它進行事務完成的資源清理工作,接下來重點來了,提示:事務已經提交或者回滾,但是事務資源可能仍然是激活狀態並且可使用的,結果就是此時觸發的任何數據訪問代碼仍將“參與”在原始事務中,允許執行清理工作(無需繼續跟蹤提交),除非聲明在單獨的事務中,使用PROPAGATION_REQUIRES_NEW對於這裏調用的任何事務。

它的意思是不是這裏做清理工作,我們的save和update也要單獨另起一個事務。我想了一下,如果這樣做,應該是可行的,我們把save另起一個事務試試,代碼如下:

@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
    public void update(User user) {
        // 根據username查詢用戶,username普通索引
        // SQL: select * from user where username = #{username} limit 1
        User top = userMapper.findTopByUsername(user);
        if (null == top) {
            System.out.println("Lock 之前 = " + Thread.currentThread().getName());
            lock.lock();
            System.out.println("Lock 進入 = " + Thread.currentThread().getName());
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                @Override
                public void afterCompletion(int status) {
                    super.afterCompletion(status);
                    System.out.println("==== afterCompletion = " + Thread.currentThread().getName());
                    lock.unlock();
                }
            });
            top = userMapper.findTopByUsername(user);
            System.out.println("second top = " + top);
            if (null == top) {
                top = new User();
                top.setUsername(user.getUsername())
                        .setNum(user.getNum())
                ;
                userMapper.save(top);
                return;
            }
        }
        userMapper.update(user);
    }


    @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
    @Insert("INSERT INTO `user` (`username`, `num`) VALUES (#{username}, #{num})")
    void save(User user);

結果發現並沒有用,頓時覺得這個事務回調不知道具體什麼情況,感覺afterCompletion方法調用是在代碼commit之後執行,但是此時數據庫中事務有沒有commit不知道,希望有牛人解答一下(其實不是Spring的鍋,請看後續內容,該段誤人子弟了)

Sharding JDBC 讀寫分離的插曲

我在demo中代碼已經沒有問題了,於是移植到我項目中,發現一個現象,事務沒法提交,一開始我以爲是死鎖,異常是鎖等待超時,總分析,不可能死鎖啊。後來還好我注意到了,查詢數據死鎖記錄是不會發生變化的,說明不是死鎖。就是純粹的死鎖等待。於是分析,不應該事務會阻塞啊,這都是微操作,又沒有資源競爭導致,事務等待。於是把多線程執行這段代碼改成單線程執行了一下,結果也是提交不了事務,顯示select for update 等待上一個事務的鎖。

把for update 去掉試了一下,沒問題,事務正常提交,說明這事情肯定跟鎖相關。

於是我分析了一下,會不會數據庫鎖和代碼的鎖發生死鎖,打日誌,分析加鎖過程。可以保證的是for update和insert是不會發生死鎖的,因爲事務的隔離級別是讀提交,沒有間隙鎖的概念。簡化加鎖過程如下:CLock ====> DBLock =========> unClock ========> unDBLock,不存在交叉獲取鎖的情況。說明這個方向是錯誤的。

於是就覺得是不是框架的坑,就像上面的Spring事務的回調一樣(demo是mybatis,項目是mybatis-plus),於是百度了一下,還真找到相關資料,說的是:**mybatis是數據源設置爲自動提交時,不會幫我們提交事務,druid默認值就是true設置。**我剛好用的就是druid,於是立馬改了一下配置,結果一樣的依然提交不了事務。

過了一段時間,偶然百度看到一個字眼,讀寫分離事務也要分離,瞬間發現新大陸了。我用的是Sharding JDBC 讀走的從數據庫數據源,寫走的是主數據庫數據源,由於開發。所以我把兩個數據配置配成一樣了。於是立馬把事務分離了,立馬可以正常提交事務了。代碼如下:

    @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
    public void update(User user) {
        // 根據username查詢用戶,username普通索引
        // SQL: select * from user where username = #{username} limit 1
        User top = userMapper.findTopByUsername(user);
        if (null == top) {
            System.out.println("Lock 之前 = " + Thread.currentThread().getName());
            lock.lock();
            System.out.println("Lock 進入 = " + Thread.currentThread().getName());
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                @Override
                public void afterCompletion(int status) {
                    super.afterCompletion(status);
                    System.out.println("==== afterCompletion = " + Thread.currentThread().getName());
                    lock.unlock();
                }
            });
            // SQL: select * from user where username = #{username} limit 1 for update
            //TODO 另起一個事務去執行。
            top = userMapper.findCCTopByUsername(user);
            System.out.println("second top = " + top);
            if (null == top) {
                top = new User();
                top.setUsername(user.getUsername())
                        .setNum(user.getNum())
                ;
                userMapper.save(top);
                return;
            }
        }
        userMapper.update(user);
    }

讀寫分離:事務記得也要分離,不然可能會出現莫名其妙的坑。

代碼優化

看到上面代碼,發現在回調裏解鎖,事務也是沒提交,於是去掉了Spring的事務回調代碼,代碼如下:

@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
    public void update(User user) {
        // 根據username查詢用戶,username普通索引
        // SQL: select * from user where username = #{username} limit 1
        User top = userMapper.findTopByUsername(user);
        if (null == top) {
            synchronized (lock) {
                top = userMapper.findCCTopByUsername(user);
                System.out.println("second top = " + top);
                if (null == top) {
                    top = new User();
                    top.setUsername(user.getUsername())
                            .setNum(user.getNum())
                    ;
                    userMapper.save(top);
                    return;
                }
            }
        }
        userMapper.update(user);
    }

後來又想了一下,反正第二個for update查詢會阻塞是不是可以整加鎖都可以去掉呢,代碼如下:

    @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
    public void update(User user) {
        // 根據username查詢用戶,username普通索引
        // select * from user where username = #{username} limit 1 for update
        User top = userMapper.findCCTopByUsername(user);
        if (null == top) {
            top = new User();
            top.setUsername(user.getUsername())
                    .setNum(user.getNum())
            ;
            userMapper.save(top);
        } else {
            userMapper.update(user);
        }
    }

其實仔細分析不行,爲啥?

原因:多個線程併發進入select for update的時候,沒有事務在執行插入,所以他們不會互相阻塞,同時得到null值,就會執行下面的插入操作,自然就是多條一樣的username記錄存在。像之前在第二次for update之前進行lock,因爲單服,所以其他線程會阻塞,而代碼的鎖釋放時機是執行插入操作之後,然後後面線程進入第二次查詢的時候,因爲有相同的username正在插入,數據庫會讓它鎖等待,直到插入完成之後釋放

總結

解決問題,方案可能很多,但是個人覺得遇到問題,應該去弄懂問題的原理。就像這次經歷,解決問題的過程很曲折,雖然已經有備用方案了,但是還是堅持下來了。

後續

這件事情,又經過了幾天,有朋友討論mybatis的一級緩存,突然腦洞,想起來了。這裏開啓了事務,所以會使用到Mybatis的一級緩存(在Spring中只有開啓事務纔會使用到一級緩存)。想到一個Mybatis會不會對for update 更新做特殊操作,不走緩存。後來驗證果然如此,這時就一切都能解釋的通了。詳情見我文章Mybatis一級緩存的坑

參考如下】:
springboot 開啓事務以及手動提交事務
Spring中@Transactional的使用及事務源碼分析
select for update不交由spring事務管理的正確姿勢
併發insert情況下會發生重複的數據插入問題
瞭解事務陷阱
TransactionSynchronizationManager綁定一個事物並且在事物提交之後操作
如何在數據庫事務提交成功後進行異步操作
併發insert情況下會發生重複的數據插入問題
Spring中聲明式事務的註解@Transactional的參數的總結(REQUIRED和REQUIRES_NEW的與主方法的回滾問題)
瞭解事務陷阱
TransactionSynchronizationManager綁定一個事物並且在事物提交之後操作
如何在數據庫事務提交成功後進行異步操作

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章