URL: http://code.openark.org/blog/mysql/sql-finding-a-users-countryregion-based-on-ip
我先後兩次遇到過相同的問題,所以認爲有必要討論一下。
在web應用中,通過偵測HTTP請求,基於IP找到用戶所在的國家或地區是一項基本的功能。而且依據獲得的信息,website可以進行相應的I10N的工作。
要開始這項工作,需要有一個對照表列出IP段對應的國家或地區。這裏假設我們僅處理IPv4的情況:
CREATE TABLE regions_ip_range (
regions_ip_range_id INT UNSIGNED AUTO_INCREMENT,
country VARCHAR(64) CHARSET utf8,
region VARCHAR(64) CHARSET utf8,
start_ip INT UNSIGNED,
end_ip INT UNSIGNED,
…
PRIMARY KEY(regions_ip_range_id),
...
);
這個表被填充並用來做對照表。現在的問題是:我們如何查詢這張表,並如何創建index。
錯誤的方式
我遇到的方式是:
KEY ip_range_idx (start_ip, end_ip)
然後查詢如下:
SELECT * FROM `regions_ip_range` WHERE `my_ip` BETWEEN `start_ip` AND `end_ip`
這種對索引的理解是錯誤的。我並不是說結果是錯誤的,只是這個查詢的效率會很差。重寫這段查詢就會明白。下面這段查詢和之前的是等價的:
SELECT * FROM `regions_ip_range` WHERE `my_ip` >= `start_ip` AND `my_ip` <= `end_ip`
發現問題了麼?
在第一個索引列上有一個範圍條件(range condition),自動取消了第二列上的索引使用。反轉查詢中的順序也一樣。
實際上,如果我們這和我們僅僅在start_ip上定義索引的效果是一樣的。
KEY ip_range_idx (start_ip)
可是,這是不允許的。很容易想到(事實上也確實是這樣),針對大量的IP 地址,MySQL會執行一次全表查詢而非使用索引。
另外一個錯誤的方式是:
KEY start_ip_idx (start_ip)
KEY end_ip_idx (end_ip)
即爲每個地址創建了一個單獨的索引。但我們並不會這麼做,因爲即使我們覺得MySQL一定會針對我們的查詢使用兩個索引,然後做一個索引合併(index_merge),也得不到一個高效的查詢。試想:對於一個給定的IP,不可能選擇兩個不同的索引。要麼這個IP趨近與區域的下界('my_ip >= start_ip'部分不會被選用),要麼趨近與上界('my_ip <= end_ip'不會被選用),或者在中間,那樣都不選用了。
事實上,很難想象MySQL會選擇執行索引合併,所以最多使用一個索引,不然就全表查詢。
解決方案
解決這個問題中重要的一步是認識到IP區域是相互排斥的。沒有一個IP能存在於兩個區域中,只能是一個(至少,這裏是這樣的。如果你面對一個分層的區域,就要自己做決定了...)。這就意味着我不需要對兩個列都做索引。一個就足夠了。
KEY start_ip_idx (start_ip)
然後我們改寫SQL 如下:
SELECT * FROM `regions_ip_range`
WHERE `start_ip` <= `my_ip`
ORDER BY `start_ip` DESC LIMIT 1
現在,我們要找的是就是符合“我們的IP大於區域起始IP”這個條件的第一個結果。優化器就是要使用索引找到第一個,然後無需再繼續了,於是加上LIMIT 1。
如果覺得迷惑,可以做相反的操作。定義如下:
KEY end_ip_idx (end_ip)
然後修改查詢:
SELECT * FROM regions_ip_range
WHERE my_ip <= end_ip
ORDER BY end_ip ASC LIMIT 1
有趣的是,由於沒有考慮LIMIT 1,EXPLAIN仍然聲明將要檢查很多行記錄。
我曾經寫過關於“存儲引擎之間對於優化器使用(或不使用)索引的不同”的文章。所以,最終可能要使用FORCE_INDEX。
假設
這裏,我有幾個假設:
1. 表列出的IP段是從0.0.0.0到255.255.255.255。
2. 對於給定的一個IP,其所屬的IP段都有明確的上下界。
3. 各IP段相互排斥(不存在分層的IP段)
如果前兩個假設不滿足,則查詢返回時,應該檢查my_ip是否確實在start_ip和end_ip之間。
如果第三個假設不滿足,可以將數據分拆到兩個表中:一個包含互斥的數據。另一個包含其他部分數據,也許可以利用用一些分層算法(嵌套集合等)。
備註
1. 評論中有人提到了不該用數據庫做這樣的功能,相應的應該使用libgeoip。
2. 還有一篇類似的博文:http://jcole.us/blog/archives/2007/11/24/on-efficiently-geo-referencing-ips-with-maxmind-geoip-and-mysql-gis/