一、redis锁处理幂等性失效
上面代码中,锁起不了作用;
——count方法,和insert方法在同一事务中,事务中包含锁,锁没有作用,锁的范围内,事务没提交,但释放锁后,事务提交前,被另一线程抢了执行权后,因为事务还没提交,另一线程拿到的count还是0。
以上代码问题:
- 对事物的理解使用有问题,幂等设计bug;
- redis锁使用有问题(单独案例讲述);
mysql默认事务级别——可重复读;
锁加错位置了,锁应该加在这个事务方法的外面;
正例:
stop the world:
学会用stop the world注释代码。
1.1 扩展:
事务在生产实践中经常犯的错误:
- 事务范围:应该加入事务的代码未加入到事务中
1.1.1 图是另一个真实生产当中的事故-仅供参考:
IdGenerator 是一个生成唯一标识符的工具类。它通常用于生成数据库表中的主键值,例如AUTO_INCREMENT 字段。
- 事务大小:事务过大,是否有必要拆解小事务(如何优化),拆解后一致性问题。
传播范围(异常标注):
- 多线程中不可传播;
- 多个方法内如果异常被捕获将要被标记为异常事务,不可以再次提交(虽然不影响数据,但是有报错信息);
二、Transaction rolled back bacause it has been marked as rollback-only问题原因复盘
2.1 复盘
错误原因:
提交了一个被标记为异常的事务,会报这个错。
解决方法:
- a处try-catch代码去掉;
- 或者,b处@Transactional注解去掉;
无论是哪种解决方法,具体看业务。
三、mysql死锁场景
- 问题1:jvm如果死锁了,java进程还在吗?——一直锁着。
- 问题2:mysql如果死锁了,其他连接还能正常运行吗?——死锁一段时间后会自动释放,可配置;
3.1 mysql死锁复盘
在 MySQL 中,FOR UPDATE 子句用于在读取数据时锁定该记录,以防止其他事务同时更新或删除该记录。当多个事务试图同时锁定同一记录时,可能会导致死锁。
下面是一个可能导致死锁的场景:
假设有两个事务 T1 和 T2,它们都试图更新同一行数据。
- 事务 T1 执行以下操作:
- 读取一行数据并加上 FOR UPDATE 锁。
- 等待一段时间(例如,进行一些计算或等待其他资源)。
- 事务 T2 执行以下操作:
- 读取同一行数据并加上 FOR UPDATE 锁。
- 试图更新该记录,但由于 T1 已经锁定了该记录,因此事务 T2 被阻塞。 现在,事务 T1 等待一>段时间后准备更新记录,但由于事务 T2 已经锁定了该记录,因此事务 T1 也被阻塞。
这就形成了一个死锁,因为两个事务都在等待对方释放锁,而它们都无法继续执行下去。
为了避免死锁,可以采取以下措施:
- 尽量减少锁定的时间,以避免其他事务长时间等待。
- 按照相同的顺序访问数据,以避免冲突。
mysql死锁时间长好还是短好?
——短的话,不好控制长事务;长的话,发生死锁时,时间等待太久;
四、幂等性设计方法
4.1 幂等性设计:
- 有时我们在填写某些 form表单 时,保存按钮不小心快速点了两次,表中竟然产生了两条重复的数据,只是id不一样;
- 我们在项目中为了解决 接口超时 问题,通常会引入了 重试机制 。第一次请求接口超时了,请求方没能及时获取返回结果(此时有可能已经成功了),为了避免返回错误的结果(这种情况不可能直接返回失败吧?),于是会对该请求重试几次,这样也会产生重复的数据;
- mq消费者在读取消息时,有时候会读取到 重复消息 ,如果处理不好,也会产生重复的数据;
这些都是幂等性问题。
接口幂等性: 是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
这类问题多发于接口的:
- insert 操作,这种情况下多次请求,可能会产生重复数据;
- update 操作,如果只是单纯的更新数据,比如: update user set status=1 where id=1 ,是没有问题的。如果还有计算,比如: update user set status=status+1where id=1 ,这种情况下多次请求,可能会导致数据错误;
那么我们要如何保证接口幂等性?请往下看。
4.1.1 insert前先select
通常情况下,在保存数据的接口中,我们为了防止产生重复数据,一般会在 insert 前,先根据 name 或 code 字段 select 一下数据。如果该数据已存在,则执行 update 操作,如果不存在,才执行 insert 操作。
该方案可能是我们平时在防止产生重复数据时,使用最多的方案。但是该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。
4.1.2 加悲观锁
4.1.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
操作位于事务中
具体流程如下:
具体步骤:
- 多个请求同时根据id查询用户信息。
- 判断余额是否不足100,如果余额不足,则直接返回余额不足。
- 如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。
- 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。
- 第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。
- 如果余额不足,说明是重复请求,则直接返回成功。
4.1.2.1 操作库场景
select* from stock_info where goods_id=12312 and storage_id=1 for update;
具体流程:
a:单件货品操作流程:
b:(同一个goodsId)多个单件货品,批量操作出库流程:
具体步骤:
- 多个请求同时根据goodsId和storageId操作货品的上下架,或者其他渠道订单批量下架操作;
- 判断当前货品是否有仓库货品;
- 如果货品库存充足,则通过for update再次查询货品库存信息,并且尝试获取锁;
- 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会;
- 第一个请求获取到锁之后,进行货品单件明细状态变更,成功后操作,则进行update操作加减库存;
- 如果库存不足或者单件不满足操作,则直接返回成功或者幂等状态。
需要特别注意的是:如果使用的是mysql数据库,存储引擎必须用innodb,因为它才支持事 务。此外,这里id字段一定要是主键或者唯一索引,不然会锁住整张表。
悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。在这里顺便说一下, 防重设计 和幂等设计 ,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。
4.1.3 加乐观锁
既然悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁。需要在表中增加一个timestamp 或者 version 字段,这里以 version 字段为例。
在更新数据之前先查询一下数据:
select id,amount,version from user id=123;
中间就省略了,相信大家也知道。直接贴出sql中的乐观锁代码了:
update user set amount=amount+100,version=version+1where id=123 and version=1;
需要注意的是,如果影响行数为0:
该 update 操作不会真正更新数据,最终sql的执行结果影响行数是 0 ,因为 version 已经变成 2了, where
中的 version=1 肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功,因为 version
值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。
具体步骤:
- 先根据id查询用户信息,包含version字段;
- 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1;
- 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作;
- 如果影响0行,说明是重复请求,则直接返回成功;
4.1.4 加唯一索引
常规的创建唯一索引,和唯一联合索引的思路就不写了。
4.1.4.1 软删除可能引发的问题:
在很多业务场景中,都使用“软删除”即使用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)建唯一索引。
4.1.4.2 唯一索引和普通索引的区别?
4.1.4.2.1 查询
select * from t_user where id_card =1000;
- 对于普通索引来说,查找到满足条件的第一个记录(1,1000)后,需要查找下一个记录,直到碰到第一个不满足id_card=1000条件的 记录;
- 对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。
性能差距微乎其微,因为mysql 数据是按照数据页为单位的,也就是说,当读取一条数据的时候,会将当前数据所在页都读入到内存,普通索引无非多了一次判断是否等于 的操作,相当于指针的寻找和一次计算,当然,如果该页码上,id_card=1000是最后一个数据,那么就需要取下一个页了,但是这种概率并不大。
总结说,查询上,普通索引和唯一索引性能是没什么差异的。
4.1.4.2.2 更新
当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致 性的前提下,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,语句执行就结束了。
4.1.4.2.3 总结
将数据从磁盘读入内存涉及随机IO的访问,是数据库里面成本最高的操作之一。change buffer因为减少了随机磁盘访问, 所以对更新性能的提升是会很明显的。
因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时change buffer的使用效果最好。
这种 业务模型常见的就是账单类、日志类的系统。
反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在change buffer,但之 后由于马上要访问这个数据页,会立即触发merge过程。这样随机访问IO的次数不会减少,反而增加了change buffer的维 护代价。所以,对于这种业务模式来说,change buffer反而起到了副作用。
redo log主要节省的是随机写磁盘的IO消耗(转成 顺序写),而change buffer主要节省的则是随机读磁盘的IO消耗。
4.1.4.2.4 Change buffer为什么只对非唯一普通索引页有效
-
主键索引,唯一索引
实际上对于【唯一索引】的更新,插入操作都会先判断当前操作是否违反唯一性约束,而这个操作就必须要将索引页读取到内存中,此时既然已经读取到内存了,那直接更新即可,没有需要在用Change buffer了。 -
非唯一普通索引
不需要判断当前操作是否违反唯一性约束,也就不需要将数据页读取到内存,因此可以直接使用 change buffer 更新。
基于此,Change buffer只有对普通索引可以使用,对唯一索引的更新无法生效。
change buffer参考文章:MySQL十七:Change Buffer