基於 MongoDB 解決微服務設計中的原子寫入問題

迎關注我的頭條號:@Wooola,10 年 Java 軟件開發及架構設計經驗,專注於 Java、Go 語言、微服務架構,致力於每天分享原創文章、快樂編碼和開源技術。

毫不保留的說,我們正處在一個充滿併發計算的世界裡。為了保證業務數據的一致性狀態不遭受破壞,開發者通常需要對潛在的併發以及異常場景做出估量並採取適當的原子性保護。

與此同時,幾乎所有主流的編程語言都提供了良好的併發框架支持,例如,Java中的 concurrent 包就提供了全面的鎖特性實現。藉由這些能力,我們很容易在單進程應用中解決原子性方面的問題。但是,微服務架構讓應用程序處理併發原子性問題變得更加複雜,這是由分佈式系統的複雜性所決定的。尤其是對於實例(進程)內施加的鎖機制無法解決分佈式的問題。

如下圖所示:

基於 MongoDB 解決微服務設計中的原子寫入問題


對於 MongoDB 來說,更多的應用實踐傾向於利用單文檔事務性來解決原子性問題,當然,你也可以使用高版本中的多文檔事務實現,但缺點是必須接受多文檔事務所帶來的性能損失。而關於MongoDB 的文檔級原子性,儘管大多數人已經知道這一點,但在一些真實的項目案例中,仍然可以發現各種考慮不周的情形。

下面,以案例來說明此類問題。

案例一

為了能瞭解網站上在售課程的受歡迎程度,我們增加了課程的關注功能,即喜歡該課程的用戶可以通過點擊關注以獲得更新通知。這樣,在課程的信息頁面上也可以清楚的看到關注的人數。

為此,每個課程文檔需要增加 favCount 字段用來表示得到的關注數量,如下:

<code>@Data@Document(collection="Course")public class Course {    @Id    private String id;    private String courseName;        //收藏數量    private Integer favCount;        .../<code>

這裡對 Course 類添加了@Document 註解,這表示框架將處理文檔和對象之間的關係,這是Spring Data Mongo 提供的 ORM 實現。

那麼,對於"加關注"這一邏輯功能,很容易實現如下:

<code>    @Autowired    private CourseRepository courseRepository;    public boolean incrFavCount(String courseId) {        Assert.hasLength(courseId, "courseId required");        Course course = courseRepository.findById(courseId).orElse(null);        if (course == null) {            return false;        }        //將收藏數加一        course.setFavCount(course.getFavCount() + 1);        return courseRepository.save(course) != null;    }    /<code>

在 incrFavCount 這個方法中,實現了增加課程的收藏數這一邏輯,一般我們會在保存用戶收藏記錄之後調用該方法,以此更新關注後的人數。

但是,這段代碼存在兩個問題:

  1. courseRepository.save() 是一個“萬金油方法”,它會保存更新後的 Course 對象。但是請注意,我們實際上只需要更新 favCount 這麼一個字段,相對於整個 Course 對象來說,選擇只更新一個整數字段的開銷要小得多。
  2. 程序採用了 get and set 非原子性的方式進行寫入,並沒有考慮到併發的問題。假設有兩個用戶同時點擊了關注,那麼會存在兩個線程同時 get 到同樣的值進行自增後,又寫入了一樣的結果,這樣就無法實現累加了。

更合理的方案是使用 $inc 操作符進行更新,一方面可以只選擇更新 favCount 字段。另一方面由於 $inc 是有原子性保證的,因此多個用戶就算同時點擊了關注,最終的 favCount 也會是累加的結果。

改善後的程序如下所示:

<code>    @Autowired    private MongoTemplate mongoTemplate;    public boolean incrFavCount(String courseId) {        Assert.hasLength(courseId, "courseId required");        Query query = new Query();        query.addCriteria(Criteria.where("id").is(courseId));        Update update = new Update();        update.inc("favCount", 1);        UpdateResult result = mongoTemplate.updateFirst(query, update, Course.class);        return result.getModifiedCount() > 0;    }/<code>

針對於第一個問題,筆者希望補充的一點建議是慎用 save() 方法。當然,慎用並不是不建議使用,而是在使用時做出一些必要的權衡。save() 是 SpringData 框架所提供的方法,它會根據所保存的對象是否包含非空(null) id 字段來選擇執行 insert 還是 update 操作,但最終都是全量的操作。出於高性能方面的考慮,在更新對象時我們應當只更新必要的部分。這是因為:

  • 如果毫無保留的使用全量 save 的做法,會浪費帶寬和計算資源。
  • 一旦集合上存在多個索引,文檔的更新還會同時觸發多個索引的 IO 操作,這是得不償失的。

案例二

在新電影上線之前,院方都會事先進行排片,這一般可以通過後臺系統做好電影的場次編排,包括放映時間、影廳信息等等。而顧客則是通過影院的訂票系統來選擇場次座位,並最終確認下單。

如下圖,是下單時選擇座位的頁面:

基於 MongoDB 解決微服務設計中的原子寫入問題

圖-影院訂座頁面

如果使用 MongoDB 來設計影院的場次訂座功能,應該如何實現呢?

可以先從場次的信息入手,考慮如下的文檔模型:

<code>{  id : ObjectId("5aed671c07ce9dc21a26238a"),  movie : "勇敢者遊戲2(決戰叢林)",  Office : "巨幕5號廳",  showTime : "2019-09-30 12:30:00",  seats: {      "101": "N","102": "N",    "103": "Y:user01","104": "Y:user01",...    "201": "N","202": "Y:user05",...  }}/<code>

這裡我們大膽使用了一種"預分配"的方式來設計該文檔,一個場次的主要信息包括:

  • id:場次的ID
  • movie:電影名稱
  • office:影廳名稱
  • showTime:播放時間
  • seats:座位表

其中 seats 是一個內嵌的子文檔,其每一個字段的 key 就是影廳的座位號。如果影廳有 100 個座位,那麼 seats 將會有 對應的100個字段。而且在一開始安排場次的時候,seats 座位表就應該預先寫入了。每個座位號對應的默認值是 N,代表未被預定的狀態,如果已經被預定,則寫入新的值 “Y:{預定用戶ID}”。

接下來該考慮如何實現預定功能了。顯而易見的是,save 方法在這裡顯然是不可取的,因為當用戶 user01 預定了某個座位時,只更新 seats 中座位號的值就可以了,而不需要讀取或者是保存整個文檔。這裡我們可以使用 $set 操作符來實現子文檔中字段的更新操作,代碼實現如下:

<code>    @Autowired    private MongoTemplate mongoTemplate;    public boolean arrangeSeat(String userId, String movieStId, String seatNo) {        Assert.hasLength(userId, "userId required");        Assert.hasLength(movieStId, "movieStId required");        Assert.hasLength(seatNo, "seatNo required");        //指定子文檔的座位號字段        String seatField = "seats." + seatNo;        Query query = new Query();        //條件1: 匹配當前場次ID        query.addCriteria(Criteria.where("id").is(movieStId));        //條件2:座位號的值為N        query.addCriteria(Criteria.where(seatField).is("N"));        Update update = new Update();        //更新座位號的值        update.set("seats." + seatNo, "Y:" + userId);        UpdateResult result = mongoTemplate.updateFirst(query, update, Course.class);        return result.getModifiedCount() > 0;    }/<code>

你可能已經注意到了,執行更新的條件並不只有滿足場次 id 一個,還包含了對於座位號現存值的判斷。也就是說只有該場次中指定座位沒有被預定的時候才會成功更新文檔。與普通的 get and set 方式相比,這樣的做法充分利用了文檔級的原子性更新,最終保證同一個場次座位號只能被一個用戶成功預訂。

對了,另外一個問題可能還需要解釋一下,那就是為什麼 seats 中座位被預定成功後需要寫入Y和用戶ID呢?

可以從下面兩點思考:

  • 預定之後可能還需要生成憑票。如果恰好在預定成功後程序發生了中斷,由於文檔更新是原子性的,這可以保證預定座位號上會同時寫入用戶ID,此時根據這個記錄可以在後續進行補票處理。
  • 在查詢座位表的狀態時,可以同時知道當前用戶是否已經預定了指定的某些座位,給予一定的提醒。

在本案例中,使用座位號(seatNo)的狀態(Y|N)作為更新的准入條件,在有限的場景下是適用的。這裡蘊含的意思是,座位的狀態不會存在反覆變更的情況。對於一些更復雜場景來說,還可以使用版本號來描述狀態,由於版本號是不斷遞增的,這樣就不存在狀態值反覆的問題。

樂觀鎖

如果已經比較熟悉 CAS (compare and set) 樂觀鎖的話,不難發現這就是 MongoDB 版本的 CAS 實現!藉助這一點,我們也可以巧妙的解決許多併發性的問題。

當然了,Spring Data Mongo 自帶了對樂觀鎖的支持,如下:

<code>@Documentclass Person {  @Id String id;  String firstname;  String lastname;  @Version Long version;}/<code>

Person 文檔中對於 version 屬性添加了 @Version 屬性,即表示該字段將作為當前文檔的元數據版本。

此後框架在執行 insert/update/delete 操作時都會對該屬性進行特殊處理,最關鍵的一點是提供了版本衝突檢測。如下面這段代碼:

<code>Person daenerys = template.insert(new Person("Daenerys"));Person tmp = template.findOne(query(where("id").is(daenerys.getId())), Person.class);daenerys.setLastname("Targaryen");template.save(daenerys);template.save(tmp);/<code> 

其執行的流程如下:

  1. 插入 Person 文檔 daenerys,此時 version 被初始化為0。
  2. 根據 ID 將 插入的文檔查出,此時 tmp 對象中的 version 也是0。
  3. 修改 daenerys 對象,執行save,此時數據庫中的文檔 version 產生了自增變為1。
  4. 再次保存 tmp 對象(id和原文檔相同),由於 tmp 對象中的 version 仍然是 0,因此這一步將會報錯。框架在檢測衝突時會拋出 OptimisticLockingFailureException 異常,此時應用可以對該異常採取進一步的措施,例如 重試、或者相關日誌的記錄。

除了 save 方法,對於部分字段更新使用 update,該操作同樣能從 @Version 註解中受益。

Spring Data Mongo 實現樂觀鎖的方式

框架對於 @Version 註解的字段做了特殊處理,每當執行 update 操作時,該字段會自動自增。

下面的源碼清楚的展示了這點:

<code>//執行更新時觸發private void increaseVersionForUpdateIfNecessary(@Nullable MongoPersistentEntity> persistentEntity, UpdateDefinition update) {        //如果存在@Version註解的屬性if (persistentEntity != null && persistentEntity.hasVersionProperty()) {String versionFieldName = persistentEntity.getRequiredVersionProperty().getFieldName();if (!update.modifies(versionFieldName)) {    //自動執行版本號自增update.inc(versionFieldName);}}}/<code>

如前面所說的,使用事務同樣可以解決原子性方面的問題。但如果你正在尋求性能無損、或是更加輕量化的方式,那完全可以考慮上面所提到的這些做法,而且隨著應用框架的日漸成熟,開發的工作量也會越來越小。

本文所展示的示例代碼藉由 Spring Data Mongo 實現,有興趣的讀者可進一步參考官方文檔:

https://docs.spring.io/spring-data/mongodb/docs/2.2.3.RELEASE/reference/html/


來源:https://mp.weixin.qq.com/s/Bd8bFiGE75MTVJzHn13ysw

侵刪


分享到:


相關文章: