5.4 服务幂等性设计
什么是幂等性
幂等性最初是一个数学概念,后来被引入计算机科学中,用来描述某个操作可以安全地重试,即多次执行的结果与单次执行的结果完全一致。
柔性事务普遍基于“最大努力交付”机制。也就是说,当网络通信失败、节点宕机或者进程崩溃时,采用重复请求的方式来容错。因此,如果某些关键服务不具备幂等性,重复处理可能导致数据不一致或其他风险。例如,在退款接口中,缺乏幂等性可能导致重复退款。
接下来,笔者将介绍两种实现系统幂等的方式,供读者参考。
5.4.1 全局唯一 ID
全局唯一ID 是在应用层使用最广泛的一种。它的核心思想是为每个操作生成一个独一无二的标识符,以此来判断是否已经执行过该操作。
全局唯一 ID 的实施步骤如下:
- 生成唯一 ID:每次执行操作前,根据业务操作内容生成一个全局唯一ID,这个 ID 可以利用 UUID、雪花算法(Snowflake) 、Uidgenerator 或 Leaf 等算法生成。
- 附加到请求: 将生成的唯一 ID 附加到请求中,作为请求的一个参数、HTTP 头或请求体的一部分。
- 处理请求: 在服务器端,接收到请求后,首先检查这个唯一 ID:
- 如果 ID 已存在:说明该请求已经被处理过,服务器可以直接返回之前的响应结果,避免重复处理。
- 如果 ID 不存在:执行请求的操作,并将操作结果和该 ID 存储在数据存储中(如数据库、缓存等),以供后续请求检查。
值得一提的是,唯一ID 生成算法 snowflake,取自世界上没有两片相同的雪花之意。使用分布式部署的情况下每秒可生成百万个不重复、递增 id。已经广泛应用于需要分布式系统中生成唯一标识符的场景。
5.4.2 乐观锁
接下来,再看数据库中关于修改相关的操作。
假设有一个账户表 accounts,包含字段 id(账户ID)和 balance(账户余额)。现在要给账户 ID 为 1 的账户增加余额
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
如果这个SQL语句执行一次,那么账户的余额会增加100。但是如果由于某些原因(比如网络重试或者程序逻辑错误),这个 SQL 语句被执行了两次,账户的余额将会增加 200,而不是预期的 100。
每次执行这个语句都会对账户余额产生不同的影响,属于典型的非幂等性操作。对于此类的非幂等性操作,我们看看使用乐观锁(Optimistic Locking)如何解决。
乐观锁基本思想是,假设并发操作发生冲突的概率较低,允许多个事务或线程在不加锁的情况下同时读取数据,但在写入数据时再进行冲突检测。如果在写入前检测到数据已被其他事务修改,则放弃当前操作,避免数据不一致的情况。
结合上述增加余额的 SQL,请看下面具体的操作:
- 增加版本号字段:在涉及更新的数据表中增加一个 version 字段,更新数据时,版本号随之增加。
- 更新时检查版本号:执行更新操作时,通过 WHERE 子句检查当前版本号是否与读取时的版本号一致,如果一致则执行更新,并更新版本号。
- 重试机制:如果更新操作因为版本号不一致失败,业务层面发起重试,直到更新成功或达到重试次数限制。
UPDATE accounts
SET balance = balance + ?,
version = version + 1
WHERE id = ? AND version = ?;
这个例子中,? 代表将要被更新的数值,id 是账户的唯一标识符,version 是用于检查数据在读取后是否被修改的版本号。如果更新操作失败,意味着数据库内的数据已经被修改。此时,业务层面请求最新的数据,更新本地 version 并发起重试,直至成功或达到最大重试次数。
上面乐观锁的操作模式,是一种典型的 CAS(Compare And Swap | Compare And Set,比较并交换)操作。CAS 有时也被称为“轻量级事务”。由于乐观锁不需要在读取和写入时持有锁,在并发冲突不频繁的情况下(也就是读多写少的场景),使用乐观锁除保证一致性之外,还可提供更好的并发性能。