说说唯一ID与CAS 元一软件
一、从数据的唯一标识开讲数据区分与标识表现数据和算法组成了我们现有的应用软件当然互联网应用也不例外。为了区分应用系统收集和运行所必要的这些数据我们通过各种方法来组织其存储形式方便其为我们所用。从数据结构、文件、到专业数据库等工具无一不是方便数据存储和访问的利器。但无论如何我们对数据存储都要通过唯一的标识来对其进行区分以确保我们根据这个标识来定位到它。在不同的系统中这个标识的表现也各不相同在编程语言中它表现为变量名称、常量名称等在文件系统中它表现为目录以及目录下的文件名等在数据库表中它表现为库名、表名、主键或唯一索引在网络通信中它表现为IP地址、MAC地址等在计算机内存中它表现为物理内存地址等。标识的生成与组织客观现实要求我们必须要做每一份数据的唯一性区分在数据量少的时候我们尚可以简单的命名方式来实现但是当存在海量数据的时候我们若想要将其区分标记出来除了命名还要相应地进行组织结构的变更来降低命名的复杂度否则海量杂乱的数据名称会加大我们管理和获取的难度。最常见的几种区分方式通常有如下几类全随机可读性差文字组合其实也可以当成一种全随机的特殊情况例如UUID结构示例如下6B29FC40-CA47-1067-B31D-00DD010662DA该方式经常用于小范围数据区分该方式通常不会单独使用一般会结合树形结构来实现一些目录区分顺序递增的数值结构该方式形式简单索引方便例如数据库的自增主键、计算机内存的物理地址、Excel表的序号树形结构区分组织方式方便直观便于索引例如文件目录结构、关系型数据库数据组织方式、编程语言的多层结构体分布式ID生成方式例如雪花算法该算法的分段标记其实有点树形结构的结合但其增长方式又有数值的使用便利其他以上方式的结合很多其他的唯一性区分通常都表现成以上方式的结合例如URLURL的域名以及Path本身就存在树形结构的引子虽然其本身指向的资源存储不一定是该方式Path中的每一小段都是区分性命名世界范围内看都是随机和不确定的。几种形式标识的结构表现图例全部随机形式以及递增数值树形结构区分的目录分布式ID之雪花算法的二进制结构实际转换为10进制就是个长串的数值标识唯一性保证与核验虽然我们已经有了唯一性生成的方式人工确认数据的唯一性是一方面但是在我们的软件系统中存储唯一性数据的时候如何保证其唯一性呢想必大家在使用计算机文件系统存储文件的时候会发现在文件改名时如果当前文件的新名字和同一目录下存在的文件名称冲突那么系统可能会给出冲突提示这是一种前置检测。而且在数据库表设置了唯一索引的时候如果唯一索引字段存在冲突那么系统也会给出相应的提示。另外一些情况下不同的软件系统通过自身的规则设计保证了其生成的数据唯一性例如数据库自增主键。全局分布式ID生成算法中的雪花算法一般也保证其生成数据的唯一性但是在极端情况下却也可能存在冲突。一些软件唯一值冲突提示信息展示文件系统命名冲突数据库唯一索引冲突编程语言变量重复命名以上的例子其实提示了我们在使用唯一标识生成的时候一定要确认该标识是否在你的系统中能保证唯一如果不能那么有可能存在无法预期的风险。这些风险需要我们提前识别并预设方案来解决例如同一个文件夹下如果来了重名文件是要选择丢弃操作还是进行文件覆盖唯一ID重复导致数据写入失败是要丢弃数据还是通过其他方法来补偿笔者曾经遇到过这样的场景历史项目中使用到了一个封装的随机字符串生成库该库在低并发下生成的字符串无异常但是在高并发状态下其生成结果有重复项。而生成的目标字符在使用时未做唯一性校验这就导致了一些异常的发生。根据如上分析我们需要通过如下两个方式来处理这个问题切换离散性更高的的唯一字符串生成方式这个可以通过UUID算法来实现增加唯一性校验理论上在UUID算法下几乎不会出现重复但在防御性编程的考量下我们依然引入校验做双重保障。二、唯一索引到分布式锁唯一索引的业务契合度在未说明更详细信息的情况下一定有人会问既然你的数据要入库为什么不使用唯一索引来保证数据不会冲突呢通常情况下如果数据满足了全局唯一这个条件我们确实可以使用唯一索引在保证数据入库数据关键字段唯一但还存在一些例外的业务场景。例如数据字符较长同时又不作为索引字段。应用数据软删除的要求系统中可能存在某个唯一字段的多条数据。这种情况下就不得不放弃数据库的校验将数据唯一性检验的过程纳入到自己的系统中。前置校验的方式选择我们考虑到要在系统中加入唯一性校验那么在数据INSERT场景下首先就要查询数据库以判断其是否存在不存在再写入。到了这一步有以下几个问题需要考虑这一步一定要查询数据库才能确认是否存在重复吗你确定自己要查询的这个字段是索引字段吗如果不是查询性能太差要怎么办你查询数据库就一定能保证不重复吗高并发下检测存在并发与时差怎么处理这里我们拆开来一个一个地讲是否必须查询数据库验证数据库作为我们最终数据的存储载体它所承载的数据总体来说体量是比较大的即便我们有索引但在索引查询以及查询数据库时候的网络交互开销一直不低。所以我们是否必须要从数据库来查询这些内容存在一定的可选择性。我们可以适当地通过唯一标识生成的规则来做一些基本的数据隔离。例如以时间段作为前缀再追加上随机字符来区分数据这样的标识自带时间区分度我们只要在这个前缀的时间粒度上保证唯一那么就可以确认整个数据在整个系统的唯一性。随机数据的生成通常习惯于用系统时间作为种子值所以高并发下的冲突不能依赖前缀来解决。时间的颗粒度按照数据的量来确认通常需要自行平衡其同级以及下级的数据量。例如如下的一串字符串 “foo_bar_20240616_randStr”我们以foo、bar作为一二级前缀时间日期作为第三级前缀在这种情况下我们可以不用关注历史数据的情况直接校验单日维度的数据是否有重复即可如果怕并发时间差影响可以适当扩充延长校验的范围。多级前缀或者类树形数据隔离后需要校验的数据量漏斗再比如UUID算法根据理论分析UUID版本4随机生成的UUID的冲突概率可以被认为是忽略不计的。这种情况下我们可以根据业务的需求来确认是否需要前置校验。由此我们知道只要我们确认数据重复性的可能是不存在的或者有其他更简洁的替代方法那么我们确实不需要去数据库查询核验。非索引字段怎么验证处理在不确认数据唯一性的情况下或者查询数据库不是最合适的解决方案的情况下我们该用什么方法来解决这个问题呢通常选择的一种方法是增加分布式锁来进行校验。依然针对我们提到的字符串“foo_bar_20240616_randStr”经过前缀隔离之后我们判断下来只要校验按日维度的数据不存在重复即可。这样我们选择分布式锁的时候需要保证锁的时间维度在一天或者稍多就能满足需求。高并发下如何保证正确性如果我们确实要走数据库查询核验那么在高并发场景下查询到存储的间隙有其他进程或线程触发同样的逻辑造成冲突了怎么办其他机器上部署的业务也同样触发相同的逻辑又该如何这种情况下我们自然而然地也想到了分布式锁。内存级分布式锁工具的高性能可以弥补直接查询数据库判断比对的处理时间差。分布式锁的必要性我们知道编程语言中也到处有锁的身影例如Go语言的互斥锁、读写锁但是其作用是在单应用进程内提供的协程并发访问互斥机制无法作用到多进程或者多节点部署的情况。而分布式锁正好可以帮助多节点部署的业务服务来解决并发访问共享资源时的一致性问题。三、锁的共性问题分布式锁的正确性保障相信对分布式锁感兴趣的小伙伴或多或少都知道常用的两种分布式锁应用方式Redis、Zookeeper。通常使用分布式锁时候要考量如下的问题加锁成功之后后续对该锁的所有操作能否确认该锁是当前线程持有当前线程释放锁是否有可能释放了不是本线程持有的锁我加了锁锁的时间不够我业务执行后面我再操作锁这个锁还是本线程的锁吗加锁的时间问题当前线程持有的锁时间内任务还没完成锁就过期了该怎么办当前线程持有的锁还有很长时间才自动释放但是任务已经结束并发上不来阻塞了怎么办锁的释放问题你确定当前线程加的锁在程序正确执行或者异常之后都释放了吗你如何知道Redis或者Zookeeper给你保证的锁是唯一的呢Redis的单线程模型保证锁操作的正确性如果你的Redis是主从版或者集群版又正好节点异常切换了呢Zookeeper的强CP原则牺牲了A可用性但是也可能存在宕机等问题如何处理针对业务上的前面几个问题我们通常可以通过下面的几个方法来解决但依然存在局限性。对于最常见的基于Redis实现的分布式锁获取锁的时候给锁value设置随机唯一标识该标识可以用来判断接下来的锁持有方给锁一个合适的初始有效时长并在锁即将到期的时候续期在程序执行的正常区间和异常区间都要释放锁如果有自旋场景那么一定要给自旋场景一个最大过期时间防止死锁对于每一个操作最好使用lua脚本实现操作的原子性针对集群版或者主从版本的Redis需要根据业务权衡是否需要采用redlock或者单节点的Redis来作为锁载体。redlock算法较重而且极端情况下也可能存在问题单节点显然存在单点问题但是肯定可以保证强一致性。而基于Zookeeper实现的分布式锁Zookeeper基于其临时有序节点的“申请-未获取-监听-申请-获取-操作-释放”的循环来实现分布式锁因为临时节点的释放是基于会话级别的所以在会话关闭或者异常中断的情况下也都会自动释放所以避免了死锁的问题心跳的存在我们也不用手动续期。Redis使用自身单线程操作内存的机制、Zookeeper使用ZAB协议其实都存在一些共同点它们的共同点就在于要保证我设置的key或者节点的次序是首次。它们要保证的是无论你的业务系统分布式程度多高基于它们所获取到锁数据都是唯一的和最先的。以上我们讲了那么多其实都绕不开一个概念那就是多个线程的访问经过层层传递收缩最终都指向到同一份数据上或者同一个数据标识上因为对于分布式缓存而言数据可能存有多份并通过半数以上同意的协议形式来确定其一致性。业务线程锁逻辑访问收缩示例能支持分布式锁的不只有Redis和Zookeeper。理论上其他满足CAP理论中CP一致性和分区容忍性的分布式系统在一定程度上都能满足支持分布式锁的条件。数据库主键唯一性保障在MySQL数据库中我们知道主键一定是唯一的唯一索引却不一定是主键。虽然上面提到的场景我们引入了分布式锁来保障数据的唯一性但是MySQL自身的自增主键机制也是我们所离不开的。在MySQL中自增主键通过“AUTO_INCREMENT控制”的机制来确保主键值的唯一性和自增AUTO_INCREMENT控制主要通过数据库的内部机制来实现。具体来说当表中的某个列被指定为AUTO_INCREMENT主键时MySQL会自动维护一个用于该列的自增计数器并确保每次对表的插入操作都会使这个计数器递增。具体实现的方式包括以下几个方面内部计数器MySQL内部会维护一个计数器用来记录下一个可用的自增值。这个计数器通常保存在系统表中跟踪每个表以及每个表的AUTO_INCREMENT列的当前值。锁机制在插入新记录时MySQL会使用锁机制来确保自增值的唯一性。在插入操作之前会对计数器或相关数据进行锁定以避免多个客户端同时尝试获取相同的自增值。事务支持对于使用事务的情况MySQL也会确保在事务失败时例如回滚已分配的自增值不会被使用以维护自增值的连续性和唯一性。总的来说MySQL的AUTO_INCREMENT控制主要依靠内部的自增计数器和相应的锁机制来实现。这种机制有效地确保了主键的自增属性并保证了主键的唯一性。进程内协同之一互斥以上说到的是分布式锁但是在单机系统中也存在不同线程或协程数据交互与执行互斥的问题。例如操作系统多应用互访、单进程应用配置数据的多线程访问和变更、下游访问的并发抑制操作等。编程语言给我们提供了一系列进程内协同的方式同步、互斥与通信。这里的进程内互斥体其实也就是称为锁。Go语言提供的互斥锁功能其底层依赖有如下一些机制原子操作原子操作是CPU提供的功能由CPU保证执行的原子性Go语言互斥锁所依赖的主要原子操作是CAS Compare and Swap 自旋模式与阻塞模式自旋CAS 、阻塞/唤醒双模式协程与锁的多对一状态锁的自旋等待显得尤为必要但对于长CPU时间片的操作自旋等待过程所消耗的资源其实也不低Go语言通过自旋模式到阻塞模式的切换来缓解锁竞争激烈场景下的CPU消耗均衡调度普通模式普通模式下老协程相比新的协程在正常锁竞争下缺乏优势可能导致长时间无法获得执行权限饥饿模式饥饿模式会使未得到执行的协程获得更高的执行优先级以摆脱长时间获取不到锁无法执行的困境其他的编程语言一般也提供相应的锁机制来保证系统中多线程执行互斥的问题。分布式锁与进程内锁的共性从上面的信息我们看的出来无论是分布式锁还是进程内的互斥锁都存在下面的一些共性使用唯一标识来识别锁的当前归属通常使用CAS方式来实现变更操作的原子性要考虑获得锁的失败自旋以及时间问题根据需要来确定是自旋等待还是失败结束。唯一标识与CAS的联系编程语言提供的互斥锁功能在底层上依赖CPU提供的原子操作功能CAS 。我们在使用Redis分布式锁的时候如果使用lua脚本其比对该锁的value是否是本线程持有的时候也有个比对后再更改的过程。Zookeeper实现分布式锁的时候我们也是获取到临时顺序节点的最小序号有个比对过程才能确定获取到锁。另外在数据库数据更新的时候我们也经常用该方式保证数据变更条件的准确性。当然CAS这个操作概念其Ccompare所依赖的依然是某个数值在当前场景下的唯一性。这也是我为什么从数据唯一性引申到这里的原因而且CAS也是我在这里要重点说的思想。看到这里不知道大家有没有发现这里存在一个很有意思的情况数据唯一性检验可以使用锁来解决而锁的获取又依赖一个小范围的唯一数据所以这也是一个将大范围唯一性校验通过一定方式转换为小范围唯一性校验的方法。而小范围唯一性的标记甚至可以深入简化到底层二进制的0和1两个状态来完成。四、同类场景延伸分布式ID生成原理我们在前面提到了唯一标识的生成组织方式其中分布式ID生成算法之一的雪花算法就使用了时间前缀区分、分片标识、节点数据自增的方式将大范围唯一标识生成缩小成小范围的数据自增还兼顾了按时间增长与高并发等性能优势。常用的分布式ID生成算法各有其特点雪花算法雪花算法是Twitter开源的分布式ID生成算法它可以在分布式系统中生成全局唯一的、递增有序的ID。Snowflake算法的ID由时间戳、机器ID和序列号组成。数据库自增ID在分布式系统中可以使用单独的数据库服务器生成自增ID。不同的服务器会有不同的起始值和步长从而避免冲突。使用Redis的INCR命令Redis的INCR命令可以用来原子递增一个key的值因此可以利用这个特性来生成全局唯一ID。但是无论使用哪种方案都需要考虑ID的唯一性、性能、可用性以及分布式环境下的并发安全性选择合适的方案应根据具体需求以及系统架构来进行权衡和决策。接口幂等与MQ消费幂等业务数据接口幂等操作与消费队列消费的幂等操作幂等性保证其实是一样的原理。幂等性是指针对同一个操作多次执行的结果和一次执行的结果是一致的。在计算机科学和网络编程中幂等性是一个重要的概念特别是在分布式系统和网络通信中。针对幂等性处理我们要记住的唯一思想其实也是CAS。对于存在数据变动的场景CAS的原则可以保证数据只在指定条件下才发生变化。以下几种保证MQ消费幂等的方式中CAS的思想其实是一致的基于数据库中唯一ID以及某个状态的值做前置判断符合条件才执行使用消息ID作为Redis分布式锁的key来判断当前消息是否消费成功使用消息ID入库以及成功后状态变更来判断消息消费是否已经执行过。操作系统进程间通信与互斥对于进程间协同来说主流操作系统支持了锁Mutex和信号量Semaphore。Windows还额外支持了事件Event同步原语。进程间的锁Mutex语义上和进程内没有什么区别只不过标识互斥资源的方法不同。Windows最简单用名称Name标识资源iOS用路径PathLinux 则用共享内存。从使用接口看Windows和iOS更为合理虽然大家背后实现上可能都是基于共享内存对用户进程来说操作系统内核对象都是共享的但是没必要把实现机理暴露给用户。CAS原理的其他应用场景无锁数据结构CAS原理通常与无锁数据结构结合使用以实现高效的并发数据访问。由于CAS原子操作的特性可以在不使用锁的情况下对共享数据进行操作从而提高并发性能。分布式系统CAS原理的思想可以应用于分布式系统中的数据同步和一致性问题。在分布式环境中类似CAS的原子操作除了可以用于实现分布式锁还可以用来实现分布式事务以及一致性算法确保全局状态的一致性和可靠性。数据库事务在数据库系统中CAS原理的思想可以用于乐观锁和并发控制。通过比较数据版本或标记位并进行更新的原子操作实现数据库事务的并发控制和一致性。业务流程控制CAS原理的思想也可以应用于业务系统中的流程控制例如利用乐观锁的机制来处理并发访问下的业务逻辑一致性问题。上面提到的幂等实现其实也是该大类下的五、总结唯一标识与CAS与多对一模型到这里我们发现上面提到的基于唯一标识与CAS原理来解决问题的方式其本质都是多对一模型下实现多线程同步互斥以及并发收缩问题的基础依赖。所以我们遇到的多对少或者多对一模型下的数据访问或修改的收缩问题其实都可以通过类似的思想来尝试寻找解决方案。学会抽象归纳唯一ID与CAS这两个点乍一看好像并无联系但是经过关联梳理我们依然能发现它们在多对一模型下的关联。当我们在工作学习中遇到的比较相似的问题多了只要愿意深入思考就能发现其中的共性并且总结提炼出来在下一次遇到类似的情况时自然而然就能想到它。熟能生巧之外我们也可以主动使用一些通用的思路和方法配合工具来提升自己的抽象归纳能力。具体来说抽象归纳有以下思路和方法观察和辨识共性、提炼概念、泛化思维、归纳推理、应用验证等可以使用的工具则有思维导图、数据分析工具、逻辑推理工具或游戏等。希望这些方法思路和工具可以带大家直击问题本质提升大家面对问题的分析能力和解决能力。*文 / 预子