zoukankan      html  css  js  c++  java
  • 第七章

    第七章 事务

    在数据系统运行过程中会出现各种故障,为了实现可靠性,系统必须处理这些故障。
    事务(transaction) 一直是简化这些问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功(提交(commit))要么失败(中止(abort)回滚(rollback))。如果失败,应用程序可以安全地重试。

    事务的目的是简化应用层的编程模型,有了事务,应用程序可以不用考虑某些内部潜在的错误以及复杂的并发性问题,这些都可以交给数据库来负责处理(安全性保证)

    深入理解事务

    事务有其优势,也有其自身的局限性。

    ACID的含义

    原子性( Atomicity ), 一致性( Consistency ),隔离性( Isolation )与持久性( Durability )
    不符合ACID标准的系统被称为BASE:即 基本可用性,软状态和最终一致性

    原子性

    多线程编程中,如果某线程执行一个原子操作 ,这意味着其他线程是无陆看到该操作的中间结果。它只能处于操作之前或操作之后的状态,而不是两者之间的状态。

    而ACID 中的原子性并不关乎多个操作的并发性,它并没有描述多个线程试图访问相同的数据会发生什么情况,后者其实是由 ACID的隔离性所定义
    ACID的原子性其实描述了客户端发起一个包含多个写操作的请求时可能发生的情况,在完成了一部分写入之后,系统发生了故障,而导致没法完成最终提交时,则事务会中止,井且数据库须丢弃或撤销那些局部完成的更改。所以可以安全地重试。

    一致性

    ACID 中的一致性的主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或者恒等条件)
    这种一致性本质上要求应用层来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性,这不是数据库可以保证的事情。
    原子性,隔离性和持久性是数据库自身的属性,而ACID 中的一致性更多是应用层的属性。

    隔离性

    ACIDt吾义中的隔离性意味着并发执行的多个事务相互隔离,它们不能互相交叉。经典的数据库教材把隔离定义为可串行化,也就是事务可能同时运行,但数据库系统要确保当事务提交时,其结果与串行执行结果完全相同。

    然而实践中,由于性能问题很少使用串行化隔离。

    持久性

    数据库系统本质上是提供一个安全可靠的地方来存储数据而不用担心数据丢失等。持久性就是这样的承诺,它保证一且事务提交成功,即使存在硬件故障或数据库崩溃,事务所写入的任何数据也不会消失。

    单对象和多对象事务操作

    ACID 中的原子性和隔离性主要针对客户端在同一事务 中包含多个写操作时,数据库所提供的保证。
    这些都是假定一个事务中会修改多个对象,
    脏读:栗个事务读取了另一个事务中尚未提交的写入,违反了隔离性。

    单对象写入

    原子性和隔离性也同样适用于单个对象的更新。例如,假设向数据库写入20KB 的JSON文档 :

    • 可能发送部分数据后网络中断
    • 可能数据库在覆盖磁盘现有数据时发生故障,最终新值和旧值混在一起
    • 另一个客户端在这个过程中读取文档,可能会看到部分更新的文档内容。

    因此存储引擎几乎必备的设计就是在单节点、单个对象层面上提供原子性和隔离性。某些数据库还提供了高级的原子操作,例如原子自增操作。
    但它们井不是通常意义上的事务。虽然compare-and-set和其他单对象操作有时也被称为“轻量级事务”,甚至“ ACID”。
    通常意义上的事务针对的是多个对象,将多个操作聚合为一个逻辑执行单元。

    多对象事务的必要性

    许多分布式数据存储系统不支持多对象事务,但是,分布式数据库实现事务并非不可能,并不存什么原理上的限制。
    有许多情况要求写入多个不同的对象并进行协调:

    • 关系型数据模型中表中的某行可能是另一个表中的外键,需要多对象事务保证外键总是最新 、正确的。
    • 当更新非规范化数据时,就需要一次更新多个文档。此时多对象事务就可以有效防止非规范化数据之间出现不同步 。
    • 对于带有二级索引的数据库,每次更改值时都需要同步更新索引。

    处理错误与中止

    事务的一个关键特性是,如果发生了意外,所有操作被中止,之后可以安全地重试。
    井不是所有的系统都遵循上述理念。 例如, 无主节点复制的数据存储会在“尽力 而为”的 基础上尝试多做些工作。
    重试中止的事务虽然是一个简单有效的错误处理机制,但它并不完美:

    • 事务成功的返回消息可能会在传输中出现意外,会导致不必要的重传
    • 如果错误是由于系统超负荷所导致 , 贝 lj重试事务将使情况变得更糟。
    • 出个出现了永久性故障,则重试毫无意义。
    • 如果在数据库之外, 事务还产生其他副作用,即使事务被中止,这些副作用可能已事实生效。
    • 如果客户端进程在重试过程中也发生失败,没有其他人继续负责重试,则那些待写入的数据可能会因此而丢失。

    弱隔离级别

    如果两个事务操作不同的数据,那么他们可能安全的并行执行,只有出现某个事务修改数据而另一个事务 同时要读取该数据,或者两个事务 同时修改相同数据时,才会引发并发问题。并发性相关的错误很难通过测试发现,这类错误通常只在某些特定时刻才会触发,这种时机相关的问题发生概率低,稳定重现比较困难。
    数据库一直试图通过事务隔离来对应用开发者隐藏内部的各种井发问题。但是可串行化的隔离会严重影响性能
    许多数据库不愿意牺牲性能,因而更多倾向于采用较弱的隔离级别。

    读-提交

    读-提交是最基本的事务隔离级别,它只提供以下两个保证 :

    • 读数据库肘,只能看到巳成功提交的数据(防止“脏读”)。
    • 写数据库肘,只会覆盖已成功提交的数据(防止“脏写”)。

    防止脏读

    定义:假定某个事务已经完成部分数据写入,但事务尚未提交(或中止),此时另一个事务是否可以看到尚未提交的数据呢?如果是的话,那就是脏读。
    解决办法:事务的任何写入只有在成功提交之后,才能被其他人观察到(并且所有的写全部可见)。

    当有以下需求时,需要防止脏读:

    • 如果事务需要更新多个对象,脏读意味着另一个事务可能会看到部分更新,而非全部。
    • 如果事务发生中止,则所有写入操作都需要回滚

    防止脏写

    定义:如果两个事务同时尝试更新相同的对象,可以想象后写的操作会覆盖较早的写入。如果先前的写入是尚未提交事务的一部分,是否会被覆盖?如果是,那就是脏写。
    解决办法:通常的方式是推迟第二个写请求,直到前面的事务完成提交(或者中止)。

    实现读-提交

    防止脏写:数据库通常采用行级锁来防止脏写。
    防止脏读:

    • 和脏写一样使用锁,但在实际中并不可行,会严重影响性能。
    • 大多数数据库用这种方法来防止脏读:对于每个待更新的对象,数据库都会维护其 旧值和当前持锁事务将要设置的新值两个版本。在事务提交之前,所有其他读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值。

    快照级别隔离与可重复读

    表面上看读-提交级别隔离,可能会认为它 已经满足了事务所需要一切特征,但在有些情况下还是会出现不一致的情况,例如:
    账户A往账户B转100元,当账户A已经扣除100元而账户B还未增加100元时去读取账户B的余额,与增加后去读取账户B的余额,得到的结果是不同的。
    这种异常现象被称为不可重复读取读倾斜
    读倾斜在读-提交隔离语义下是可以接受的,但在有些场景是不能容忍的:

    • 备份场景:如果在备份的时候出现了不一致,但此时宕机,从备份的数据库中恢复数据,会导致不一致的那部分数据完全丢失,造成了永久的不一致。
    • 分析查询与完整性检查场景:有时查询可能会扫描几乎大半个数据库。亦或定期的数据完整性检查。如果这些查询在不同时间点观察数据库,可能会返回无意义的结果。

    快照级别隔离这是解决上述问题最常见的手段。其总体想怯是,每个事务都从数据库的一致性快照中读取, 事务一开始所看到是最近提交的数据, 即使数据随后可能被另一个事务更改,但保证每个事务都只看到该特定时间点的旧数据。
    快照级别隔离对于长时间运行的只读查询(如备份和分析)非常有用。如果数据在执行查询的同时还在发生变化,那么查询结果对应的物理含义就难以理清。而如果查询的是数据库在某时刻点所冻结的一致性快照,贝 lj查询结果的含义非常明确。

    实现快照级别隔离

    与读-提交隔离类似,快照级别隔离的实现通常采用写锁来防止脏写,但是,读取则不需要加锁。
    从性能角度看,快照级别隔离的一个关键点是读操作不会阻止写操作,反之亦然。这使得数据库可以在处理正常写入的同时,在一致性快照上执行长时间的只读查询,且两者之间没有任何锁的竞争。
    为了实现快照级别隔离,数据库采用了一种类似防止脏读但却更为通用的机制,考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象多个不同的提交版本,这种技术因 此也被称为多版本并发控制。
    支持快照级别隔离的存储引擎往往直接采用MVCC来实现读,提交隔离。典型的做能是,在读-提交级别下,对每一个不同的查询单独创建一个快照;而快 m~级别隔离则是使用一个快照来运行整个事务。
    当事务开始时, 首先赋予一个唯一的、单调递增的事务ID。每当事务向数据库写入新内容时,所写的数据都会被标记写入者的事务ID 。如果事务要删除某行,则将该行标记为删除。事后,当确定没有其他事务引用该标记删除的行时,数据库的垃圾回收进程才去真正删除井释放存储空间。

    一致性快照的可见性规则

    • 每笔事务开始时,数据库列出所有当时尚在进行中的其他事务,然后忽略这些事务完成的部分写入
    • 所有中止事务所做的修改全部不可见。
    • 所有中止事务所做的修改全部不可见。
    • 除此之外,其他所有的写入都对应用查询可见。

    换句话说,除此之外,其他所有的写入都对应用查询可见:

    • 事务开始的时刻,创建该对象的事务已经完成了提交。
    • 对象没有被标记为删除;或者即使标记了,但删除事务在当前事务开始时还没有完成提交。

    索引与快照级别隔离

    索引直接指向对象的所有版本,然后想办怯过滤对当前事务不可见的那些版本。当后台的垃圾回收进程决定删除某个旧对象版本时,对应的索引条目也需要随之删除。

    可重复读与命名混淆

    快照级别隔离对于只读事务特别有效。
    Oracle称之为可串行化, PostgreSQL和MySQL则称为可重复读

    防止更新丢失

    写事务并发会导致脏写,还会导致一些其他问题,比如更新丢失问题。
    例如:

    • 递增计数器,或更新账户余额
    • 对某复杂对象的一部分内容执行修改,例如对JSON文档中一个列表添加新元素
    • 两个用户同时编辑wiki页面,且每个用户都尝试将整个页面发送到服务器,覆盖数据库中现有内容以使更改生效

    原子写操作

    许多数据库提供了原子更新操作,以避免在应用层代码完成“读-修改-写回”操作。如果支持的话,通常这就是最好的解决方案。
    原子操作通常采用对读取对象加独占锁的方式来实现,这样在更新被提交之前不会其他事务可以读它。
    另一种实现方式是强制所有的原子操作都在单线程上执行。

    显式加锁

    由应用程序显式锁定待更新的对象。
    该方法是可行的,但要做到这一点,需要仔细考虑清楚应用层的逻辑。

    自动检测更新丢失

    先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的“读-修改-写回”方式。
    优点:数据库完全可以借助快照级别隔离来高效地执行检查。
    很多数据库都可以自动检测何时发生了更新丢失,然后会中止违规的那个事务。但MySQL和InnoDB的可重复读并不支持检测更新丢失。

    原子比较和设置(CAS)

    在不提供事务支持的数据库中,有时你会发现它们支持CAS。
    使用该操作可以避免更新丢失,即只有在上次读取的数据没有发生变化时才允许更新;如果已经发生了变化 , 则回退到“读-修改-写回”方式。

    冲突解决与复制

    加锁和原子修改都有个前提即只有一个最新的数据副本。然而,对于多主节点或者无主节点的多副本数据库,由于支持多个井发写 ,且通常以异步方式来同步更新,所以会出现多个最新的数据副本。 此时加锁和原子 比较将不再适用
    多副本数据库通常支持多个井发写,然后保留多个冲突版本(互称为兄弟),之后由应用层逻辑或依靠特定的数据结构来解决、合并多版本。
    如果操作可交换,则原子操作在多副本情况下也可以工作。而最后写入获胜( LWW )冲突解决方陆则容易丢失更新。

    写倾斜与幻读

    除了脏写和丢失更新,还有另一种并发写导致的问题,那就是写倾斜。

    定义写倾斜

    可以将写倾斜视为一种更广义的更新丢失问题。即如果两个事务读取相 同的一组对象,然后更新其中 一部分:不同的事务可能更新不同的对象,则可能发生写倾斜 ; 而不同的事务如果更新的是同一个对象,则可能发生脏写或更新丢失(具体取决于时间窗口)。

    对于写倾斜,可选的方案有很多限制:

    • 由于涉及多个对象,单对象的原子操作不起作用。
    • 目前所有数据库的实现的快照级别隔离都不支持检测写倾斜问题,自动防止写倾斜要求真正懂得可串行化隔离。
    • 某些数据库支持自定义约束条件,然后由数据库代为检查、执行约束,开发者可能可以采用触发器或物化视图来自己实现类似约束。
    • 如果不能使用可串行化级别隔离, 一个次优的选择是对事务依赖的行来显式的加锁。

    为何产生写倾斜

    写倾斜的例子往往都遵循类似的模式:

    • 首先输入一些匹配条件,采用查询语句查询满足条件的行,
    • 然后根据查询的结果,应用层代码来决定下一步的操作,
    • 如果应用程序决定继续执行,它将发起数据库写入并提交事务,
    • 而这个写操作会改变步骤2做出决定的前提条件。

    这种在一个事务中的写入改变了另一个事务查询结果的现象,称为幻读
    快照级别的隔离可以避免只读查询时的幻读,但是无法解决写倾斜的问题。

    实体化冲突

    如果问题的关键是查询结果中没有对象可以加锁,或许可以引入一些可加锁的对象。
    【例如会议室预订问题,两个查询都显示某时段无预订,查询到的结果为空,则无法上锁,如果将时间分成块写在表中,使用一个标志位来表示是否预定,则可以对每个时间块所在的行加锁】
    这种方桂称为实体化冲突(或物化冲突),它把幻读问题转变为针对数据库中一组具体行的锁冲突问题。
    我们不推荐采用实体化冲突。而在大多数情况下,可串行化隔离方案更为可行 。

    串行化

    有以下挑战:

    • 隔离级别通常难以理解,而且不同的数据库的实现不尽一致
    • 如果去检查应用层的代码,往往很难判断它在特定的隔离级别下是否安全
    • 还缺乏好的工具来帮助检测竞争状况
      这些问题的答案就是:采用可串行化隔离。
      可串行化隔离通常被认为是最强的隔离级别。
      目前大多数提供可串行化的数据库都使用了以下三种技术之一:
    • 严格按照串行顺序执行
    • 两阶段锁定,几十年来这几乎是唯一可行的选择。
    • 乐观井发控制技术,例如可串行化的快照隔离

    实际串行执行

    解决井发问题最直接的方法是避免井发 :即在一个线程上按顺序方式每次只执行一个事务 。
    看上去这是个很直白的想告,但数据库设计人员直到2007年前后才完全确信,采用单线程循环来执行事务是可行的
    单线程执行有时可能会比支持并发的系统效率更高,尤其是可以避免锁开销 。但是 ,其吞吐量上限是单个CPU核的吞吐量。为了充分利用单线程,相比于传统形式, 事务也需要做出相应调整。

    采用存储过程封装事务

    在数据库的早期应用阶段,采用事务机制是希望能囊括用户的所有操作序列。
    然而,人类做出决定并回应的速度通常比较慢,即使把人为交互从关键路径移除掉,也会将大量时间花费在应用程序与数据库之间的网络通信,出于这个原因,采用单线程串行执行的系统往往不支持交互式的多语句事务。

    存储过程的优缺点

    缺点:

    • 每家数据库厂商都有自己的存储过程语言,这些语言井没有跟上通用编程语言的发展
    • 在数据库中运行代码难以管理
    • 因为数据库实例往往被多个应用服务器所共享 ,所以数据库通常比应用服务器要求更高的性能。数据库中一个设计不好的存储过程(例如,消耗大量内存或CPU时间)要比同样低效的应用服务器代码带来更大的麻烦。

    不过这些问题也是可以克服的,最新的存储过程已经放弃了PL/SQL ,而是使用现有的通用编程语言。
    存储过程与内存式数据存储使得单线程上执行所有事务变得可行。它们不需要等待I/O ,避免加锁开销等复杂的井发控制机制,可以得到相当不错的吞吐量 。
    VoltDB还借助存储过程来执行复制:它并非将事务的执行结果从一个节点复制到另一个节点,而是在每个副本上都执行相同的存储过程。

    分区

    串行执行所有事务使得井发控制更加简单,但是数据库的吞吐量被限制在单机单个CPU核。
    为了扩展到多个CPU核和多节点,可以对数据进行分区
    但是,对于跨分区的事务,数据库必须在涉及的所有分区之间协调事务。存储过程需要跨越所有分区加锁执行,以确保整个系统的可串行化。
    由于跨分区事务具有额外的协调开销,其性能比单分区内要慢得多。
    事务是否能只在单分区上执行很大程度上取决于应用层的数据结构。

    串行执行小结

    满足以下条件可以实现串行化隔离:

    • 事务必须简短而高效,否则一个缓慢的事务会影响到所有其他事务的执行性能。
    • 仅限于活动数据集完全可以加载到内存的场景。
    • 写入吞吐量必须足够低,才能在单个CPU核上处理;否则就需要采用分区,最好没有跨分区事务。
    • 跨分区事务虽然也可以支持,但是占比必须很小。

    两阶段加锁

    多个事务可以同时读取同一对象,但只要出现任何写操作(包括修改或删除),则必须加锁以独占访问:

    • 如果事务A已经读取了某个对象,此时事务B想要写入该对象,那么B必须等到A提交或中止之才能继续。
    • 如果事务A 已经修改了对象, 此时事务B想要读取该对象, 贝IJB必须等到A提交或中止之后才能继续。

    因此2PL不仅在井发写操作之间互斥,读取也会和修改产生互斥 。
    快照级别隔离 的口号 “读写互不干扰”非常准确地点 明了它和两阶段加锁的关键区别。
    因为2PL提供了串行化,所以它可以防止前面讨论的所有竞争条件,包括更新丢失和写倾斜。

    实现两阶段加锁

    数据库的每个对象都有一个读写锁来隔离读写操作 。 即锁可以处于共享模式或独占模式。基本用法如下

    • 如果事务要读取对象 ,必须先以共享模式获得锁。
    • 如果事务要修改对象,必须以独占模式获取锁。
    • 如果事务首先读取对象,然后尝试写入对象,则需要将共享锁升级为独占锁。
    • 事务获得锁之后, 一直持有锁直到事务结束(包括提交或中止)。(两阶段:在第一阶段即事务执行之前要获取锁,第二阶段则释放锁)

    由于使用了这么多的锁机制,所以很容易出现死锁现象,数据库系统会自动检测事务之间的死锁情况,并强行中止其中的一个以打破僵局,这样另一个可以继续向前执行 。

    两阶段加锁的性能

    两阶段加锁事务吞吐量和查询响应时间相比于其他弱隔离级别下降非常多。
    当一个事务还需要等待另一个事务时,那么最终的等待时间几乎是没有上限的。
    在2PL下,取决于事务的访问模式,死锁可能变得更为频繁。

    谓词锁

    可串行化隔离也必须防止幻读问题。
    我们需要引入一种谓词锁(或者属性谓词锁)它的作用类似于之前描述的共享/独占锁,而区别在于,它并不属于某个特定的对象( 如表的某一行),而是作用于满足某些搜索条件的所有查询对象。
    谓词锁会限制如下访问:

    • 如果事务A想要读取某些搞足匹配条件的对象,例如采用 SELECT查询,它必须以共享模式获得查询条件的谓词锁。如果另一个事务B正持有任何一个匹配对象的互斥锁,那么A必须等到B释放锁之后才能继续执行查询。
    • 如果事务A想要插入、更新或删除任何对象,则必须首先检查所有旧值和新值是否与现有的任何谓词锁匹配(即冲突)。如果事务B持有这样的谓词锁,那么A必须等到B完成提交(或中止)后才能继续。
      谓词锁甚至可以保护数据库中那些尚不存在但可能马上会被插入的对象(幻读)。
      将两阶段加锁与谓词锁结合使用,数据库可以防止所有形式的写倾斜以及其他竞争条件,隔离变得真正可串行化。

    索引区间锁

    谓词锁性能不佳,大多数使用 2PL的数据库实际上实现的是索引区 间锁,本质上它是对谓词锁的简化或者近似
    简化谓词锁的方式是将其保护的对象扩大化。
    【如果一个查询条件是某房间某时段是否预定,则查询肯定会经过索引,如果索引位于房间号上,则数据库可以简单地将共享锁附加到此索引条目,对该房号添加共享锁,如果索引位于时间段上,则对该时间段添加共享锁。无论 哪种方式,查询条件的近似值都附加到某个索引上。另一个事务想要插入更新删除相关数据,肯定需要更新这些索引,一定会和共享锁冲突,因此会自动处于等待状态直到共享锁释放。】
    这样就有效防止了写倾斜和幻读问题。虽然索引区间锁不像谓词锁那么精确,但由于开销低得多。

    可串行化的快照隔离(SSI)

    SSI可用于单节点数据库或者分布式数据库,SSI尚需在实践中证明其性能。

    悲观与乐观的并发控制

    两阶段加锁是一种典型的悲观井发控制机制。可串行化的快照隔离则是一种乐观井发控制
    如果可能发生潜在冲突,事务会继续执行而不是中止,寄希望一切相安无事:而当事务提交时(只有可串行化的事务被允许提交),数据库会检查是否确实发生了冲突(即违反了隔离性原则),如果是的话,中止事务并接下来重试。
    SSI基于快照隔离,也就是说,事务中的所有读取操作都是基于数据库的一致性快照,在快照隔离的基础上, SSI新增加了相关算陆来检测写入之间的串行化冲突从而决定中止哪些事务。

    基于过期的条件做决定

    查询与写事务之间可能存在因果依赖关系。为了提供可串行化的隔离,数据库必须检测事务是否会修改其他事务的查询结果,并在此情况下中止写事务。
    数据库如何知道查询结果是否发生了改变呢?可以分以下两种情况 :

    • 检测是否读取了过期的MVCC对象
    • 检测写是否影晌了之前的读

    检测是否读取了过期的MVCC对象

    数据库需要跟踪那些由于MVCC可见性规则而被忽略的写操作。当事务提交时 ,数据库会检查是否存在一些当 初被忽略的写操作现在已经完成了提交,如果是则必须中止当前事务。
    通过减少不必要的中止, SSI可以高效支持那些需要在一致性快照中运行很长时间的读事务。

    检测写是否影晌了之前的读

    这里使用了和索引区间锁类似的技术,只不过SSI锁不会阻止其他事务。
    当另一个事务尝试修改时,它首先检查索引,从而确定是否最近存在一些读目标数据的其他事务。这个过程类似于在受影响 的宇段范围上获取写锁,但它并不会阻塞读取,而是直到读事务提交时才进一步通知他们 : 所读到的数据现在已经发生了变化。

    可串行化快照隔离的性能

    与两阶段加锁相比,可串行化快照隔离的一大优点是事务不需要等待其他事务所持有的锁。这一点和快照隔离一样 ,读写通常不会互相阻塞。这样的设计使得查询延迟更加稳定、可预测。特别是,在一致性快照上执行只读查询不需要任何锁,这对于读密集的负载非常有吸引力。
    与串行执行相比,可串行化快照隔离可以突破单个CPU核的限制。可以将冲突检测分布在多台机器上,从而提高总体吞吐量。
    事务中止的比例会显著影响 SSI的性能表现。SSI要求读-写型事务要简短)。但总体讲,相比于两阶段加锁与串行执行, SSI更能容忍那些执行缓慢的事务。

  • 相关阅读:
    vue2.0 之 douban (二)创建自定义组件tabbar
    vue2.0 之 douban (一)框架搭建 及 整体布局
    react-navigation
    react-native modal
    javascript-使用el表达式获取后台传递的数据
    java Map
    java List集合
    javascript 自定义Map
    javascript Array(数组)
    ZOJ 3868 GCD Expectation (容斥+莫比乌斯反演)
  • 原文地址:https://www.cnblogs.com/aojun/p/15261312.html
Copyright © 2011-2022 走看看