項目中遇到一個ID生成策略的需求:需要在系統中為每個用戶分配一個ID用作以后的用戶標示。這個需求應該是非常普遍的,對於使用人數較少的系統而言不會是一個問題,不過對於向用戶眾多的互聯網系統來講這不是一個簡單的問題。下面是翻譯的最近最火爆的Instagram應用開發者的一篇文章,看看他們一個十幾個人的公司是怎么解決這個問題的:
以下為簡單翻譯(不清楚的地方請參照原文):
Instagram 的分片和IDs
每秒接收25副圖片、90次"like"分享,Instagram存儲了大量的數據。為了確保所有重要的數據都存入到了內存並且盡快地對於用戶可用,我們將數據進行了分片---換句話說就是將數據存到很多小分片上,每個分片都持有數據的一部分。
我們使用Django 和PostgreSQL 作為后台的數據庫系統。在決定對數據進行分片后我們遇到的第一個問題就是是否仍舊將PostgreSQL作為我們主要的數據存儲系統,還是換個其他的。我們評估了一些不同的NoSQL解決方案,但最終決定:最符合我們需求的是將數據分片到由多個PostgreSQL組成的服務器組上。
在將數據寫入到PostgreSQL服務器組之前,我們必須先解決如何為數據庫中每一份數據指定相應的唯一標示(例如每一副發布在我們系統上的圖片)。典型的解決方案在單個數據庫中還行得通---直接使用數據庫的自增來分配唯一標示;但要將數據同時插入到多個數據庫時這種方案就不行了。這篇文章接下來的內容就指明了我們是如何對付這個問題的。
在開始之前我們列出了幾個系統中必須的幾個功能:
1.生成的ID必須可以按時間排序(這樣一來,一組圖片可以不用再查找其他相關信息就能排序)
2.ID最好是64bit的(為了索引更小且方便存儲在像Redis這樣的系統中)
3.新系統造成的不確定性(or改動)越小越好---我們之所以能用這么少的工程師搞定Instagram,很大的原因就在於選擇簡單、易懂、可靠的解決方案。
現存的解決方案:
對於ID生成問題現在已經有很多現存的方案,以下為幾個我們可以考慮的:
在web應用(web application)中生成ID
這種方式將ID生成的問題全部交給你的應用程序,而不是數據庫來解決。
例如:MongoDB 的ObjectID 一共有12bytes長,並且將編碼后的時間戳作為首要的組件。另一個常用的就是UUID。
優點:
1.每個應用程序線程都獨立地生成ID,減少了故障和不一致。
2.如果你使用時間戳作為ID的首要組件,ID就是可以按時間排序的。
缺點:
1.為了保證唯一性的可靠,一般需要更多的存儲空間(96bit或更多)。
2.有些UUID完全是隨機的,不能自然排序。
使用專門的服務生成ID
例如:Twitter的 Snowflake---一個使用Apache ZooKeeper來整合所有節點然后生成64bit唯一ID的簡潔的服務。
優點:
1.Snowflake生成的ID只有64位,只有UUID的一半大小。
2.可以使用時間戳作為首要組件,可排序。
3.分布式的系統,某個節點down掉也沒事。
缺點:
1.會給整體架構引入額外的復雜性,和一些不確定內容(ZooKeeper, Snowflake servers)
數據庫"票務"服務器
使用數據庫的自增功能來保證唯一性。Flickr 使用了這種方法---但使用了兩台數據庫服務器(一台生成奇數,一台生成偶數)來防止單點當機。
優點:
1.數據庫非常熟悉,有很多可預見的因素。
缺點:
1.會最終成為一個寫瓶頸(根據Flickr的報告,即使在大規模並發的情況下這也不會成為一個問題)
2.對於管理員而言額外多了一對機器。
3.如果使用單個數據庫則容易出現單點故障,使用多個則無法保證可以按時間排序。
以上幾種方法中Twitter的離我們想要的最為接近,但是運行一個專門的ID服務所造成的額外復雜性卻是一個負面因素。
替代地,我們選擇了一個概念上與之相似的方法,並將它整合到了PostgreSQL中。
我們的解決方案
我們的分片系統由上千個邏輯分片組成,而這些邏輯分片在代碼中與非常少的物理分片進行了映射。使用這種方法我們可以從很少的數據庫服務器開始,最終轉到更多的服務器:只需要將一些邏輯片從一台服務器移到另一台,中間不需要重新打包任何數據。為了易於編碼和管理我們使用Postgres的schema功能來實現。
在我們的系統中每個邏輯分片都是一個schema,每個被分片的表都存在於每個schema中。我們使用PL/PGSQL(Postgres內置的編程語言)和Postgers自身的自增函數,為每個分片中的每張表都賦予了生成ID功能。
每個ID由以下部分組成:
1.41bits 存儲毫秒格式的時間。
2.13bits 表示邏輯分片ID。
3.10bits 存儲自增序列值對1024取模后的結果,這意味着每個分片每秒可以產生1024個ID。
下面進行一個測試:假設現在是2011-09-09 下午05:00,我們的業務從2011-01-01開始運行。從開始到現在已經運行了1387263000毫秒,開始構造我們的ID,我們使用左移位的方式填充左面的41位:
id = 1387263000 << (64-41)
接着,我們獲取即將插入的這份數據所在的分片ID,假設我們按用戶ID來進行分片,系統中一共有2000個邏輯分片;如果我們的用戶ID是31341,那么我們的分片ID就是31341 % 2000 -> 1341
。我們使用這個值來填充接下來的13位:
id |= 1341 << (64-41-13)
最后我們使用任意生成的自增序列值(單個schema單張表內唯一)來填充其余的位數。假設我們要插入的那張表已經生成了5000個ID了,下一個值就是5001,我們對其使用1024取模(這樣生成的數據就是10bits了),然后加上去:
id |= (5001 % 1024)
現在我們已經獲取到了想要的ID,接下來可以使用return關鍵字作為insert過程的一部分返回給應用程序了。
下面是以上整個過程的PL/PGSQL代碼實現(例如:在schema實例5中):
DECLARE
our_epoch bigint : = 1314220021721;
seq_id bigint;
now_millis bigint;
shard_id int : = 5;
BEGIN
SELECT nextval( ' insta5.table_id_seq ') %% 1024 INTO seq_id;
SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) INTO now_millis;
result : = (now_millis - our_epoch) << 23;
result : = result | (shard_id << 10);
result : = result | (seq_id);
END;
$$ LANGUAGE PLPGSQL;
下面是創建數據庫表:
"id" bigint NOT NULL DEFAULT insta5.next_id(),
...rest of table schema...
)
就這樣了!主鍵在我們的應用里面是唯一的(作為捎帶的私貨,為了易於映射含有分片ID)。我們已經將這個方法應用到了產品中,目前看來效果挺令人滿意。有興趣幫助我們解決這些問題?我們的聯系方式
Mike Krieger, co-founder