互斥锁在实现并发场景下业务操作的原子性以及解决互斥访问问题方面,是极为有效的手段之一。因其使用方式相对简单且安全,所以在互联网的分布式系统以及嵌入式并发场景中都有着广泛的应用。
然而,若互斥锁的选择与使用不当,极有可能成为系统性能的瓶颈之一。因此,对互斥锁进行合理优化,是系统性能优化的重要途径。我将为你分享一个互联网场景下互斥锁优化的案例。按照优化前的软件实现、性能瓶颈分析以及优化解决方案的思路,带你深入剖析我是如何优化业务中的互斥锁,以及如何将业务的 RPS(Requests per second,请求吞吐量)性能指标提升 10 倍以上的。
在这个案例中够全面了解分析与优化互斥锁的详细过程,从而在业务中准确识别出哪些场景下的互斥锁可以优化,哪些场景下不可以。此外,你还将掌握一种手动实现事务的机制(支持业务操作回滚机制),以此替代业务中的互斥锁,进一步优化软件性能。
接下来,我们先看看该案例中业务优化前的实现情况以及存在的性能问题。
优化前的业务实现为什么会有性能问题?
这个性能优化案例的业务场景如下:用户向在线表单提交一条记录,该记录包含众多字段内容。其中,部分字段在插入时有一个规则要求,即不能与已有的字段值重复。为了便于理解,这里我用一张图来描述原业务系统中实现字段插入值不重复规则的实现逻辑,具体情况如下所示。
图片
可见,在该业务中使用了一个 Redis 锁来实现互斥访问,从而实现了被加锁的业务逻辑执行的原子性,所以这部分计算逻辑在系统中是串行执行的。而被加锁的业务逻辑主要有三个关键操作,分别是:
一、字段不重复检测
对插入的字段值进行检测,查看其在数据库中是否有重复情况。若出现重复值,则插入失败并直接退出;若未出现重复值,则执行下一步操作。在这个过程中,系统会遍历所有要求值不能重复的字段项,只要其中任何一个字段项出现值重复,就会退出。
二、其他操作
即用户提交记录过程中的一些关键业务操作。这些操作具有不能被拆分执行且不能被回滚的特点。若操作成功,则执行下一步操作;否则,也会直接退出。
三、所有字段插入
由于上述三个操作通过加锁保证了原子性执行,所以前面检测的 “字段值不重复” 的条件仍然有效。在这一步,会将有的字段进行插入。
除此之外,在优化前的代码实现中,需要进行重复性校验的字段都会记录在 Redis 中。所以,图中的操作 1、操作 3 都是基于 Redis 来实现的。
在看完这个业务实现逻辑图后,你或许会感到好奇:这种字段唯一性检测机制为何不使用关系数据库中的字段唯一性检测机制来实现呢?这确实是个好问题,我在刚看到这个业务逻辑实现时也同样好奇,后来深入分析业务后才理解其如此实现的原因。
实际上,在这个业务系统中大约有 1000 万张表单,且每张表单的字段唯一性规则可能各不相同,用户还能随意修改这个规则。所以,该系统在设计实现时,将所有表单中的所有字段都放到了一张很大的数据库表中,因此无法使用数据库表上的字段唯一性规则来处理这个问题。
原来的 Redis 加锁实现方式较为简单,且是按照单个表单来进行加锁的,所以在单个表单并发提交请求吞吐量不是很大的情况下,不会对系统性能产生太大影响。
然而,随着系统业务规模逐渐增大,会出现少量表单的并发请求吞吐量暴增的情况。此时,当单个表单提交请求超过并发请求吞吐量的上限值后,就会引发两个较为严重的性能问题:
其一,针对超过并发请求吞吐量性能上限值的那个表单,用户在提交表单的页面会出现卡死现象,导致提交数据失败;
其二,由于后端服务系统是基于进程模型的,而进程资源的数目有限,一旦个别表单提交数据请求的处理进程被阻塞,占用大量进程资源,就会导致整个系统无法正常处理所有的业务请求。
因此,提升单个表单提交请求吞吐量的性能指标,就成为了这个软件系统性能优化的关键问题。那么接下来,我们就要先搞明白,这个互斥锁是如何影响这个表单的请求吞吐量性能的。
互斥锁是如何影响最大请求吞吐量的?
接下来,我就使用一个公式来描述下在这个案例中,使用了 Redis 互斥锁以后,来计算 Max RPS(最大请求吞吐量)的计算方法,具体公式如下所示:
图片
在这个公式中,由于 Lock 和 Unlock 是通过 Redis 的互斥锁来实现的,其使用的 Redis 的 script 脚本实现如图所示。经在真实系统中测量,Lock time 与 Unlock time 的操作时间之和约为 3ms。接着,可通过上面的公式进行计算。若中间加锁的计算逻辑(resource competition)执行开销约为 30ms,那么对应的 Max RPS = 1s / (3ms + 30ms),即大约为 30RPS 左右。也就是说,只有当把加锁的计算逻辑降低极限值为 0 时,对应的 Max RPS 才可以达到 300RPS 左右。这里需要注意的是,因为业务中的互斥锁是全局控制的,所以当系统达到最大 RPS 时,即便通过弹性扩展机制部署再多的后端服务实例进程,也无法再提升这个性能指标了。
至此,在这个性能优化案例中,我们经过测量得知加锁的计算逻辑执行时间为 30RPS,然后根据上面的公式,计算出的最大 RPS 值也约为 30RPS 左右,这与真实的性能测试获取的性能指标值完全一致。
好的,现在问题已经比较清楚了。那么,有没有办法可以优化提升这个系统的性能呢?下面我们来看一下。
性能优化解决方案
果这个业务逻辑没有增加互斥锁,在 99.9% 的情况下业务逻辑也是正确的。
所以,针对这种场景,我们可以采用手动实现事务机制,优化掉业务代码中的互斥锁,以提升请求吞吐量的性能。
我们已知在这个案例中,使用互斥锁解决的核心问题是判断字段不重复和字段插入操作的原子性问题。因此,我们可以考虑采用一些优化机制,单独实现这两个操作组合的原子性。
但要注意,如果在互斥锁的使用场景中,被加锁的业务操作还有更复杂的一致性要求,比如存在数据库写冲突的问题等,那么这种互斥锁实现就不能被简单地优化掉了。
那么对于这个案例中的互斥锁而言,我们应该怎样优化呢?
我来说说我想到的优化思路。这里呢,为了更清晰地描述该解决方案,我用了一个流程图来给你详细地介绍下性能优化后的具体实现过程,如下图所示。
图片
也就是说,我们可以将 “字段不重复检测”“单个字段插入”“其他操作” 这三个操作绑定在一起,实现一种事务机制的能力,以便在后面操作失败的情况下,能够回滚到前面的操作中。实际上,原来的 Redis 互斥锁主要是为了实现 “字段不重复检测” 和 “字段的插入操作” 的原子性。而在手动实现事务机制之后,我们可以把这两步操作放到开始处执行,然后使用 Redis 的 Pipeline 机制保证这两步操作组合的原子性,从而不会被其他 Redis 操作干扰。
这样,对于接下来的其他操作(即用户提交数据过程中的一些不可拆分的关键业务操作),如果操作成功,就提交任务成功结束;如果操作失败,则需要回滚之前的字段插入操作。另外,为了实现事务的机制和能力,我们还需要在前面字段插入时,同时记录插入前的状态和插入后的变更状态,从而实现失败后的回滚机制。
其实,这里我还考虑过另外两种实现方案,分别是基于 Redis 的事务机制和基于 MongoDB 上的事务机制。但是,我最后在实现时并没有采纳,这背后有很多原因。比如,使用 MongoDB 的事务需要进行数据迁移,而且需要升级系统的 MongoDB 集群的数据库版本等。以及使用 Redis 事务机制的代码实现并不友好等等。不过,这里有一个最重要的原因就是,不管是使用 Redis 事务还是 MongoDB 上的事务,它们都把对字段插入操作的冲突时间,拉长到了步骤 3 “其他操作” 结束之后,而这样就显著增大了事务冲突失败的概率。
所以最后,我们采用前面这种优化后的实现机制。因为去除了互斥锁,所以用户间的提交记录可以更大程度地并行。而且优化后的实现方式,只有 Pipeline 操作会排队处理,而由于单个 Pipeline 的执行时长在 1ms - 3ms 之间,所以最后优化后的表单最大请求吞吐量,就从原来的 30RPS,提升到了 300RPS 左右,这样就实现了性能提升超过 10 倍的目标。