本文闡述了大型系統(tǒng)由Mongodb切換至PostgreSQL的工程實踐及經(jīng)驗教訓。
背景
系統(tǒng)早期選型時莫秆,出于分布式崔拥,擴縮容幸缕、等方面考慮采用了MongoDB群发,但隨著系統(tǒng)的演進及對可靠性、事務等的訴求发乔,關系型數(shù)據(jù)庫似乎是一個更好的選擇熟妓。
在IT系統(tǒng)中,數(shù)據(jù)存儲是整個系統(tǒng)的核心栏尚,數(shù)據(jù)庫選型起愈、切換甚至版本升級都會對整個系統(tǒng)產(chǎn)生重大影響。
本次切換中抵栈,我們充分比較了PostgreSQL、Oracle坤次、MySql等關系型數(shù)據(jù)庫古劲,最終我們決定采用PostgreSQL,并在其之上完成簡易的dbproxy功能缰猴,同時保持底層存儲的分布式特性和可擴展性产艾。
選擇PostgreSQL主要有以下幾個原因:
- 事務。除本地事務外,還支持兩階段事務闷堡,滿足分布式一致性的訴求隘膘。
- 性能。在典型場景下杠览,PostgreSQL的性能極為出色弯菊,在讀取上性能也要優(yōu)于Mongodb。
- JSON類型支持踱阿。MongoDB的數(shù)據(jù)存儲格式為JSON管钳,原有系統(tǒng)中存在諸多使用MongoDB接口訪問數(shù)據(jù)庫的場景,選擇一款支持JSON的數(shù)據(jù)庫更容易完成數(shù)據(jù)庫遷移软舌。
目標
項目達成以下目標:
- 由MongoDB到PostgreSQL的平滑遷移才漆。
平滑遷移主要是指對外保留現(xiàn)有的MongoDB接口,內部數(shù)據(jù)存儲由MongoDB切換至PostgreSQL數(shù)據(jù)庫佛点。對于遺留系統(tǒng)或具有一定規(guī)模的(分布式)系統(tǒng)醇滥,數(shù)據(jù)訪問層作為公共數(shù)據(jù)接口被大量的業(yè)務使用。如果由于數(shù)據(jù)切換廢棄原有的抽象接口超营,而全部采用全新接口鸳玩,后果將是災難性的。 - 提供新的糟描、支持事務怀喉、分布式事務或其它關系型數(shù)據(jù)庫具備標準能力的數(shù)據(jù)訪問層。
Hibernate作為DAO層船响,擴展并豐富現(xiàn)有的Annotation躬拢。 - 支持分片、主備部署见间、可靠性增強等聊闯。
技術分析
接口定義
拋開實現(xiàn)細節(jié),來看典型Mongodb的DAO層接口定義:
class UnifiedDAO
{
//通用接口
public boolean insert(T pojo)
//通用接口
public boolean save(T pojo)
//通用接口
public boolean update(T pojo)
//通用接口??
public List find(String condition, ...)
}
原有的增米诉、刪菱蔬、改接口足夠通用,即便我們基于RDBMS數(shù)據(jù)庫重新設計或者直接采用Hibernate也大體如此史侣。find接口這里做了簡化拴泌,僅傳入一個condition及多個變長參數(shù),來看典型用法:
UnifiedDAO personDao = new UnifiedDAO();
//查找名字為Joe惊橱,年齡為18歲的person
personDao.find("{name:#, age:#}", "Joe", 18);
//找出所有名字中包含jo的條目
personDao.find("{name: {$regex: #}}", "jo.*")
//age < 100
personDao.find({age:{$lt:100}})
// age < 100 and age > 20
personDao.find({age:{$lt:100,$gt:20}})
// age != 11
personDao.find({age:{$ne:11}})
// age in [11, 22]
personDao.find({age:{$in:[11,22]}})
// age = 11 or age = 22
personDao.find({$or:[{age:11},{age:22}]})
// age = 11 or name equal 'xttt'
personDao.find({$or:[{age:11},{name:'xttt'}]})
查詢接口暴露原生的Mongo語法蚪腐,業(yè)務根據(jù)需要自己組裝查詢條件,并返回查詢結果税朴。Mongo語法和SQL語法完全不同回季,我們需要:將Mongo的CQL轉換為postgres的SQL語句家制。
存儲差異
Mongodb為文檔型數(shù)據(jù)庫,數(shù)據(jù)以JSON形式存儲泡一,并沒有Schema概念颤殴。而PostgreSQL數(shù)據(jù)以表-行-列形式存儲。No-shema的數(shù)據(jù)內容是任意的鼻忠,而schema表是需要預先定義的涵但,將運行時任意插入的數(shù)據(jù)全部羅列出來并定義成列是不現(xiàn)實的。
這里需要用到前文提到的一個特性--PostgreSQL對JSON類型支持粥烁。Postgres中提供的類型叫jsonb贤笆,它是json的一個變種。關于jsonb類型可參見如下鏈接:
對于現(xiàn)有的Mongo數(shù)據(jù)讨阻,我們的想法是:將mongo的每一個collection定義成一張數(shù)據(jù)表芥永,并將Mongo中的json數(shù)據(jù)轉儲到PostgreSQL中。數(shù)據(jù)表定義如下:
create table collection_name
{
document jsonb;
}
//增加基于document->_id的索引字段钝吮,提升查詢效率埋涧。
但MongoDB和JSON相比額外支持了那么多的數(shù)據(jù)類型,在JSON中要怎么處理呢奇瘦?又怎么把實體對象轉換為PostgreSQL中的JSONB數(shù)據(jù)呢棘催?明確我們的下一個目標:正確處理MongoDB支持的各種數(shù)據(jù)類型,做到『無損轉換』耳标。在增醇坝、刪、改時次坡,將實體對象轉換為JSONB數(shù)據(jù)呼猪;在查找時,將JSONB數(shù)據(jù)轉換為實體對象砸琅。
工程實踐
從接口調用開始宋距,簡化處理如下流程:
- 增、刪症脂、改:實體對象序列化為JSONB數(shù)據(jù) -> 通過JDBC入庫
- 查找:Cql轉換為SQL ->通過JDBC訪問數(shù)據(jù)庫 -> JSONB數(shù)據(jù)反序列化為實體對象
詞法分析
工程中谚赎,我們采用JONGO作Mongo數(shù)據(jù)訪問,那么JONGO必然做了Cql的詞法分析诱篷。查看JONGO源碼壶唤,發(fā)現(xiàn)方法如下:
DBObject dbObject = bsonQueryFactory.createQuery(cql, parameters).toDBObject();
DBObject是樹狀結構,并已將所有的參數(shù)標記替換為實際參數(shù)數(shù)據(jù)棕所。后續(xù)闸盔,我們涉及的Cql討論將基于DBObject展開。
語法差異
在開始語法具體細節(jié)討論之前橙凳,先來看下相同操作在MongoDB蕾殴、PostgreSQL下的語法差異:
- 數(shù)據(jù)類型。Cql對所有數(shù)據(jù)類型處理方式上保持一致岛啸,并且支持任意形式的操作符組合钓觉。PostgreSQL按日期類型、數(shù)字類型坚踩、正則表達式荡灾、其它類型等語法上有所不同。
- 操作類型瞬铸。Cql對所有操作類型處理方式上保持一致批幌,并且支持任意形式的操作符組合。PostgreSQL在相等嗓节、大于荧缘、小于等操作處理語法基本一致,在包含不包含處理語法一致拦宣。
- 嵌套查詢截粗。Cql嵌套查詢和非嵌套查詢語法并無明顯區(qū)別。PostgreSQL對于數(shù)組鸵隧、非數(shù)組的嵌套查詢處理方式不同绸罗,且僅支持相等操作的嵌套查詢。
- 其它豆瘫。需要注意兩點:第一珊蟀、并非所有的Mongo操作都可以轉換成合理的SQL;第二外驱、本文并未窮盡所有的Mongo操作育灸。
Postgresql中JSONB支持的類型及操作符
上面是PostgreSQL官方手冊中截取的JSONB支持的類型及操作符描述。在操作不同類型或者操作時略步,postgres最常用的語法:
- -> :int描扯、boolean類型操作,數(shù)組類型操作
- ->> :string類型操作趟薄,數(shù)組類型操作
- #> :復合路徑下绽诚,int、boolean類型操作杭煎,數(shù)組類型操作
- #>> : 復合路徑下恩够,string類型操作,數(shù)組類型操作
細心的朋友可能已經(jīng)注意到羡铲,在『語法差異對比』一節(jié)中蜂桶,我們根本沒用到->和#>,而是采用了->>也切、#>> + ::number等指定類型方式扑媚。這是因為腰湾,語法上二者是等價的,但工程實踐上更適合做一致處理疆股。
既然在數(shù)據(jù)類型费坊、操作類型、嵌套查詢等方面存在差異旬痹,那么我們需要從CQL中提取這些信息附井,以便進行CQL->SQL轉換。通過JONGO完成詞法分析后CQL已被轉換成了DBObject两残,那么DBObject中是否包含了這些信息呢永毅?實踐證明:操作類型、是否為嵌套查詢可以提取人弓,但數(shù)據(jù)類型卻有可能獲取失敗沼死。這是因為Cql字符串轉換到DBObject時某些類型丟失了(或者說是退化),如日期退化成了字符串崔赌。
通過DBObject無法獲取正確的數(shù)據(jù)類型漫雕,是不是語法轉換就進行不下去了呢?來回顧一下數(shù)據(jù)處理的整個流程:首先峰鄙,把Cql轉換為SQL浸间,隨后通過JDBC訪問數(shù)據(jù)庫,最后將JSONB數(shù)據(jù)反序列化成實體對象吟榴。到這里你可能已經(jīng)明白了魁蒜,除了CQL外,還有一個隱藏輸入----實體對象吩翻,實體對象上必然有數(shù)據(jù)的實際類型兜看,不是嗎?最終做法如下:根據(jù)實體類(Entity Class)及查詢key值做反射狭瞎,將key值按『.』做分割细移,逐層查找Entity Class字段獲取正確類型。出于性能考慮熊锭,采用延遲加載+緩存策略弧轧,保證每個Entity Class僅被反射處理一次。
語法轉換
參見『語法差異對比』一節(jié)碗殷,表面上看MongoDB和PostgreSQL的語法差異很大精绎。進一步分析,來看下圖:
其中藍色代表mongodb語法元素锌妻,綠色代表postgresql語法元素代乃。將Cql和SQL拆解成語法樹后,二者除操作符不同($eq換成=)外仿粹,語法樹完全一致搁吓。之所以看起來差別很大是因為:Cql采用的是前序遍歷原茅,而SQL采用的是中序遍歷。
此處堕仔,只是簡單列舉了基本CQL的語法樹员咽,部分語法上有所不同,代碼處理時需要定制贮预,本文不詳細說明。
代碼實現(xiàn)
定義操作符枚舉類型契讲,用于操作符轉換仿吞。
enum SqlOperator
{
Or("$or", "or"),
And("$and", "and"),
Equal("$eq","="),
NotEqual("$ne", "!=");
LessThan("$lt", "<"),
GreatThan("$gt", ">"),
LessThanEqual("$lte", "<="),
GreatThanEqual("$gte", ">="),
In("$in", "in"),
NotIn("$nin", "not in"),
ElementMatch("$elemMatch", ""),
Regex("$regex", " ~ ")
}
定義語法樹節(jié)點類。這里沒有采用二叉樹捡偏,因為對于與唤冈、或這兩種操作可以支持任意多個子元素存在。
struct SyntaxElement
{
SqlOperator sqlOperator;
String key;
Object value;
List childElements;
}
定義一個SQL存儲類银伟。沒有直接將Cql轉換為SQL字符串而是單獨定義一個Sql類你虹,主要是出于工程上防SQL注入的考慮。
struct PreparedSql
{
String sql;
List sqlParameters;
}
struct SqlParameter
{
String parameter;
SqlDataType sqlDataType;
}
//Jsonb類型的參數(shù)處理方式和其它類型不同彤避,需要單獨定義
enum SqlDataType
{
Jsonb,
Other
}
創(chuàng)建SQL生成器傅物,負責Cql轉換為SQL。這里琉预,工程實現(xiàn)上最復雜的董饰、容易出錯的是『將DBObject轉換為語法樹』。我們需要小心處理每種細微差異圆米,并將其統(tǒng)一到SyntaxElement上來卒暂。
class SqlGenerator
{
public PreparedSql getPreparedSql(String tableName, String cql, Object... parameters)
{
//將字符串轉換為DBObject
dbObject = bsonQueryFactory.createQuery(cql, parameters).toDBObject();
//將DBObject轉換為語法樹
SyntaxElement sqlElement = syntaxAnalysis(dbObject);
//采用中序遍歷生成SQL
PreparedSql preparedSql = generatePreparedSql(sqlElement);
}
}
SyntaxElement+SqlOperator都屬Model類,并無任何函數(shù)娄帖。我們還需要定義一組針對SqlOperator的語法轉換規(guī)則:
switch(sqlOperator)
{
case Regex:
//特殊處理
case ElementMatch:
//特殊處理
case And:
case Or:
case Equal:
//標準處理
}
至于怎么將PreparedSql轉換為PreparedStatement也祠,再通過JDBC訪問PostgreSQL本文略過不提。
序列化/反序列化
基本想法是近速,JONGO既然可以將實體對象轉換成JSON數(shù)據(jù)诈嘿,并將取出的JSON數(shù)據(jù)反序列化成實體對象。那么我們就基于JONGO的這種能力做二次開發(fā)削葱。
有一點值得慶幸永淌,JONGO是開源的,我們得以深入到JONGO去研究其內部實現(xiàn)佩耳,并且在盡量不修改JONGO的前提下獲得這種能力遂蛀。
序列化
實體對象->DBObject
public DBObject convertToDBObject(T t)
{
//先將對象轉換為BsonDocument
BsonDocument document=asBsonDocument(mapper.getMarshaller(),t);
return document.toDBObject();
}
DBObject處理
private BasicDBObject doSerializeProcess(DBObject dbObject)
{
BasicDBObject basicDBObject = new BasicDBObject();
for (String key : dbObject.keySet())
{
Object obj = dbObject.get(key);
//根據(jù)類型,查找是否存在自定義轉換器干厚,存在則做二次處理
UserConverter converter=userConverters.get(obj.getClass());
if (converter != null)
{
obj = converter.serialize(obj);
}
basicDBObject.put(key, obj);
}
return basicDBObject;
}
DBObject -> Json
public String convertToJson(BasicDBObject basicDBObject)
{
return basicDBObject.toString();
}
反序列化
Json -> DBObject
public DBObject convertToDBObject(String json)
{
return (DBObject)JSON.parse(json);
}
DBObject處理
private DBObject doUnserializeProcess(DBObject dbObject, Class entityClass)
{
Class tempClass = entityClass;
//處理存在基類的繼承場景
while (!Object.class.equals(tempClass))
{
for (Field field : tempClass.getDeclaredFields())
{
String name = field.getName();
Object value = dbObject.get(name);
UserConverter converter=userConverters.get(field.getType());
if (parser != null)
{
value = parser.unserialize((String)value);
}
dbObject.put(name, value);
}
tempClass = tempClass.getSuperclass();
}
}
DBObject -> 實體對象
private T convertToEntity(DBObject dbObject)
{
//轉換為實體類
BsonDocument document = Bson.createDocument(dbObject);
return mapper.getUnmarshaller().unmarshall(document, entityClass);
}
UserConverter用于處理特殊數(shù)據(jù)類型李滴,即那些Mongo做了特殊處理導致無法使用標準的jsonb語法做正確存儲螃宙、查詢的類型。先來看UserConverter的定義:
public interface UserConverter
{
public String serialize(T obj);
public T unserialize(String value);
}
當前系統(tǒng)中所坯,我們識別到的特殊類型包括UUID谆扎、Date兩種類型,具體實現(xiàn)是相對自由的芹助,此處略過不提堂湖。
實際上除了上述討論的內容,還有三點需要說明
- 除了Date状土、UUID兩種類型无蜂,還有一種byte[]類型是需要外處理的。
- JONGO實際上自身已經(jīng)支持自定義轉換器蒙谓。
到這里你可能心里暗罵:麻蛋斥季,明明知道JONGO支持自定義轉換器還要自定義;明明是Date累驮、UUID酣倾、byte[]三種類型需要處理卻說是兩種(-_-!!)......
OK,讓我來解釋下谤专。JONGO支持的自定義轉換器只針對非內置類型躁锡,而Date、UUID被認定為內置類型置侍。byte[]為非內置類型稚铣,因此可以使用自身的自定義轉換器。 - Java是面向對象的墅垮,當實體對象中包含了基類或接口對象時惕医,其存放的實體可能是基類的繼承類或接口的實現(xiàn)類。這在序列化時沒有問題算色,但反序列化時抬伺,由于類型丟失,一個接口并不知道要轉換成哪種實現(xiàn)類灾梦,基類場景則只能退化為基類峡钓。針對這類場景,Mongo已有解決方案若河,我們利用該特性并做了易用性簡化能岩。
public void addTypeAdapter(Class origType, Class realType)
{
builder.addDeserializer(origType, new JsonDeserializer()
{
@Override
public T deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException
{
return jp.readValueAs(realType);
}
});
mapper = builder.build();
}
結束語
至此,MongoDB切換PostgreSQL工程實踐上遇到的各種問題萧福、解決方案拉鹃、部分技術細節(jié)做了詳述。可能基于此文膏燕,你還是無法將MongoDB切換到PostgreSQL或者其他數(shù)據(jù)庫钥屈,但希望至少是有幫助的。
當然坝辫,本文還有很多沒有深入篷就、甚至沒有提及。如MongoDB中_id默認字段的處理方式近忙、對jsonb數(shù)據(jù)增加索引以提高性能竭业,或者語法樹上的各種技術細節(jié)。再比如針對數(shù)據(jù)庫切換后的擴及舍、減容策略未辆,可靠性機制等。
最后击纬,如果你正在做數(shù)據(jù)庫接口設計,請盡量避免將其和具體的數(shù)據(jù)庫綁定钾麸,萬一真的出現(xiàn)需要切換數(shù)據(jù)庫的場景將會為你節(jié)省大量的工作更振。退一步講,即便沒有更換數(shù)據(jù)庫的訴求饭尝,接口和實現(xiàn)解耦本身也是架構師應該具備的基本能力肯腕。
轉載請注明:(隨安居士)http://www.reibang.com/p/309f876be20a