这是一篇公司组内分享文稿,其中的「聚合表」是指的一种支持跨表校验的实时多表物化视图功能;「聚合计算」是指的是聚合表的一种封状态;「RWLock」 是内部封装的一个基于 Redis的多粒度锁组件(X\S\IX\IS).
TL;DR
复杂业务的并发控制是一个多方面的问题,需要从多个角度进行综合考虑。通过合理设计并发控制机制,可以确保系统在高并发情况下的数据一致性和操作正确性,提高系统的可靠性和稳定性。
1. 明确并发参与者
首先,必须明确可能会互相影响的事务性操作,即并发参与者。一个常见的陷阱是没有划分到合适的粒度。以聚合计算为例,聚合计算配置内嵌在表单元数据里,表单配置保存可能触发聚合计算配置的修改。表单保存对于表单设计业务自己来说是一个操作,但对于聚合计算业务领域来说,需要细分为:
- 保存-编辑/新增聚合计算 → 类比于新增编辑聚合表配置
- 保存-删除所有聚合计算 → 类比于删除一张聚合表
这两个参与者的加锁类型是不同的,需要分别处理。
2. 明确需要的资源及行为
需要标明所有资源,并区分其读/写行为,如果资源太多、依赖关系太复杂,大脑无法整个加载,可以用 OmniGraffle、ProcessOn 之类的工具绘制一个资源拓扑图帮助自己理解。
加锁的目的是控制共享资源的读写一致性,保证在锁定资源后,读写操作之间不会发生一致性问题,常见的一致性问题有 (Copy From DDIA):
- 脏读(Dirty Read):一个事务在读数据时,另一个事务正在修改该数据,导致读到的数据是未提交的临时数据 → 解决方案:在读取数据前对数据加读锁,确保读取过程中数据不会被其他事务修改。
- 不可重复读(Non-Repeatable Read):一个事务在读取同一数据两次时,两次读取的数据不同,因为在两次读取之间另一个事务修改并提交了该数据。 → 解决方案:在读取数据前对数据加读锁,直到当前事务完成,防止其他事务修改数据。
- 幻读(Phantom Read):一个事务在读取某范围的数据时,另一个事务在该范围内插入了新数据,导致第一次读取后再读取时,结果集不同。→ 解决方案:对读取的数据范围加锁,确保在事务执行期间,数据范围内不会有新的数据插入或删除。
- 写偏差(Write Skew):两个事务在读取相同的数据集并基于这些数据进行写操作,导致数据不一致。→ 解决方案:在读取和写入操作期间加写锁,确保其他事务不能同时进行相同的数据操作
- 丢失更新(Lost Update):两个事务同时读取相同的数据并进行修改,后提交的事务覆盖了先提交的事务的修改,导致先前的修改丢失。 → 解决方案:读取和修改数据期间加写锁,确保在当前事务提交前,其他事务不能修改相同的数据。
- 死锁(Deadlock):两个或多个事务在等待彼此持有的锁,导致无法继续执行。目前比较容易出现的死锁可能是“自死锁”:自己加了个 S 锁,后面又对同一个 key 加了 X 锁,或者 X 锁重入。→ 解决方案:通过设计锁的获取顺序和避免锁的嵌套,尽量减少死锁的发生。此外,可以使用死锁检测和恢复机制,检测到死锁后主动回滚其中一个事务。
绘制拓扑图有助于降低大脑功耗,理清资源读写动作的依赖关系,便于确定加锁的时机和方式。
3. 设计锁的种类和粒度
设计锁的种类和粒度是并发控制的关键。
首先,如果两个操作强互斥(例如单张表的聚合计算配置保存和全量操作;或者同一张表的多个全量操作之间),可以考虑在一开始加一个表锁,这样可以避免后续复杂的资源操作互斥关系处理。
然后,锁的粒度可以有很多种划分,比如:字段级别、行级别、页级别、表级别等。根据并发度决定合适的划分:
- 高并发读写操作:使用行级锁或字段级锁,最大限度提高并发度。不那么敏感的业务,一般优先用乐观锁。
- 低并发批量操作:使用表级锁,简化管理,提高效率。
一般中小并发度下,用行级别和表级别两个划分就够了。
4. 明确领域
在设计增量和全量操作时,需要明确领域。例如聚合计算增量操作除了加配置相关的锁,还要加数据锁。数据锁相对独立,与全量和配置保存关系不大,可以分开思考。
5. 考虑操作频率
考虑参与者发生的频率和成本。在聚合计算中,增量操作频率远高于全量操作和配置保存。因此,在加锁时,低频操作可以加更多的锁,而高频操作加更少的锁,减少系统整体的均摊成本。
例如一种比较好的设计是,增量操作只需加当前表单的聚合计算配置锁,而全量操作和删除聚合计算需要加所有涉及表单的聚合计算配置锁。
6. 枚举中断点和确保有恢复机制
需要考虑中断点(如宕机/锁丢失)及其可恢复性。由于 MongoDB 不支持数据库事务,可以假设流程中每个地方都可能发生宕机,并考虑是否会影响一致性及恢复方案。对于聚合计算/聚合表,宕机导致的一致性错误通常可以通过发起一次全量操作解决。
7. 防御性检查
防御性检查是必要的,以避免其他代码的不严谨对业务一致性造成影响。例如,(1)当前数据锁和表锁加锁不规范、(2)老聚合表可能出现入库成功后获取锁失败的 bug(这个时候聚合计算需要继续执行增量,不让 bug 蔓延),(3)关联关系配置可能不一致、(4)表单配置保存可能存在脏读问题等。需要分别考虑并设计防御措施。
8. 熟悉你所用的轮子
这里主要是考虑所依赖的锁工具是否支持锁重入、锁续期等。目前,系统的RWLock不支持锁重入和锁续期,因此需要特殊逻辑记录并排除已加过的锁,并根据业务预估耗时调整锁的超时时间。
9. 真正的TL;DR:并发度考虑(逃
考虑当前系统的QPS情况。例如简道云 QPS 正常场景下没有那么高,也许设计上可以允许不那么完美的方案。
绝对完美的方案一般会有性能损耗,需要在一致性和性能之间找到平衡。