基于 MongoDB 解决微服务设计中的原子写入问题

2020-02-19 15:08:53 浏览数 (1)

本文是唐卓章在“我和MongoDB的故事”MongoDB征文比赛的获奖文章,下面我们一起来欣赏下。

毫不保留的说,我们正处在一个充满并发计算的世界里。为了保证业务数据的一致性状态不遭受破坏,开发者通常需要对潜在的并发以及异常场景做出估量并采取适当的原子性保护。

与此同时,几乎所有主流的编程语言都提供了良好的并发框架支持,例如,Java中的 concurrent 包就提供了全面的锁特性实现。借由这些能力,我们很容易在单进程应用中解决原子性方面的问题。但是,微服务架构让应用程序处理并发原子性问题变得更加复杂,这是由分布式系统的复杂性所决定的。尤其是对于实例(进程)内施加的锁机制无法解决分布式的问题。

如下图所示:

对于 MongoDB 来说,更多的应用实践倾向于利用单文档事务性来解决原子性问题,当然,你也可以使用高版本中的多文档事务实现,但缺点是必须接受多文档事务所带来的性能损失。而关于MongoDB 的文档级原子性,尽管大多数人已经知道这一点,但在一些真实的项目案例中,仍然可以发现各种考虑不周的情形。

下面,以案例来说明此类问题。

案例一

为了能了解网站上在售课程的受欢迎程度,我们增加了课程的关注功能,即喜欢该课程的用户可以通过点击关注以获得更新通知。这样,在课程的信息页面上也可以清楚的看到关注的人数。

为此,每个课程文档需要增加 favCount 字段用来表示得到的关注数量,如下:

代码语言:javascript复制
@Data
@Document(collection="Course")
public class Course {

    @Id
    private String id;
    private String courseName;
    
    //收藏数量
    private Integer favCount;
    
    ...

这里对 Course 类添加了@Document 注解,这表示框架将处理文档和对象之间的关系,这是Spring Data Mongo 提供的 ORM 实现。

那么,对于"加关注"这一逻辑功能,很容易实现如下:

代码语言:javascript复制
    @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;
    }
    

在 incrFavCount 这个方法中,实现了增加课程的收藏数这一逻辑,一般我们会在保存用户收藏记录之后调用该方法,以此更新关注后的人数。

但是,这段代码存在两个问题:

  1. courseRepository.save() 是一个“万金油方法”,它会保存更新后的 Course 对象。但是请注意,我们实际上只需要更新 favCount 这么一个字段,相对于整个 Course 对象来说,选择只更新一个整数字段的开销要小得多。
  2. 程序采用了 get and set 非原子性的方式进行写入,并没有考虑到并发的问题。假设有两个用户同时点击了关注,那么会存在两个线程同时 get 到同样的值进行自增后,又写入了一样的结果,这样就无法实现累加了。

更合理的方案是使用 $inc 操作符进行更新,一方面可以只选择更新 favCount 字段。另一方面由于 $inc 是有原子性保证的,因此多个用户就算同时点击了关注,最终的 favCount 也会是累加的结果。

改善后的程序如下所示:

代码语言:javascript复制
    @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;
    }

针对于第一个问题,笔者希望补充的一点建议是慎用 save() 方法。当然,慎用并不是不建议使用,而是在使用时做出一些必要的权衡。save() 是 SpringData 框架所提供的方法,它会根据所保存的对象是否包含非空(null) id 字段来选择执行 insert 还是 update 操作,但最终都是全量的操作。出于高性能方面的考虑,在更新对象时我们应当只更新必要的部分。这是因为:

  • 如果毫无保留的使用全量 save 的做法,会浪费带宽和计算资源。
  • 一旦集合上存在多个索引,文档的更新还会同时触发多个索引的 IO 操作,这是得不偿失的。

案例二

在新电影上线之前,院方都会事先进行排片,这一般可以通过后台系统做好电影的场次编排,包括放映时间、影厅信息等等。而顾客则是通过影院的订票系统来选择场次座位,并最终确认下单。

如下图,是下单时选择座位的页面:

图-影院订座页面

如果使用 MongoDB 来设计影院的场次订座功能,应该如何实现呢?

可以先从场次的信息入手,考虑如下的文档模型:

代码语言:javascript复制
{
  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",
	
	...
  }

}

这里我们大胆使用了一种"预分配"的方式来设计该文档,一个场次的主要信息包括:

  • id:场次的ID
  • movie:电影名称
  • office:影厅名称
  • showTime:播放时间
  • seats:座位表

其中 seats 是一个内嵌的子文档,其每一个字段的 key 就是影厅的座位号。如果影厅有 100 个座位,那么 seats 将会有 对应的100个字段。而且在一开始安排场次的时候,seats 座位表就应该预先写入了。每个座位号对应的默认值是 N,代表未被预定的状态,如果已经被预定,则写入新的值 “Y:{预定用户ID}”。

接下来该考虑如何实现预定功能了。显而易见的是,save 方法在这里显然是不可取的,因为当用户 user01 预定了某个座位时,只更新 seats 中座位号的值就可以了,而不需要读取或者是保存整个文档。这里我们可以使用 $set 操作符来实现子文档中字段的更新操作,代码实现如下:

代码语言:javascript复制
    @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;

    }

你可能已经注意到了,执行更新的条件并不只有满足场次 id 一个,还包含了对于座位号现存值的判断。也就是说只有该场次中指定座位没有被预定的时候才会成功更新文档。与普通的 get and set 方式相比,这样的做法充分利用了文档级的原子性更新,最终保证同一个场次座位号只能被一个用户成功预订。

对了,另外一个问题可能还需要解释一下,那就是为什么 seats 中座位被预定成功后需要写入Y和用户ID呢?

可以从下面两点思考:

  • 预定之后可能还需要生成凭票。如果恰好在预定成功后程序发生了中断,由于文档更新是原子性的,这可以保证预定座位号上会同时写入用户ID,此时根据这个记录可以在后续进行补票处理。
  • 在查询座位表的状态时,可以同时知道当前用户是否已经预定了指定的某些座位,给予一定的提醒。

在本案例中,使用座位号(seatNo)的状态(Y|N)作为更新的准入条件,在有限的场景下是适用的。这里蕴含的意思是,座位的状态不会存在反复变更的情况。对于一些更复杂场景来说,还可以使用版本号来描述状态,由于版本号是不断递增的,这样就不存在状态值反复的问题。

乐观锁

如果已经比较熟悉 CAS (compare and set) 乐观锁的话,不难发现这就是 MongoDB 版本的 CAS 实现!借助这一点,我们也可以巧妙的解决许多并发性的问题。

当然了,Spring Data Mongo 自带了对乐观锁的支持,如下:

代码语言:javascript复制
@Document
class Person {

  @Id String id;
  String firstname;
  String lastname;
  @Version Long version;
}

Person 文档中对于 version 属性添加了 @Version 属性,即表示该字段将作为当前文档的元数据版本。

此后框架在执行 insert/update/delete 操作时都会对该属性进行特殊处理,最关键的一点是提供了版本冲突检测。如下面这段代码:

代码语言:javascript复制
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);

其执行的流程如下:

  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 操作时,该字段会自动自增。

下面的源码清楚的展示了这点:

代码语言:javascript复制

//执行更新时触发
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);
			}
		}
	}

如前面所说的,使用事务同样可以解决原子性方面的问题。但如果你正在寻求性能无损、或是更加轻量化的方式,那完全可以考虑上面所提到的这些做法,而且随着应用框架的日渐成熟,开发的工作量也会越来越小。

本文所展示的示例代码借由 Spring Data Mongo 实现,有兴趣的读者可进一步参考官方文档:

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

作者:唐卓章

华为技术专家,多年互联网研发/架设经验,关注NOSQL 中间件高可用及弹性扩展,在分布式系统架构性能优化方面有丰富的实践经验,目前从事物联网平台研发工作,致力于打造大容量高可用的物联网服务。

0 人点赞