做MySQL的都知道,數據庫操作里面,DDL操作(比如CREATE,DROP,ALTER等)代價是非常高的,特別是在單表上千萬的情況下,加個索引或改個列類型,就有可能堵塞整個表的讀寫。
然后 mysql 5.6 開始,大家期待的Online DDL出現了,可以實現修改表結構的同時,依然允許DML操作(select,insert,update,delete)。在這個特性出現以前,用的比較多的工具是pt-online-schema-change
,比較請參考pt-online-schema-change使用說明、限制與比較或 ONLINE DDL VS PT-ONLINE-SCHEMA-CHANGE 。
1. Online DDL
在 MySQL 5.1 (帶InnoDB Plugin)和5.5中,有個新特性叫 Fast Index Creation(下稱 FIC),就是在添加或者刪除二級索引的時候,可以不用復制原表。對於之前的版本對於索引的添加刪除這類DDL操作,MySQL數據庫的操作過程為如下:
- 首先新建Temp table,表結構是 ALTAR TABLE 新定義的結構
- 然后把原表中數據導入到這個Temp table
- 刪除原表
- 最后把臨時表rename為原來的表名
為了保持數據的一致性,中間復制數據(Copy Table)全程鎖表只讀,如果有寫請求進來將無法提供服務,連接數爆張。
引入FIC之后,創建二級索引時會對原表加上一個S鎖,創建過程不需要重建表(no-rebuild);刪除InnoDB二級索引只需要更新內部視圖,並標記這個索引的空間可用,去掉數據庫元數據上該索引的定義即可。這個過程也只允許讀操作,不能寫入,但大大加快了修改索引的速度(不含主鍵索引,InnoDB IOT的特性決定了修改主鍵依然需要 Copy Table )。
FIC只對索引的創建刪除有效,MySQL 5.6 Online DDL把這種特性擴展到了添加列、刪除列、修改列類型、列重命名、設置默認值等等,實際效果要看所使用的選項和操作類別來定。
1.1 Online DDL選項
MySQL 在線DDL分為 INPLACE
和 COPY
兩種方式,通過在ALTER語句的ALGORITHM參數指定。
ALGORITHM=INPLACE
,可以避免重建表帶來的IO和CPU消耗,保證ddl期間依然有良好的性能和並發。
ALGORITHM=COPY
,需要拷貝原始表,所以不允許並發DML寫操作,可讀。這種copy方式的效率還是不如 inplace ,因為前者需要記錄undo和redo log,而且因為臨時占用buffer pool引起短時間內性能受影響。
上面只是 Online DDL 內部的實現方式,此外還有 LOCK 選項控制是否鎖表,根據不同的DDL操作類型有不同的表現:默認mysql盡可能不去鎖表,但是像修改主鍵這樣的昂貴操作不得不選擇鎖表。
LOCK=NONE
,即DDL期間允許並發讀寫涉及的表,比如為了保證 ALTER TABLE 時不影響用戶注冊或支付,可以明確指定,好處是如果不幸該 alter語句不支持對該表的繼續寫入,則會提示失敗,而不會直接發到庫上執行。ALGORITHM=COPY
默認LOCK級別
LOCK=SHARED
,即DDL期間表上的寫操作會被阻塞,但不影響讀取。
LOCK=DEFAULT
,讓mysql自己去判斷lock的模式,原則是mysql盡可能不去鎖表
LOCK=EXCLUSIVE
,即DDL期間該表不可用,堵塞任何讀寫請求。如果你想alter操作在最短的時間內完成,或者表短時間內不可用能接受,可以手動指定。
但是有一點需要說明,無論任何模式下,online ddl開始之前都需要一個短時間排它鎖(exclusive)來准備環境,所以alter命令發出后,會首先等待該表上的其它操作完成,在alter命令之后的請求會出現等待waiting meta data lock
。同樣在ddl結束之前,也要等待alter期間所有的事務完成,也會堵塞一小段時間。所以盡量在ALTER TABLE之前確保沒有大事務在執行,否則一樣出現連環鎖表。
1.2 考慮不同的DDL操作類別
從上面的介紹可以看出,不是5.6支持在線ddl就可以隨心所欲的alter table,鎖不鎖表要看情況:
提示:下表根據官方 Summary of Online Status for DDL Operations 整理挑選的常用操作。
- In-Place為Yes是優選項,說明該操作支持INPLACE
- Copies Table為No是優選項,因為為Yes需要重建表。大部分情況與In-Place是相反的
- Allows Concurrent DML?為Yes是優選項,說明ddl期間表依然可讀寫,可以指定 LOCK=NONE(如果操作允許的話mysql自動就是NONE)
- Allows Concurrent Query?默認所有DDL操作期間都允許查詢請求,放在這只是便於參考
- Notes會對前面幾列Yes/No帶
*
號的限制說明
Operation |
In-Place? |
Copies Table? |
Allows Concurrent DML? |
Allows Concurrent Query? |
Notes |
添加索引 |
Yes* |
No* |
Yes |
Yes |
對全文索引的一些限制 |
刪除索引 |
Yes |
No |
Yes |
Yes |
僅修改表的元數據 |
OPTIMIZE TABLE |
Yes |
Yes |
Yes |
Yes |
從 5.6.17開始使用ALGORITHM=INPLACE,當然如果指定了old_alter_table=1 或mysqld啟動帶--skip-new 則將還是COPY模式。如果表上有全文索引只支持COPY |
對一列設置默認值 |
Yes |
No |
Yes |
Yes |
僅修改表的元數據 |
對一列修改auto-increment 的值 |
Yes |
No |
Yes |
Yes |
僅修改表的元數據 |
添加 foreign key constraint |
Yes* |
No* |
Yes |
Yes |
為了避免拷貝表,在約束創建時會禁用foreign_key_checks |
刪除 foreign key constraint |
Yes |
No |
Yes |
Yes |
foreign_key_checks 不影響 |
改變列名 |
Yes* |
No* |
Yes* |
Yes |
為了允許DML並發, 如果保持相同數據類型,僅改變列名 |
添加列 |
Yes* |
Yes* |
Yes* |
Yes |
盡管允許 ALGORITHM=INPLACE ,但數據大幅重組,所以它仍然是一項昂貴的操作。當添加列是auto-increment,不允許DML並發 |
刪除列 |
Yes |
Yes* |
Yes |
Yes |
盡管允許 ALGORITHM=INPLACE ,但數據大幅重組,所以它仍然是一項昂貴的操作 |
修改列數據類型 |
No |
Yes* |
No |
Yes |
修改類型或添加長度,都會拷貝表,而且不允許更新操作 |
更改列順序 |
Yes |
Yes |
Yes |
Yes |
盡管允許 ALGORITHM=INPLACE ,但數據大幅重組,所以它仍然是一項昂貴的操作 |
修改ROW_FORMAT 和KEY_BLOCK_SIZE |
Yes |
Yes |
Yes |
Yes |
盡管允許 ALGORITHM=INPLACE ,但數據大幅重組,所以它仍然是一項昂貴的操作 |
設置列屬性NULL 或NOT NULL |
Yes |
Yes |
Yes |
Yes |
盡管允許 ALGORITHM=INPLACE ,但數據大幅重組,所以它仍然是一項昂貴的操作 |
添加主鍵 |
Yes* |
Yes |
Yes |
Yes |
盡管允許 ALGORITHM=INPLACE ,但數據大幅重組,所以它仍然是一項昂貴的操作。 如果列定義必須轉化NOT NULL,則不允許INPLACE |
刪除並添加主鍵 |
Yes |
Yes |
Yes |
Yes |
在同一個 ALTER TABLE 語句刪除就主鍵、添加新主鍵時,才允許inplace;數據大幅重組,所以它仍然是一項昂貴的操作。 |
刪除主鍵 |
No |
Yes |
No |
Yes |
不允許並發DML,要拷貝表,而且如果沒有在同一 ATLER TABLE 語句里同時添加主鍵則會收到限制 |
變更表字符集 |
No |
Yes |
No |
Yes |
如果新的字符集編碼不同,重建表 |
從表看出,In-Place為No,DML一定是No,說明ALGORITHM=COPY
一定會發生拷貝表,只讀。但ALGORITHM=INPLACEE
也要可能發生拷貝表,但可以並發DML:
- 添加、刪除列,改變列順序
- 添加或刪除主鍵
- 改變行格式ROW_FORMAT和壓縮塊大小KEY_BLOCK_SIZE
- 改變列NULL或NOT NULL
- 優化表OPTIMIZE TABLE
- 強制 rebuild 該表
不允許並發DML的情況有:修改列數據類型、刪除主鍵、變更表字符集,即這些類型操作ddl是不能online的。
另外,更改主鍵索引與普通索引處理方式是不一樣的,主鍵即聚集索引,體現了表數據在物理磁盤上的排列,包含了數據行本身,需要拷貝表;而普通索引通過包含主鍵列來定位數據,所以普通索引的創建只需要一次掃描主鍵即可,而且是在已有數據的表上建立二級索引,更緊湊,將來查詢效率更高。
修改主鍵也就意味着要重建所有的普通索引。刪除二級索引更簡單,修改InnoDB系統表信息和數據字典,標記該所以不存在,標記所占用的表空間可以被新索引或數據行重新利用。
1.3 在線DDL的限制
- 在alter table時,如果涉及到table copy操作,要確保
datadir
目錄有足夠的磁盤空間,能夠放的下整張表,因為拷貝表的的操作是直接在數據目錄下進行的。
- 添加索引無需table copy,但要確保
tmpdir
目錄足夠存下索引一列的數據(如果是組合索引,當前臨時排序文件一合並到原表上就會刪除)
- 在主從環境下,主庫執行alter命令在完成之前是不會進入binlog記錄事件,如果允許dml操作則不影響記錄時間,所以期間不會導致延遲。然而,由於從庫是單個SQL Thread按順序應用relay log,輪到ALTER語句時直到執行完才能下一條,所以從庫會在master ddl完成后開始產生延遲。(pt-osc可以控制延遲時間,所以這種場景下它更合適)
- During each online DDL ALTER TABLE statement, regardless of the LOCK clause, there are brief periods at the beginning and end requiring an exclusive lock on the table (the same kind of lock specified by the LOCK=EXCLUSIVE clause). Thus, an online DDL operation might wait before starting if there is a long-running transaction performing inserts, updates, deletes, or SELECT … FOR UPDATE on that table; and an online DDL operation might wait before finishing if a similar long-running transaction was started while the ALTER TABLE was in progress.
- 在執行一個允許並發DML在線 ALTER TABLE時,結束之前這個線程會應用 online log 記錄的增量修改,而這些修改是其它thread里產生的,所以有可能會遇到重復鍵值錯誤(ERROR 1062 (23000): Duplicate entry)。
- 涉及到table copy時,目前還沒有機制限制暫停ddl,或者限制IO閥值
在MySQL 5.7.6開始能夠通過 performance_schema 觀察alter table的進度
- 一般來說,建議把多個alter語句合並在一起進行,避免多次table rebuild帶來的消耗。但是也要注意分組,比如需要copy table和只需inplace就能完成的,應該分兩個alter語句。
- 如果DDL執行時間很長,期間又產生了大量的dml操作,以至於超過了
innodb_online_alter_log_max_size
變量所指定的大小,會引起DB_ONLINE_LOG_TOO_BIG 錯誤。默認為 128M,特別對於需要拷貝大表的alter操作,考慮臨時加大該值,以此獲得更大的日志緩存空間
- 執行完
ALTER TABLE
之后,最好 ANALYZE TABLE tb1
去更新索引統計信息
2. 實現過程
online ddl主要包括3個階段,prepare階段,ddl執行階段,commit階段,rebuild方式比no-rebuild方式實質多了一個ddl執行階段,prepare階段和commit階段類似。下面將主要介紹ddl執行過程中三個階段的流程。
-
Prepare階段:
- 創建新的臨時frm文件(與InnoDB無關)
- 持有EXCLUSIVE-MDL鎖,禁止讀寫
- 根據alter類型,確定執行方式(copy,online-rebuild,online-norebuild)
假如是Add Index,則選擇online-norebuild即INPLACE方式
- 更新數據字典的內存對象
- 分配row_log對象記錄增量(僅rebuild類型需要)
- 生成新的臨時ibd文件(僅rebuild類型需要)
-
ddl執行階段:
- 降級EXCLUSIVE-MDL鎖,允許讀寫
- 掃描old_table的聚集索引每一條記錄rec
- 遍歷新表的聚集索引和二級索引,逐一處理
- 根據rec構造對應的索引項
- 將構造索引項插入sort_buffer塊排序
- 將sort_buffer塊更新到新的索引上
- 記錄ddl執行過程中產生的增量(僅rebuild類型需要)
- 重放row_log中的操作到新索引上(no-rebuild數據是在原表上更新的)
- 重放row_log間產生dml操作append到row_log最后一個Block
-
commit階段:
- 當前Block為row_log最后一個時,禁止讀寫,升級到EXCLUSIVE-MDL鎖
- 重做row_log中最后一部分增量
- 更新innodb的數據字典表
- 提交事務(刷事務的redo日志)
- 修改統計信息
- rename臨時idb文件,frm文件
- 變更完成
這有一直導圖挺直觀的:http://blog.itpub.net/22664653/viewspace-2056953 。
添加列 時由於需要copy table,row_log會重放到新表上(臨時ibd文件),直到最后一個block,鎖住原表禁止更新。
row_log記錄了ddl變更過程中新產生的dml操作,並在ddl執行的最后將其應用到新的表中,保證數據完整性
3. 對比實驗
3.1 添加二級索引
我這里使用sysbench產生的表測試(500w數據):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
mysql> select version(); +------------+ | version() | +------------+ | 5.6.30-log | +------------+ 1 row in set (0.00 sec)
mysql> show create table sbtest1; CREATE TABLE `sbtest1` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `k` int(10) unsigned NOT NULL DEFAULT '0', `c` char(120) COLLATE utf8_bin NOT NULL DEFAULT '', `pad` char(60) COLLATE utf8_bin NOT NULL DEFAULT '', PRIMARY KEY (`id`), KEY `k_1` (`k`) ) ENGINE=InnoDB AUTO_INCREMENT=5000001 DEFAULT CHARSET=utf8 COLLATE=utf8_bin MAX_ROWS=1000000
mysql> show variables like "old_alter_table"; +-----------------+-------+ | Variable_name | Value | +-----------------+-------+ | old_alter_table | OFF | +-----------------+-------+ 1 row in set (0.00 sec)
|
舊模式下,創建刪除普通索引:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
|
**SESSION1:** mysql> set old_alter_table=1; Query OK, 0 rows affected (0.00 sec)
mysql> alter table sbtest1 drop index idx_k_1; Query OK, 5000000 rows affected (44.79 sec) Records: 5000000 Duplicates: 0 Warnings: 0
mysql> alter table sbtest1 add index idx_k_1(k); Query OK, 5000000 rows affected (1 min 11.29 sec) Records: 5000000 Duplicates: 0 Warnings: 0
**SESSION2:** mysql> select * from sbtest1 limit 1; +----+---------+-------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------+ | id | k | c | pad | +----+---------+-------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------+ | 1 | 2481886 | 08566691963-88624...106334-50535565977 | 63188288836-9235114...351-49282961843 | +----+---------+-------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------+ 1 row in set (0.00 sec)
mysql> update sbtest1 set k=2481885 where id=1; Query OK, 1 row affected (45.16 sec) Rows matched: 1 Changed: 1 Warnings: 0
**SESSION3:** mysql> show processlist; +--------+-----------------+-----------+------------+---------+--------+---------------------------------+-----------------------------------------+ | Id | User | Host | db | Command | Time | State | Info | +--------+-----------------+-----------+------------+---------+--------+---------------------------------+-----------------------------------------+ | 118652 | root | localhost | confluence | Query | 19 | copy to tmp table | alter table sbtest1 add index k_1(k) | | 118666 | root | localhost | confluence | Query | 3 | Waiting for table metadata lock | update sbtest1 set k=2481885 where id=1 | | 118847 | root | localhost | NULL | Query | 0 | init | show processlist | +--------+-----------------+-----------+------------+---------+--------+---------------------------------+-----------------------------------------+ 4 rows in set (0.00 sec)
同時在datadir目錄下可以看到 -rw-rw---- 1 mysql mysql 8.5K May 23 21:24 sbtest1.frm -rw-rw---- 1 mysql mysql 1.2G May 23 21:24 sbtest1.ibd -rw-rw---- 1 mysql mysql 8.5K May 23 20:48 |
傳統ddl方式有 copy to tmp table 過程,dml更新操作期間被堵住45s:Waiting for table metadata lock
。
下面改成Online DDL方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
**SESSION1** mysql> set old_alter_table=0;
mysql> alter table sbtest1 drop index k_1; Query OK, 0 rows affected (0.01 sec) Records: 0 Duplicates: 0 Warnings: 0 索引秒刪
mysql> alter table sbtest1 add index k_1(k); Query OK, 0 rows affected (13.99 sec) Records: 0 Duplicates: 0 Warnings: 0
**SESSION2** mysql> update sbtest1 set k=2481887 where id=1; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0
**SESSION3** mysql> show processlist; +--------+-----------------+-----------+------------+---------+--------+------------------------+--------------------------------------+ | Id | User | Host | db | Command | Time | State | Info | +--------+-----------------+-----------+------------+---------+--------+------------------------+--------------------------------------+ | 118652 | root | localhost | confluence | Query | 10 | altering table | alter table sbtest1 add index k_1(k) | | 118666 | root | localhost | confluence | Sleep | 9 | | NULL | | 118847 | root | localhost | NULL | Query | 0 | init | show processlist | +--------+-----------------+-----------+------------+---------+--------+------------------------+--------------------------------------+ 4 rows in set (0.00 sec)
|
添加普通索引,並未出現阻塞update操作,而且速度更快。從 rows affected 可以看出有沒有copy table。
但如果在alter之前有大事務在執行,會阻塞ddl以及后續的所有請求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
**SESSION1** mysql> select * from sbtest1 where c='long select before alter'; Empty set (4.36 sec)
**SESSION2** mysql> alter table sbtest1 add index k_1(k); Query OK, 0 rows affected (16.28 sec) Records: 0 Duplicates: 0 Warnings: 0
**SESSION3** mysql> select * from sbtest1 where c='long select after alter execution but not complete'; Empty set (5.89 sec)
**SESSION4** mysql> show processlist; +----+-----------------+-----------+------------+---------+------+---------------------------------+------------------------------------------------------------------------------------+ | Id | User | Host | db | Command | Time | State | Info | +----+-----------------+-----------+------------+---------+------+---------------------------------+------------------------------------------------------------------------------------+ | 5 | root | localhost | confluence | Query | 3 | Sending data | select * from sbtest1 where c='long select before alter' | | 7 | root | localhost | NULL | Query | 0 | init | show processlist | | 13 | root | localhost | confluence | Query | 2 | Waiting for table metadata lock | alter table sbtest1 add index k_1(k) | | 14 | root | localhost | confluence | Query | 1 | Waiting for table metadata lock | select * from sbtest1 where c='long select after alter execution but not complete' | +----+-----------------+-----------+------------+---------+------+---------------------------------+------------------------------------------------------------------------------------+ 5 rows in set (0.00 sec)
|
3.2 添加列示例
添加新列是ddl操作里面相對較多的一類操作。從上文表中可以看到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
**SESSION1** mysql> ALTER TABLE `sbtest2` \ ADD COLUMN `f_new_col1` int(11) NULL DEFAULT 0, \ ADD COLUMN `f_new_col2` varchar(32) NULL DEFAULT '' AFTER `f_new_col1`; Query OK, 0 rows affected (1 min 57.86 sec) Records: 0 Duplicates: 0 Warnings: 0
**SESSION2** mysql> update sbtest2 set c="update when add colomun ddl start" where c='33333'; Query OK, 0 rows affected (4.41 sec) Rows matched: 0 Changed: 0 Warnings: 0
**SESSION3** mysql> select * from sbtest2 where c='select when add colomun ddl start'; Empty set (3.44 sec)
**SESSION4** mysql> show processlist; +-----+-----------------+-----------+------------+---------+------+---------------------------+------------------------------------------------------------------------------------------------------+ | Id | User | Host | db | Command | Time | State | Info | +-----+-----------------+-----------+------------+---------+------+---------------------------+------------------------------------------------------------------------------------------------------+ | 5 | root | localhost | confluence | Query | 4 | altering table | ALTER TABLE `sbtest2` ADD COLUMN `f_new_col1` int(11) NULL DEFAULT 0, ADD COLUMN `f_new_col2` varch | | 7 | root | localhost | NULL | Query | 0 | init | show processlist | | 161 | root | localhost | confluence | Query | 2 | Searching rows for update | update sbtest2 set c="update when add colomun ddl start" where c='33333' | | 187 | root | localhost | confluence | Query | 1 | Sending data | select * from sbtest2 where c='select when add colomun ddl start' | +-----+-----------------+-----------+------------+---------+------+---------------------------+------------------------------------------------------------------------------------------------------+ 5 rows in set (0.00 sec)
|
看到,默認不加 ALGORITHM=INPLACE 就已經允許ddl期間並發DML操作。但是會有一個小臨時文件產生:
1 2
|
-rw-rw---- 1 mysql mysql 8.6K May 23 21:42 #sql-7055_5.frm -rw-rw---- 1 mysql mysql 112K May 23 21:42 #sql-ib21-16847116.ibd
|
當指定copy時,就會鎖表了(一般你不想這樣做):
1 2
|
ALTER TABLE `sbtest2` 4DROIP COLUMN `f_new_col1`, algorithm=copy;
|
3.3 修改字段類型
修改列類型與添加新列不一樣,修改類型需要rebuild整個表:
(select ok, update waiting)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
**SESSION1** mysql> ALTER TABLE sbtest2 4 CHANGE f_new_col2 f_new_col2 varchar(50) NULL DEFAULT '', algorithm=inplace ; ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY. 不支持INPLACE
mysql> ALTER TABLE sbtest2 4 CHANGE f_new_col2 f_new_col2 varchar(50) NULL DEFAULT '';
**SESSION2** mysql> update sbtest2 set c="update when add colomun ddl start" where c='33333';
mysql> select * from sbtest2 where c='select when add colomun ddl start'; Empty set (3.79 sec)
mysql> show processlist; +-----+-----------------+-----------+------------+---------+------+---------------------------------+----------------------------------------------------------------------------------+ | Id | User | Host | db | Command | Time | State | Info | +-----+-----------------+-----------+------------+---------+------+---------------------------------+----------------------------------------------------------------------------------+ | 5 | root | localhost | confluence | Query | 5 | copy to tmp table | ALTER TABLE sbtest2 CHANGE f_new_col2 f_new_col2 varchar(50) NULL DEFAULT '' | | 7 | root | localhost | NULL | Query | 0 | init | show processlist | | 161 | root | localhost | confluence | Query | 4 | Waiting for table metadata lock | update sbtest2 set c="update when add colomun ddl start" where c='33333' | | 187 | root | localhost | confluence | Query | 3 | Sending data | select * from sbtest2 where c='select when add colomun ddl start' | +-----+-----------------+-----------+------------+---------+------+---------------------------------+----------------------------------------------------------------------------------+ 5 rows in set (0.00 sec)
|
Online DDL看起來很美好,實驗測試也正如預期,但幾次在生產環境修改索引時(5000w的表),還是無法避免出現大量 *Waiting for table metadata lock* 鎖等待,線程數持續增加並告警,導致長達十多分鍾不可寫。后來發現原來是版本升級的問題導致的,見這里。關於metadata lock介紹參考[這篇文章]。
4. 參考