掘金 后端 ( ) • 2024-03-08 10:33

theme: condensed-night-purple

4 案例解析

4.1 热身-事故1:redis锁处理幂等性失效


/**
 * 接口需要幂等,此处身份证号不允许重复
 * @param user
 */
@Override
@Transactional(rollbackFor = Exception.class)
public void add (User user){
    log.info("add user params user:{}", JSON.toJSONString(user));
    Assert.isTrue(StringUtils.isNotBlank(user.getIdCard()), "身份证号不允许null");
    String key = "key";
    RLock lock = redissonClient.getLock(key);
    lock.lock();
    try {
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
                .eq(User::getIdCard, user.getIdCard());
        long count = userMapper.selectCount(wrapper);
        if (count == 0) {
            userMapper.insert(user);
        }
    } catch (Exception e) {
        log.error("add user error", e);
    } finally {
        lock.unlock();
    }
}

以上代码问题:

1:对事物的理解使用有问题,幂等设计bug

2:redis锁使用有问题

扩展:

事务在生产实践中经常犯的错误:

事务范围:应该加入事务的代码未加入到事务中

image.png

事务大小:事务过大,是否有必要拆解小事务(如何优化),拆解后一致性问题。

传播范围(异常标注):

多线程中不可传播

多个方法内如果异常被捕获将要被标记为异常事务,不可以再次提交(虽然不影响数据,但是有报

错信息)

4.1.1 幂等性设计方法

幂等性设计:

  1. 有时我们在填写某些 form表单 时,保存按钮不小心快速点了两次,表中竟然产生了两条重

复的数据,只是id不一样。

  1. 我们在项目中为了解决 接口超时 问题,通常会引入了重试机制 。第一次请求接口超时了, 请求方没能及时获取返回结果(此时有可能已经成功了),为了避免返回错误的结果(这种 情况不可能直接返回失败吧?),于是会对该请求重试几次,这样也会产生重复的数据。

  2. mq消费者在读取消息时,有时候会读取到 重复消息 ,如果处理不好,也会产生重复的数

据。

  1. 没错,这些都是幂等性问题。

接口幂等性 是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因

为多次点击而产生了副作用。

这类问题多发于接口的:

insert 操作,这种情况下多次请求,可能会产生重复数据。

update 操作,如果只是单纯的更新数据,比如: update user set status=1 where id=1 ,是没有问题的。如果还有计算,比如:update user set status=status+1where id=1 ,这种情况下多次请求,可能会导致数据错误。

1. insert前先select

通常情况下,在保存数据的接口中,我们为了防止产生重复数据,一般会在 insert 前,先 根据 namecode 字段 select 一下数据。如果该数据已存在,则执行 update 操作,如果不 存在,才执行 insert 操作。

image.png

该方案可能是我们平时在防止产生重复数据时,使用最多的方案。但是该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。

2. 加悲观锁

1)支付场景在加减库存场景中,用户A的账号余额有150元,想转出100元,正常情况下用户A 的余额只剩50元。一般情况下,sql是这样的:

update user amount = amount-100 where id=123;

如果出现多次相同的请求,可能会导致用户A的余额变成负数。这种情况,用户A来可能要哭 了。于此同时,系统开发人员可能也要哭了,因为这是很严重的系统bug

为了解决这个问题,可以加悲观锁,将用户A的那行数据锁住,在同一时刻只允许一个请求获得锁,更新数据,其他的请求则等待。

通常情况下通过如下sql锁住单行数据:

select * from user id=123 for update;

条件:数据库引擎为innoDB

操作位于事务中

具体流程如下:

image.png

具体步骤:

  1. 多个请求同时根据id查询用户信息。

  2. 判断余额是否不足100,如果余额不足,则直接返回余额不足。

  3. 如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。

  4. 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。

  5. 第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。

  6. 如果余额不足,说明是重复请求,则直接返回成功。

2)操作库存场景

select* from stock_info where goods_id=12312 and storage_id=1 for update;

具体流程:

a:单件货品操作流程

image.png

b:(同一个goodsId)多个单件货品,批量操作出库流程

image.png

具体步骤:

  1. 多个请求同时根据goodsId和storageId操作货品的上下架,或者其他渠道订单批量下架操

  1. 判断当前货品是否有仓库货品

  2. 如果货品库存充足,则通过for update再次查询货品库存信息,并且尝试获取锁。

  3. 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。

  4. 第一个请求获取到锁之后,进行货品单件明细状态变更,成功后操作,则进行update操作

加减库存。

  1. 如果库存不足或者单件不满足操作,则直接返回成功或者幂等状态。

需要特别注意的是:如果使用的是mysql数据库,存储引擎必须用innodb,因为它才支持事 务。此外,这里id字段一定要是主键或者唯一索引,不然会锁住整张表。

悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等 待,影响接口性能。此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场 景,但是在防重场景中是可以的使用的。在这里顺便说一下, 防重设计 和 幂等设计 ,其实是有 区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产 生重复数据之外,还要求每次请求都返回一样的结果。

3. 加乐观锁

既然悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁。需要在表中增加一个 timestamp 或者 version 字段,这里以 version 字段为例。

在更新数据之前先查询一下数据

select id,amount,version from user id=123;

如果数据存在,假设查到的 version 等于 1 ,再使用 idversion 字段作为查询条件更新数据: 更新数据的同时 version+1 ,然后判断本次 update 操作的影响行数,如果大于0,则说明本次更 新成功,如果等于0,则说明本次更新没有让数据变更。

由于第一次请求 version 等于 1 是可以成功的,操作成功后 version 变成 2 了。这时如果并发的 请求过来,再执行相同的sql:

update user set amount=amount+100,version=version+1where id=123 and version=1;

update 操作不会真正更新数据,最终sql的执行结果影响行数是 0 ,因为 version 已经变成 2了, where 中的 version=1 肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功, 因为 version 值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。

具体流程如下:

image.png

具体步骤:

  1. 先根据id查询用户信息,包含version字段

  2. 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1

  3. 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。

  4. 如果影响0行,说明是重复请求,则直接返回成功。

4. 加唯一索引

绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引,这是一个非常简单,并

且有效的方案。

alter table `order` add UNIQUE KEY `un_code` (`code`);

加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报

Duplicate entry '002' for key 'order.un_code 异常,表示唯一索引有冲突。

虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该

异常进行捕获,然后返回成功。

如果是 java 程序需要捕获: DuplicateKeyException 异常,如果使用了 spring 框架还需要捕

获: MySQLIntegrityConstraintViolationException 异常。

具体流程图如下:

image.png

具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据。

  2. 将该数据插入mysql

  3. 判断是否执行成功,如果成功,则操作其他数据(可能还有其他的业务逻辑)。

  4. 如果执行失败,捕获唯一索引冲突异常,直接返回成功。

思考:

在很多业务场景中,都使用“软删除”即使用flag或is_deleted等字段表示记录是否被删除,这 种方式能很好地保存“历史记录”,但由于”历史记录”的存在,导致无法在表上建立唯一索引, 需要通过程序来控制”数据唯一性”,其中一种程序实现逻辑就是“先尝试更新,更新失败则插 入”,该方式在高并发下死锁频发。(select for update ;为什么?你能复现么?如何避免?)

尽管可以通过程序来控制”数据唯一性”,但仍建议使用数据库级别的唯一约束来确保数据在表 级别的”唯一”,对于”硬删除”方式,直接在唯一索引列上建立为唯一索引即可,对于”软删 除”方式,可以通过复合索引方式来处理。

假设当前有订单相关的表tb_order_worker,表中有order_id字段需要唯一约束,使用is_delete字段来标识记 录是否被”软删除”,is_delete=1时表示记录被删除,is_delete=0时表示记录未被删除,需要控制满足 is_delete=0时的记录中order_id唯一,如果对(order_id,is_delete)的建唯一索引,那么当同一订单被多次” 软删除”时就会出现唯一索引冲突的问题。

解决方式一:提升is_delete列的取值范围,当is_delete=0时表示记录有效,当is_delete>0时 表示记录被删除,在删除记录时将is_delete值设置为不同数值,只要确保相同order_id的记录使 用不同数值即可(很多表都使用自增主键,可以取自增主键的值来作为is_delete值)。

解决方式二:新增列order_rid来保持方式一中is_delete的原有取值范围,当is_delete时设置order_rid=0, 当is_delete=1时设置order_rid为任意非0值,只要确保相同order_id的记录使用不同值即可(同样建议参照自 增主键值来设置),然后对(order_id,yn,order_rid)建唯一索引

唯一索引和普通索引的区别?

查询

select * from t_user where id_card =1000;

对于普通索引来说,查找到满足条件的第一个记录(1,1000)后,需要查找下一个记录,直到碰到第 一个不满足id_card=1000条件的 记录。

对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检 索。

性能差距微乎其微,因为mysql 数据是按照数据页为单位的,也就是说,当读取一条数据的时 候,会将当前数据所在页都读入到内存,普通索引无非多了一次判断是否等于 的操作,相当于指 针的寻找和一次计算,当然,如果该页码上,id_card=1000是最后一个数据,那么就需要取下一 个页了,但是这种概率并不大。

总结说,查询上,普通索引和唯一索引性能是没什么差异的

更新

当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的 话,在不影响数据一致 性的前提下,InooDB会将这些更新操作缓存在change buffer中,这样就 不需要从磁盘中读入这个数据页了。在下次查询 需要访问这个数据页的时候,将数据页读入内 存,然后执行change buffer中与这个页有关的操作。通过这种方式就能保证 这个数据逻辑的正确性。

这个change buffer通常被称为InnoDB的写缓冲?

在MySQL5.5之前,叫插入缓冲(insert buffer),只针对insert做了优化;现在对delete和update 也有效,叫做写缓冲(change buffer)。

它是一种应用在非唯一普通索引页(non-unique secondary index page)不在缓冲池中,对页进 行了写操作,并不会立刻将磁盘页加载到缓冲池,而仅仅记录缓冲变更(buffer changes),等未来 数据被读取时,再将数据合并(merge)恢复到缓冲池中的技术。

写缓冲的目的是降低写操作的磁盘IO,提升数据库性能。

对于唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。比如,要插入 (1,1000)这个记录,就要先判 断现在表中是否已经存在id_card=1000的记录,而这必须要将数据 页读入内存才能判断。如果都已经读入到内存了,那直接更新内 存会更快,就没必要使用change buffer了。 因此,唯一索引的更新就不能使用change buffer,实际上也只有普通索引可以使用。 接着分析InnoDB更新流程

第一种情况是,该记录要更新的目标页在内存中。

处理流程如下:

对于唯一索引来说,找到999和1001之间的位置,判断到没有冲突,插入这个值,语句执行结 束;

对于普通索引来说,找到999和1001之间的位置,插入这个值,语句执行结束。

这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小的 CPU时间。

真正影响性能的是第二种情况是,这个记录要更新的目标页不在内存中。处理流程如下:

对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束; 对于普通索引来说,则是将更新记录在change buffer,语句执行就结束了。

总结

将数据从磁盘读入内存涉及随机IO的访问,是数据库里面成本最高的操作之一。change buffer因 为减少了随机磁盘访问, 所以对更新性能的提升是会很明显的。

因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时change buffer的使用效果最好。

这种 业务模型常见的就是账单类、日志类的系统。

反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录 在change buffer,但之 后由于马上要访问这个数据页,会立即触发merge过程。这样随机访问 IO的次数不会减少,反而增加了change buffer的维 护代价。所以,对于这种业务模式来说, change buffer反而起到了副作用。

redo log 主要节省的是随机写磁盘的IO消耗(转成 顺序写),

而change buffer主要节省的则是随机读磁盘的IO消耗。

5. 建防重表

有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直 接在表中加唯一索引,显然是不太合适的。

针对这种情况,我们可以通过 建防重表 来解决问题。

该表可以只包含两个字段: id 和 唯一索引 ,唯一索引可以是多个字段比如:name、code等 组合起来的唯一标识,例如:pauipai_0001。

具体流程图如下:

image.png

具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据。
  2. 将该数据插入mysql防重表
  3. 判断是否执行成功,如果成功,则做mysql其他的数据操作(可能还有其他的业务逻辑)。
  4. 如果执行失败,捕获唯一索引冲突异常,直接返回成功。

需要特别注意的是:防重表和业务表必须在同一个数据库中,并且操作要在同一个事务中。

6. 根据状态机

很多时候业务表是有状态的,比如订单表中有:1-下单、2-已支付、3-完成、4-撤销等状态。 如果这些状态的值是有规律的,按照业务节点正好是从小到大,我们就能通过它来保证接口的幂等 性。

假如id=123的订单状态是 已支付 ,现在要变成 完成 状态。

update `order` set status=3 where id=123 and status=2;

第一次请求时,该订单的状态是 已支付 ,值是 2 ,所以该 update 语句可以正常更新数据,sql 执行结果的影响行数是 1 ,订单状态变成了 3 。

后面有相同的请求过来,再执行相同的sql时,由于订单状态变成了 3 ,再用 status=2 作为条 件,无法查询出需要更新的数据,所以最终sql执行结果的影响行数是 0 ,即不会真正的更新数 据。但为了保证接口幂等性,影响行数是 0 时,接口也可以直接返回成功。

具体流程图如下:

image.png

具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据。

  2. 根据id和当前状态作为条件,更新成下一个状态

  3. 判断操作影响行数,如果影响了1行,说明当前操作成功,可以进行其他数据操作。

  4. 如果影响了0行,说明是重复请求,直接返回成功。

主要特别注意的是,该方案仅限于要更新的 表有状态字段 ,并且刚好要更新 状态字段 的这 种特殊情况,并非所有场景都适用。

7. 加分布式锁

其实前面介绍过的 加唯一索引 或者 加防重表 ,本质是使用了 数据库 的 分布式锁 ,也属于分布 式锁的一种。但由于 数据库分布式锁 的性能不太好,我们可以改用: redis 或 zookeeper 。

我们以 redis 为例介绍分布式锁。

目前主要有三种方式实现redis的分布式锁:

  1. setNx命令

  2. set命令

  3. Redission框架

具体流程图如下:

image.png

具体步骤:

  1. 用户通过浏览器发起请求,服务端会收集数据,并且生成订单号code作为唯一业务字段。

  2. 使用redis的set命令,将该订单code设置到redis中,同时设置超时时间。

  3. 判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作。

  4. 如果设置失败,说明是重复请求,则直接返回成功。

需要特别注意的是:分布式锁一定要设置一个合理的过期时间,如果设置过短,无法有效的 防止重复请求。如果设置过长,可能会浪费 redis 的存储空间,需要根据实际业务情况而 定。

8. 获取token

除了上述方案之外,还有最后一种使用 token 的方案。该方案跟之前的所有方案都有点不一样, 需要两次请求才能完成一次业务操作。

  1. 第一次请求获取 token

  2. 第二次请求带着这个 token ,完成业务操作。

具体流程图如下:

第一步,先获取token。

image.png

image.png

具体步骤:

  1. 用户访问页面时,浏览器自动发起获取token请求。

  2. 服务端生成token,保存到redis中,然后返回给浏览器。

  3. 用户通过浏览器发起请求时,携带该token。

  4. 在redis中查询该token是否存在,如果不存在,说明是第一次请求,做则后续的数据操作。

  5. 如果存在,说明是重复请求,则直接返回成功。

  6. 在redis中token会在过期时间之后,被自动删除。

以上方案是针对幂等设计的。

如果是防重设计,流程图要改改:

image.png

需要特别注意的是:token必须是全局唯一的。