上一篇提過,那次 ANR 追到最後是三個問題疊在一起:迴圈裡查資料庫、缺索引、資料表設計本身有問題。這篇把「缺索引」這一項拉出來講,包括我是怎麼發現的,以及索引這東西到底在解決什麼問題。
我是在做開單系統的時候發現的
車牌身分查詢,資料量一大就開始卡
當時在做一個開單系統,裡面有一段是查車牌對應的身分資料。
功能本身沒有很複雜,但隨著資料越堆越多,這段查詢開始明顯變慢,慢到會卡住主執行緒、觸發 ANR。
這個專案的本地資料庫是用 GreenDAO 管的。我是從這段查詢往回查,才發現除了主鍵之外,整張資料表沒有任何一個欄位加過索引。
不是這張表特別忘記加,是這個專案的資料表設計階段,索引這件事根本沒有被考慮進去。
用了 ORM 之後,@Entity 一標、欄位一寫、表就生出來了,索引不是自動附贈的,你不特別標記,它就是不存在。
沒有索引,資料庫在做的事
SQL 語法寫對,不代表資料庫找得快
沒有索引的查詢,資料庫的做法是把整張表從第一筆掃到最後一筆,逐筆比對符不符合條件。
表裡有一百筆,最壞情況就是比對一百次;有一百萬筆,就是一百萬次。
這解釋了為什麼一開始完全沒感覺。測試階段資料量小,全表掃描跟有索引的查詢幾乎感覺不出差異。等資料真的長到「非常大」,這個差異才會被放大到使用者感覺得到。
而放大到最後,就是主執行緒卡住、系統跳出 ANR 對話框。
索引做的事,簡單說是幫資料庫先建好一份「排序過的索引」,查詢時不用整張表掃過去,而是直接用類似二分搜尋的方式定位到資料。
差別是從「掃過去」變成「跳過去找」。
GreenDAO 底層還是 SQLite,所以要看一段查詢到底有沒有吃到索引,一樣可以用 EXPLAIN QUERY PLAN 檢查,只是要先從 DaoSession 把底層的資料庫物件拿出來,才能下原生 SQL:
// DaoSession 底層包的還是 SQLiteDatabase,拿出來就能下原生 SQL
Cursor cursor = daoSession.getDatabase()
.rawQuery("EXPLAIN QUERY PLAN SELECT * FROM PLATE WHERE PLATE_NUMBER = ?",
new String[]{"ABC-1234"});如果結果顯示 SCAN PLATE,代表是全表掃描;顯示 SEARCH PLATE USING INDEX,才代表真的用到索引在找。
在 GreenDAO 裡加索引,不是手寫 CREATE INDEX,是在 Entity 的欄位上標註解,重新產生程式碼即可:
@Entity
public class Plate {
@Id
private Long id;
@Index
private String plateNumber;
}要加唯一約束的話是 @Index(unique = true)。這一步的成本低到近乎沒有藉口。多一個註解、重新 build 一次,遠比想像中麻煩要小。
這件事回頭看,其實是設計階段就該問的問題
「這張表以後查詢最常用哪個欄位當條件?」
索引不是查詢慢了才補救的東西,是設計資料表的當下就該想的問題。這張表以後最常用哪個欄位去查?那個欄位就該加索引。
我們當時完全沒有問過這個問題,資料表是照著「有哪些欄位」去設計的,不是照著「以後會怎麼查」去設計的。
這跟前端的效能問題不太一樣。前端效能不好,通常是畫面明顯卡、掉幀,有感覺。
資料庫缺索引,在資料量小的時候完全沒有任何徵兆,等你發現的時候,通常已經是資料量長到某個門檻、使用者已經在抱怨的時候了。
如果你也在用 ORM 管本地資料庫,這件事更容易被忽略,因為連 CREATE TABLE 都是自動生成的,索引這種「不寫就不會出現」的東西,很容易被誤會成 ORM 已經幫你顧好了。
如果你也在做會長期累積資料的系統,這是一個值得在寫下第一個 @Entity 之前先問自己的問題。
