并发优化
1、MongoDB的锁模式
1.1、MongoDB的锁设计
MongoDB的高性能表现离不开它的多粒度锁机制。多粒度主要可以针对不同层级的数据库对象进行加锁,通过避免全局性的互斥来提升并发能力。从整个数据库层面看,MongoDB的并发锁的分层机制如图所示。

从上往下是一个逐步细分的关系,分别为Global(全局)、Database(数据库)、Collection(集合)、Document(文档)。需要说明的是,MongoDB只定义了前面三种级别的锁,对于文档级的锁则是由WiredTiger引擎实现的,其内部使用了MVCC乐观锁的方式来实现并发控制。因此,并不是所有引擎的实现都支持文档级的锁,如早期的MMAPV1引擎并不支持这样的特性。
除此之外,针对不同的读写方式,数据库将锁类型分为以下几种。
- 读锁(R),代表共享锁(S)。
- 写锁(W),代表排它锁(X)。
- 意向读锁(r),代表意向的共享锁(IS)。
- 意向写锁(w),代表意向的排它锁(IX)。
这几种锁类型的兼容情况见表。

其中,读写锁一般比较容易理解,但意向锁却有着更为重要的意义。它描述的是一种中间状态(非真正的互斥)。对于某一层资源进行锁定时,都需要对其更高层次的资源添加意向锁。而且,意向锁之间不是互斥的,这样可以保证在高层次资源上尽可能不会出现锁争用的情况。
比如,更新users集合中的某一条用户记录(id=1)时,需要经过如下流程:
- 对Global增加意向写锁(IX)。
- 对Database增加意向写锁(IX)。
- 对Collection增加意向写锁(IX)。
- 对users(id=1)记录执行更新(乐观锁)。
可见,有了意向锁这层定义,不同用户文档的写操作便不会产生互斥了(同一集合的意向写锁可以存在多个)。那么不添加意向锁是不是也可以呢?答案是不行的,例如我们想要执行一些全局操作,如renameCollection,此时需要对Collection添加写锁。而为了保证数据的一致性,任何对于该Collection内的文档读写都应该等待该操作完成,此时就需要通过意向锁来实现这种并发控制了。
当数据库对象产生锁冲突时,被阻塞的请求会被写入队列。例如由于某个对象存在写锁(X),之后获取锁的请求需要进行排队,如下:IS→IS→X→X→S→IS
如果以先进先出(FIFO)队列的思路来考虑,那么当写锁被释放后,前面的两个IS请求应该得到授权。但为了提高吞吐量,MongoDB会将所有的IS、S请求都授予,然后授予X,这样做的目的在于尽可能地减少总体的等待时间。
一些常见操作对应的锁见表。

1.2、查看锁的状态
我们可以通过db.currentOp命令查看锁的状态,代码如下:


上述代码中,返回的locks子文档描述了当前操作对每一种锁的获得情况,其中xxx.acquireCount表示获得的次数。如果一个操作范围很大,如使用updateMany更新大量文档时,则往往可以看到比较高的锁获得数量。这是因为MongoDB对于这种长操作会拆分为大量的小事务(updateOne),因而会呈现出多次获取锁的情况。另外,写冲突(write conflict)可能会导致重试,此时也会产生多次重复的锁操作。
1.3、锁的让步
在某些情况下,读写操作会让出它们所持有的锁(Yield),这个操作是在数据库内部发生的。
对于一些长时间运行的读写操作,如查询、更新及删除,有很大的概率会产生这种让步行为。对于updateMany这样的操作,MongoDB同样会在每次更新文档的间隙中让渡出锁。我们知道,WiredTiger已经达到了文档级别的细粒度并发控制,对于集合及以上级别的意向锁也不会影响并发,那为什么还需要进行让渡呢?原因如下。
- 避免长时间执行的存储性事务,因为这些会给内存造成较大的压力。
- 作为中断响应点(interruption point),以便可以杀死长时间运行的操作。
- 允许一些关键的排它性操作得到执行,例如索引/集合的创建、删除。
2、MVCC
在并发场景下,用于保证一致性的做法一般有两种:
- 使用互斥锁。
- 基于MVCC(Multi Version ConcurrencyControl)的多版本并发控制。
其中,MVCC是目前数据库中使用最广泛的一种经典机制,包括MongoDB、MySQL、PostGreSQL等流行数据库都在使用。我们可以将MVCC看成行级锁的一种妥协,它在许多情况下避免了使用锁,同时可以提供更小的开销。相比互斥锁来说,MVCC允许存在数据的多份复制,可以使读操作和写操作同时进行,很大程度上提升了并发能力。
不同数据库对MVCC的实现方式有些不同,MongoDB对于文档的并发控制是由WiredTiger引擎实现的。其对于数据的每一次修改都会在内存中产生一个新的版本,并通过链表结构进行记录,如图所示。

从图中可以看到,文档的更新是一种copy on write的方式,每一次修改都会被附加到链表的头部。访问数据的线程会自动检查是否存在更新的版本并获取最近修改的副本。如果是删除操作,则会写入数据的delete标记,读取时进行判别就可以了。在这种模式下,允许写入线程在读取线程执行读操作时并发地创建新版本,该过程是无锁的。而只有在多个线程尝试更新同一个记录时才会产生写冲突(write confict),此时只会有一个更新操作成功,其他操作会在稍后进行重试,如图所示。

MongoDB在写入更新记录时使用了基于version的乐观锁模式,当写冲突产生(尝试更新失败)时,WiredTiger内部会产生WT_ROLLBACK结果,而MongoDB检测到该状态之后会抛出WriteConflictException,最终由写入的执行线程捕获该异常,并在后续进行重试。
正如前面所说的,MongoDB在一开始就实现了单文档的事务,用来保证文档写操作、oplog以及journal日志的原子性。但这种单文档事务仍然属于内部机制,这是一种隐性的事务。而MongoDB 4.0版本之后支持的多文档事务则是显性的事务,多文档事务提供了基于快照一致性的读能力,这同样离不开MVCC。
如果将多文档事务考虑进来,那么情况可能会复杂一些,比如在读取时根据事务的序号来获取在事务开启之前已提交的版本(快照一致性),而不是最近提交的版本。
总的来说,MongoDB的MVCC在实现上有如下特性。
- 在内存中存放文档的多个版本。
- 读取数据行默认获取到当前最新的提交。如果在多文档事务中,还可以使用snapshot级别读取事务一致的版本。
- 写操作时只会追加新的版本,不会和读操作互斥。
- 对于同一个文档的并发更新会导致写冲突,由MongoDB内部自动进行重试。
3、原子性操作
在分布式系统中,有很大概率会出现并发读写的情况。为了保证业务数据的一致性状态不遭受破坏,开发者通常需要对潜在的并发以及异常场景做出估量并采取适当的原子性保护。
几乎所有主流的编程语言都提供了良好的并发框架支持,例如,Java中的concurrent包就提供了全面的锁特性实现。借由这些能力,我们很容易在单进程应用中解决原子性方面的问题。但是,微服务架构让应用程序处理并发原子性问题变得更加复杂,关键就在于这些进程内施加的本地锁无法解决分布式的问题,如图所示。

对于MongoDB来说,更多的应用实践倾向于利用单文档事务性来解决原子性问题,当然,你也可以使用高版本中的多文档事务实现,但缺点是必须接受多文档事务所带来的性能损失。而关于MongoDB的文档级原子性,尽管大多数人已经知道这一点,但在一些真实的项目案例中,仍然可以发现各种考虑不周的情形。
下面,我们以案例来说明此类问题。
为了能了解网站上在售课程的受欢迎程度,我们增加了课程的关注功能,即喜欢该课程的用户可以通过单击关注以获得更新的通知。这样,在课程的信息页面上也可以清楚地看到关注人数。
为此,每个课程文档需要增加favCount字段用来表示得到的关注数量,代码如下:
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>mongo4sb</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.2.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
</dependencies>
</project>
js
spring:
data:
mongodb:
host: 127.0.0.1
database: test
port: 27017
# 也可以使用uri mongodb://127.0.0.1:27017/test
server:
port: 8080
entity:
java
@Data
public class Course {
@Id
private String id;
private String courseName;
//收藏数量
private Integer favCount;
}
那么,对于"加关注"这一逻辑功能,很容易写出下面的代码:
java
@RestController
@RequestMapping("/test")
public class TestConrtoller {
@Autowired
private CourseService courseService;
@GetMapping("/a")
public boolean a(String courseId){
return courseService.incrFavCount(courseId);
}
}
java
public interface CourseRepository extends MongoRepository<Course, String> {
}
java
@Service
public class CourseService {
@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这个方法中,实现了增加课程的收藏数这一逻辑,一般我们会在保存用户收藏记录之后调用该方法,以此更新关注后的人数。
上述代码其实存在两个问题:
- courseRepository.save是一个"万金油方法",它会保存更新后的Course对象。但是请注意,我们实际上只需要更新favCount这一个字段,相对于整个Course对象来说,选择只更新一个整数字段的开销要小得多。
- 程序中使用了"get and set"这种非原子性的方式进行更新,并没有考虑到并发的问题。假设有两个用户同时单击了关注,那么两个线程同时读取到同样的值进行自增后,又写入了一样的结果,这样就无法实现累加了。
更合理的方案是,使用inc操作符进行更新,一方面可以只选择更新favCount字段。另一方面由于inc是有原子性保证的,因此多个用户就算同时单击了关注,最终的favCount也会是累加的结果。
改善后的代码如下所示:
java
@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.getMatchedCount() > 0;
}
对于第一个问题所提到的save方法,建议在使用之前做一些必要的权衡。save是SpringData框架所提供的方法,它会根据所保存的对象是否包含非空(null)ID字段来选择执行insert还是update操作,但始终是一个全量保存的动作。出于高性能方面的考虑,在更新对象时建议只更新必要的部分。这是因为:
- 如果毫无保留地使用全量保存的做法,则很容易造成带宽的浪费。
- 一旦集合中存在多个索引,文档的更新还会同时触发多个索引的I/O操作,这会增加写磁盘的压力。
4、乐观锁
乐观锁(CAS)是避免并发冲突的优选模式,实现乐观锁的前提是文档的原子性更新。借助MongoDB的CAS模式,我们可以巧妙地解决许多并发性的问题。
4.1、电影院订座的案例
在新电影上线之前,通常院方都会事先进行排片,通过后台系统做好电影的场次编排,包括放映时间、影厅信息等。而顾客则通过影院的订票系统来选择场次座位,并最终确认下单。图是下单时选择座位的页面。

如果使用MongoDB来设计影院的场次订座功能,应该如何实现呢?可以先从场次的信息入手,考虑如下的文档模型:
js
> db.movieSt.find().pretty()
{
"_id" : "1111",
"movie" : "星河舰队",
"office" : "巨幕影厅3",
"showTime" : ISODate("2026-07-23T16:18:10.795Z"),
"seats" : {
"88" : "N",
"89" : "N",
"90" : "N",
"91" : "N",
"92" : "N",
"93" : "N",
"94" : "N",
"95" : "N",
"96" : "N",
"97" : "N",
"10" : "N",
"98" : "N",
"11" : "N",
"99" : "N",
"12" : "N",
"13" : "N",
"14" : "N",
"15" : "N",
"16" : "N",
"17" : "N",
"18" : "N",
"19" : "N",
"1" : "N",
"2" : "N",
"3" : "N",
"4" : "N",
"5" : "N",
"6" : "N",
"7" : "N",
"8" : "N",
"9" : "N",
"20" : "N",
"21" : "N",
"22" : "N",
"23" : "N",
"24" : "N",
"25" : "N",
"26" : "N",
"27" : "N",
"28" : "N",
"29" : "N",
"30" : "N",
"31" : "N",
"32" : "N",
"33" : "N",
"34" : "N",
"35" : "N",
"36" : "N",
"37" : "N",
"38" : "N",
"39" : "N",
"40" : "N",
"41" : "N",
"42" : "N",
"43" : "N",
"44" : "N",
"45" : "N",
"46" : "N",
"47" : "N",
"48" : "N",
"49" : "N",
"50" : "N",
"51" : "N",
"52" : "N",
"53" : "N",
"54" : "N",
"55" : "N",
"56" : "N",
"57" : "N",
"58" : "N",
"59" : "N",
"60" : "N",
"61" : "N",
"62" : "N",
"63" : "N",
"64" : "N",
"65" : "N",
"66" : "N",
"67" : "N",
"68" : "N",
"69" : "N",
"70" : "N",
"71" : "N",
"72" : "N",
"73" : "N",
"74" : "N",
"75" : "N",
"76" : "N",
"77" : "N",
"78" : "N",
"79" : "N",
"80" : "N",
"81" : "N",
"82" : "N",
"83" : "N",
"84" : "N",
"85" : "N",
"86" : "N",
"87" : "N"
},
"_class" : "demo.entity.MovieSt"
}
这里我们大胆使用了一种"预分配"的方式来设计该文档,一个场次的主要信息如下。
- id:场次的ID。
- movie:电影名称。
- office:影厅名称。
- showTime:播放时间。
- seats:座位表。
其中,seats是一个内嵌的子文档,其每一个字段的key就是影厅的座位号。如果影厅有100个座位,那么seats将会有对应的100个字段。而且在一开始安排场次时,seats(座位表)就应该预先写入了。每个座位号对应的默认值是N,代表未被预订的状态,如果已经被预订,则写入新的值Y:{预订用户ID}。
接下来该考虑如何实现预订功能了。显而易见的是,save方法在这里是不可取的,因为当用户user01预订了某个座位时,只更新seats中座位号的值就可以了,而不需要读取或者保存整个文档。这里我们可以使用$set操作符来实现子文档中字段的更新操作,代码实现如下:
java
@Data
@Document(collection = "movieSt")
public class MovieSt {
@Id
private String id;
private String movie;
private String office;
private Instant showTime;
/**
* key: 座位号
* value: 座位详情
*/
private Map<String, String> seats;
}
java
@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");
System.out.println(userId);
System.out.println(movieStId);
System.out.println(seatNo);
//指定子文档的座位号字段
String seatField = "seats." + seatNo;
Query query = new Query();
//条件一:匹配当前场次id
query.addCriteria(Criteria.where("id").is(movieStId));
//条件二:座位号的值为N
query.addCriteria(Criteria.where(seatField).is("N"));
Update update = new Update();
//更新座号的值
update.set("seats." + seatNo, "Y:" + userId);
UpdateResult result = mongoTemplate.updateFirst(query, update, MovieSt.class);
return result.getMatchedCount() > 0;
}
你可能已经注意到了,执行更新的条件并不只有满足场次ID一个,还包含了对于座位号现存值的判断。也就是说只有该场次中指定座位没有被预订时才会成功更新文档。与此前的"get and set"方式相比,这样的做法充分利用了文档级的原子性更新,最终保证同一个场次座位号只能被一个用户成功预订。
另外,为什么seats中座位被预订成功后需要写入状态Y和用户ID呢?可以从以下两个方面思考:
- 预订之后可能还需要生成凭票。如果恰好在预订成功后程序发生了中断,由于文档更新是原子性的,则可以保证预订座位号中会同时写入用户ID,此时根据这个记录可以在后续进行补票处理。
- 在查询座位表的状态时,可以同时知道当前用户是否已经预订了指定的某些座位,给予一定的提醒。
在本案例中,使用座位号(seatNo)的状态(Y|N)作为更新的准入条件,这需要基于特定场景来进行设计。与此同时,由于座位号状态可能存在反复变更,我们很难对文档的真实变更进行跟踪。如果遇到一些更复杂的场景,则建议使用版本号模式来实现乐观锁。
4.2、版本号模式
版本号模式是一种更加通用的CAS模式,其通过在文档中加入一个特殊的版本号字段实现。由于版本会不断自增,因此可以保证不会出现状态回溯问题。Spring DataMongo框架实现了基于版本号的乐观锁模式,代码如下:
java
@Data
public class Person {
@Id
private String id;
private String firstname;
private String lastname;
@Version
private Long version;
}
Person文档中对于version属性添加了@Version属性,即表示该字段将作为当前文档的元数据版本。
对于Person文档的insert、update、delete操作时都会加入版本号的处理。框架会为我们自动检测冲突,代码如下:
java
@Autowired
private MongoTemplate mongoTemplate;
public void testVersion() {
//person.version=0
Person person = mongoTemplate.insert(new Person("1", "aaa", "bbb"));
//tmp.version=0
Person tmp = mongoTemplate.findOne(Query.query(Criteria.where("id").is(person.getId())), Person.class);
person.setLastname("ccc");
//person.version=1
mongoTemplate.save(person);
//tmp.version=0 与 person.version 不同,报错
mongoTemplate.save(tmp);
// org.springframework.dao.OptimisticLockingFailureException: Cannot save entity 1 with version 1 to collection person. Has it been modified meanwhile?
}
- 插入Person文档person,此时version被初始化为0。
- 根据ID将插入的文档查出,此时tmp对象中的version也是0。
- 修改person文档,执行save方法,此时数据库中的文档version产生了自增变为1。
- 再次保存tmp对象(ID和原文档相同),由于tmp对象中的version仍然是0,因此这一步将会报错。
框架在检测冲突时会抛出OptimisticLockingFailureException异常,此时应用可以对该异常采取进一步的措施,例如重试或者记录相关日志。
除了save方法,对于部分字段更新可以使用update操作,该方法同样能从@Version注解中受益。
框架对于@Version注解的字段做了特殊处理,每当执行update操作时,该字段会自动自增。下面的代码清楚地展示了这点:

上述代码取自Spring Data Mongo项目源代码。
5、缓解行锁竞争
尽管MongoDB实现了MVCC的文档级并发控制,但如果是对于单文档的更新,则仍然会遇到高并发的问题。
一个最典型的例子就是计数器,这种需求在大量应用中是很常见的。比如用计数器表示缓存一段时间内的用户浏览量,或者是某网站的单击量等。计数器文档的体积非常小,通常只有一个key和一个整数型value,而且更容易被缓存和快速读取。但唯一的不足是它的更新会受到行锁互斥的影响,从而导致性能的下降。
以物联网应用系统为例,为了统计每一天设备上报的数据总量,我们设计了如下集合:
js
> db.statDailyUpData.find().pretty()
{
"_id" : ObjectId("6a630511f3245915029b4457"),
"count" : 1983,
"timestamp" : ISODate("2026-07-22T00:00:00Z"),
"_class" : "demo.entity.StatDailyUpData"
}
{
"_id" : ObjectId("6a630511f3245915029b4458"),
"count" : 887,
"timestamp" : ISODate("2026-07-23T00:00:00Z"),
"_class" : "demo.entity.StatDailyUpData"
}
{
"_id" : ObjectId("6a630511f3245915029b4459"),
"count" : 2889,
"timestamp" : ISODate("2026-07-24T00:00:00Z"),
"_class" : "demo.entity.StatDailyUpData"
}
每个文档的timestamp字段是一个Instant型,用来标识具体的日期,取值时需要自动对齐到当天的0点。count字段就是具体的数量,设备的每一次数据上报都会触发该字段的更新,使用inc操作符来保证它的原子性。具体更新的操作代码如下所示:
java
@Data
public class StatDailyUpData {
private Integer count;
private Instant timestamp;
}
java
@Autowired
private MongoTemplate mongoTemplate;
public boolean incrCount(int delta) {
//日期取整
Instant startOfUtcDay = Instant.now().truncatedTo(ChronoUnit.DAYS);
Query query = new Query();
//条件:当前日期相同
query.addCriteria(Criteria.where("timestamp").is(startOfUtcDay));
//更新:增加delta计量
Update update = new Update();
update.inc("count", delta);
UpdateResult result = mongoTemplate.upsert(query, update, StatDailyUpData.class);
return result.getMatchedCount() > 0 || result.getMatchedCount() > 0;
}

5.1、为什么大量写冲突会导致CPU飙升
这是由WiredTiger的重试机制导致的,例如对一个文档同时执行100个更新操作,会产生100个MVCC的版本,但只有一个会被成功保留,剩余的99个将会被重试,接下来继续更新,将又会剩下98个进行重试......这个过程会持续到所有操作都成功。因此,对于n 个并发更新会产生指数级的提交任务。尽管WiredTiger在内部对于并发重试任务做了排队方面的优化,但对于应用程序源源不断对单文档产生的并发写行为,这个优化能取得的成效微乎其微。最终仍然避免不了产生大量的提交,这些因素导致了CPU使用率高涨不下。
5.2、优化
如果希望改善这种情形,则可以采用分槽的做法,比如将每天的单个计数器拆分为500个槽位(slot),代码如下:
js
> db.statDailyUpData.find().pretty()
{
"_id" : ObjectId("6a63096ef32459159251449a"),
"count" : 89,
"timestamp" : ISODate("2026-07-24T00:00:00Z"),
"slot" : 1,
"_class" : "demo.entity.StatDailyUpData"
}
{
"_id" : ObjectId("6a63096ef32459159251449b"),
"count" : 123,
"timestamp" : ISODate("2026-07-24T00:00:00Z"),
"slot" : 2,
"_class" : "demo.entity.StatDailyUpData"
}
{
"_id" : ObjectId("6a63096ef32459159251449c"),
"count" : 75,
"timestamp" : ISODate("2026-07-24T00:00:00Z"),
"slot" : 3,
"_class" : "demo.entity.StatDailyUpData"
}
...
每个slot的值都保持唯一,其取值范围从1至500逐个递增。在每次更新时,则随机选择其中一个slot的值进行写入,代码如下:
java
@Data
public class StatDailyUpData {
private Integer count;
private Instant timestamp;
private Integer slot;
}
java
@Autowired
private MongoTemplate mongoTemplate;
public boolean incrCount(int delta) {
//日期取整
Instant startOfUtcDay = Instant.now().truncatedTo(ChronoUnit.DAYS);
//随机选取一个slot值
int slot = new Random().nextInt(500) + 1;
Query query = new Query();
//条件:当前日期相同
query.addCriteria(Criteria.where("timestamp").is(startOfUtcDay));
//条件:slot相同
query.addCriteria(Criteria.where("slot").is(slot));
//更新:增加delta计量
Update update = new Update();
update.inc("count", delta);
UpdateResult result = mongoTemplate.upsert(query, update, StatDailyUpData.class);
return result.getMatchedCount() > 0 || result.getMatchedCount() > 0;
}
这样,我们就将单个文档的写压力分摊到了500个文档上,此时的行锁冲突会降低不少!但这样的读取方式需要做一些变动,即查询一天的计数器值需要对500个slot的值进行累计($sum)计算。当然,如果查询非常频繁,还可以使用定时器将slot的值预先汇聚到汇总表中读取,进一步提高效率。
5.3、举一反三
将计数器进行分槽的做法只能缓解单点的锁竞争,也就是将写入点尽可能分摊得均匀一些,这并不影响整体的写入压力。如果数据库无法承载当前的写入吞吐量,则只能寻求提升硬件性能或是进行分片处理。分槽写入需要存储更多的文档,同时数据的读取变得更加困难。而最终,该方案也带来了一定的复杂度。除了这里提到的做法,计数器还可以参考下面的方法实现:
- 利用定时器对历史数据进行统计,这可以借助聚合功能实现。缺点是需要牺牲一些实时性,而且系统也要确保有能力存储大量历史记录。
- 使用高速缓存服务器,比如Redis。相对来说,Redis的计数器实现更加高效和便捷,这得益于其采用了单线程计算模式,在执行incr命令时并不存在多线程竞争的问题。
6、避免重复数据
某些重复性的数据会干扰系统的运行,严重的情况下还会导致一些难以修复的错误,所以应避免植入重复的业务数据。例如,在影院订票系统中,完成订票环节之后还需要生成对应的票券,这个票券就是我们入场时需要进行检查的凭证。订票流程如图所示。

一般票券会包含相应的验证码,这通常是一个随机生成的串号。除此之外,票券还会记录电影的场次,即什么电影、什么时段、什么影厅,包括座位号信息等,代码如下:
java
@Data
@Document(collection = "movieSt")
public class MovieSt {
@Id
private String id;
private String movie;
private String office;
private Instant showTime;
/**
* key: 座位号
* value: 座位详情
*/
private Map<String, String> seats;
}
java
public interface TicketRepository extends MongoRepository<Ticket, String> {
int countByMovieStAndSeatNo(String movieSt, String seatNo);
}
java
@Service
public class TicketService {
@Autowired
private TicketRepository ticketRepository;
public String createTicket(String userId, String movieSt, String seatNo, Date expireTime) {
Assert.hasLength(userId, "userId required");
Assert.hasLength(movieSt, "movieSt required");
Assert.hasLength(seatNo, "seatNo required");
Assert.notNull(expireTime, "expireTime required");
//判断是否存在重复的票据
if (ticketRepository.countByMovieStAndSeatNo(movieSt, seatNo) > 0) {
return null;
}
Ticket ticket = new Ticket();
ticket.setUserId(userId);
ticket.setMovieSt(movieSt);
ticket.setSeatNo(seatNo);
ticket.setVerifyCode(new Random().nextInt(999999) + "");//可能重复
ticket.setExpireAt(expireTime);
ticket = ticketRepository.save(ticket);
if (ticket != null) {
return ticket.getId();
}
return null;
}
}
js
> db.ticket.find().pretty()
{
"_id" : ObjectId("6a6313abf3245916b024c051"),
"movieSt" : "1",
"userId" : "1",
"verifyCode" : "614430",
"seatNo" : "1",
"expireAt" : ISODate("2026-07-24T07:26:35.316Z"),
"_class" : "demo.entity.Ticket"
}
{
"_id" : ObjectId("6a6313b1f3245916b024c052"),
"movieSt" : "1",
"userId" : "1",
"verifyCode" : "683760",
"seatNo" : "2",
"expireAt" : ISODate("2026-07-24T07:26:41.706Z"),
"_class" : "demo.entity.Ticket"
}
上述代码中,先是通过ticketRepository.countByMovieStAndSeatNo方法进行了判重,在检查无误后,才进行Ticket的生成操作。其中,makeVerifyCode方法会生成6位随机数字串号,作为Ticket对象的verifyCode字段传入。
让我们继续考虑唯一性的问题,根据票券的业务特点,其唯一性应该至少包含两点:
- 票券验证码必须是唯一的,这是因为需要避免一个场次的不同用户拿到了同样的验证码。
- 对于同一个场次的同一个座位来说,最多只能产生一个唯一的票券,否则就会出现多余的凭票。
对于第(1)点来说,尽管使用了随机生成的验证码,但仍然无法保证不出现重复的情况。而第(2)点在前面的createTicket方法实现中已经考虑到了,但问题在于count和save方法并非同一个原子性动作,因此仍然可能会写入重复的数据。
无论如何,我们必须通过建立唯一性索引来达到不产生重复数据的目的。在Ticket类中声明索引如下:
java
@Data
@Document(collection = "ticket")
@CompoundIndexes({
@CompoundIndex(name = "idx_movieSt_seatNo", def = "{movieSt: 1, seatNo: 1}", unique = true),
@CompoundIndex(name = "idx_movieSt_verifyCode", def = "{movieSt: 1, verifyCode: 1}", unique = true)
})
public class Ticket {
...
此后,在生成Ticket时,如果数据库检测到了重复的记录,则会抛出DuplicateKeyException,我们可以对其进行捕获:
java
...
try {
ticket = ticketRepository.save(ticket);
} catch(DuplicateKeyException e) {
log.error("there's some thing duplicate...", e);
return null;
}
...
7、那些影响并发的操作
最后,我们来关注一些可能会影响并发性能的命令。这里所指的是,一些管理性质的命令实质上会对数据库集合,甚至整个库产生锁,在不经意间影响正常的业务操作。
- 创建索引,createIndexes命令可以用于创建多个索引。整个命令的执行需要使用临时内存,这部分内存会由MongoDB额外向操作系统申请。一个createIndexes命令可用的最大内存默认是500MB,如果超过了这个值就会开始使用磁盘空间交换,所以这里可能会出现性能拐点。在MongoDB 4.0以及之前的版本中,索引的创建默认会使用前台(foreground)模式,这会对整个数据库产生一个全局的排它锁(X),从而阻碍正常的业务。因此,建议创建索引应准确使用background模式。这个问题在MongoDB 4.2版本中有所改进,索引创建不再区分模式,而是仅仅在创建的一开始和最后产生短暂的排它锁,而且目标对象也改成了当前的集合。
对超大集合建立索引的过程可能是缓慢的,这是因为创建索引时需要扫描全部的文档,而且一些常规的业务操作会使得建立索引的工作线程出现让步等待而进一步延缓。考虑到对性能的影响,在副本集执行大集合的索引创建时可以采取滚动操作的方式。
-
删除索引,dropIndexes命令在执行时同样会产生库级的排它锁。在MongoDB 4.2版本中调整为集合的排它锁。
-
查询全部集合,listCollections会对数据库产生一个意向读锁(IS)。需要特别注意的是,在MongoDB 4.0版本以前,这个命令对数据库产生的是一个共享锁(S),由于共享锁与意向写锁(IX)是互斥的,所以这会影响数据库中数据的写操作。
-
重命名集合,renameCollection会产生集合的排它锁,但rename操作的时间一般较短。
-
一些全局性的维护操作,如db.copyDatabase、db.collection.reIndex操作会导致所有数据库都被锁住,直到操作完成才能释放锁。