MongoDB切換PostgreSQL工程實踐

本文闡述了大型系統(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ù)庫遷移软舌。

目標

項目達成以下目標:

  1. 由MongoDB到PostgreSQL的平滑遷移才漆。
    平滑遷移主要是指對外保留現(xiàn)有的MongoDB接口,內部數(shù)據(jù)存儲由MongoDB切換至PostgreSQL數(shù)據(jù)庫佛点。對于遺留系統(tǒng)或具有一定規(guī)模的(分布式)系統(tǒng)醇滥,數(shù)據(jù)訪問層作為公共數(shù)據(jù)接口被大量的業(yè)務使用。如果由于數(shù)據(jù)切換廢棄原有的抽象接口超营,而全部采用全新接口鸳玩,后果將是災難性的。
  2. 提供新的糟描、支持事務怀喉、分布式事務或其它關系型數(shù)據(jù)庫具備標準能力的數(shù)據(jù)訪問層。
    Hibernate作為DAO層船响,擴展并豐富現(xiàn)有的Annotation躬拢。
  3. 支持分片、主備部署见间、可靠性增強等聊闯。

技術分析

接口定義

拋開實現(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類型可參見如下鏈接:

Documentation: 9.5: JSON Types
JSON Functions and Operators

對于現(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)是相對自由的芹助,此處略過不提堂湖。
實際上除了上述討論的內容,還有三點需要說明

  1. 除了Date状土、UUID兩種類型无蜂,還有一種byte[]類型是需要外處理的。
  2. JONGO實際上自身已經(jīng)支持自定義轉換器蒙谓。
    到這里你可能心里暗罵:麻蛋斥季,明明知道JONGO支持自定義轉換器還要自定義;明明是Date累驮、UUID酣倾、byte[]三種類型需要處理卻說是兩種(-_-!!)......
    OK,讓我來解釋下谤专。JONGO支持的自定義轉換器只針對非內置類型躁锡,而Date、UUID被認定為內置類型置侍。byte[]為非內置類型稚铣,因此可以使用自身的自定義轉換器。
  3. 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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市钥平,隨后出現(xiàn)的幾起案子实撒,更是在濱河造成了極大的恐慌,老刑警劉巖涉瘾,帶你破解...
    沈念sama閱讀 222,104評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件知态,死亡現(xiàn)場離奇詭異,居然都是意外死亡立叛,警方通過查閱死者的電腦和手機负敏,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,816評論 3 399
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來秘蛇,“玉大人其做,你說我怎么就攤上這事×藁梗” “怎么了妖泄?”我有些...
    開封第一講書人閱讀 168,697評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長艘策。 經(jīng)常有香客問我蹈胡,道長,這世上最難降的妖魔是什么贰逾? 我笑而不...
    開封第一講書人閱讀 59,836評論 1 298
  • 正文 為了忘掉前任欠拾,我火速辦了婚禮,結果婚禮上赐纱,老公的妹妹穿的比我還像新娘搅轿。我一直安慰自己病涨,他們只是感情好,可當我...
    茶點故事閱讀 68,851評論 6 397
  • 文/花漫 我一把揭開白布璧坟。 她就那樣靜靜地躺著既穆,像睡著了一般。 火紅的嫁衣襯著肌膚如雪雀鹃。 梳的紋絲不亂的頭發(fā)上幻工,一...
    開封第一講書人閱讀 52,441評論 1 310
  • 那天,我揣著相機與錄音黎茎,去河邊找鬼囊颅。 笑死,一個胖子當著我的面吹牛傅瞻,可吹牛的內容都是我干的踢代。 我是一名探鬼主播,決...
    沈念sama閱讀 40,992評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼嗅骄,長吁一口氣:“原來是場噩夢啊……” “哼胳挎!你這毒婦竟也來了?” 一聲冷哼從身側響起溺森,我...
    開封第一講書人閱讀 39,899評論 0 276
  • 序言:老撾萬榮一對情侶失蹤慕爬,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后屏积,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體医窿,經(jīng)...
    沈念sama閱讀 46,457評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,529評論 3 341
  • 正文 我和宋清朗相戀三年炊林,在試婚紗的時候發(fā)現(xiàn)自己被綠了留搔。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,664評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡铛铁,死狀恐怖隔显,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情饵逐,我是刑警寧澤括眠,帶...
    沈念sama閱讀 36,346評論 5 350
  • 正文 年R本政府宣布,位于F島的核電站倍权,受9級特大地震影響掷豺,放射性物質發(fā)生泄漏捞烟。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 42,025評論 3 334
  • 文/蒙蒙 一当船、第九天 我趴在偏房一處隱蔽的房頂上張望题画。 院中可真熱鬧,春花似錦德频、人聲如沸苍息。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,511評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽竞思。三九已至,卻和暖如春钞护,著一層夾襖步出監(jiān)牢的瞬間盖喷,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,611評論 1 272
  • 我被黑心中介騙來泰國打工难咕, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留课梳,地道東北人。 一個月前我還...
    沈念sama閱讀 49,081評論 3 377
  • 正文 我出身青樓余佃,卻偏偏與公主長得像暮刃,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子咙冗,可洞房花燭夜當晚...
    茶點故事閱讀 45,675評論 2 359

推薦閱讀更多精彩內容