开发者社区> 问答> 正文

快速多次请求接口,事务失效,造成数据重复 先给出简单解决方案,具体的实现在下文会给出。 具体解决方案

我有一个报名接口:在service里判断此用户在报名表里没有记录就会插入一条。

但是如果前端快速请求两次,就会因为第一次请求没来得及插入的时候第二次已经执行select了,造成数据重复。

我想知道的是:为什么我在service里加上事务了 (mysql数据库,默认的不可重复读) 那为什么在并发下还是会读取到一个事务没有提交的数据

展开
收起
kun坤 2020-06-07 22:25:14 1747 0
1 条回答
写回答
取消 提交回答
  • public synchronized void insert() {

        ...

    }

    试试这个。

    ######您好,请问 如果我不用这种同步的方法, 只用事务隔离可以解决吗######

    加synchronized比较简单暴力,性价比最好。更优的方式是添加流水单号,根据流水单号进行同步或者异步添加。但是需要实现很多内容。

    ######简单暴力、好处是 Java 端当掉了并发的压力,数据库还是一个个进出,压力不会落到数据库上。哈哈哈######

     transactionl

    ######

    两次插入请求和事务没太大关系,上面的加synchronize关键字在一台机器上的时候算是一个办法,但不是可行的办法,这相当于把所有任务都串行了,浪费服务器资源。这种情况可以有几种处理办法:

    1. 数据库加唯一索引,如果有唯一列可以标识的话

    2. 两行重复在事务完成之后做一个删除判断,将id比较小的(大的也OK,只要逻辑一致)几条删掉,只保留一条

    3. 加分布式锁,这也需要唯一标识来加锁

    4. 不完美的解决办法,前端保证短时间内只发一次请求(正常用户没有问题,容易被hack,但可以挡正常流量,这应该是必须要做的)

    ######

    引用来自“52iSilence7”的评论

    两次插入请求和事务没太大关系,上面的加synchronize关键字在一台机器上的时候算是一个办法,但不是可行的办法,这相当于把所有任务都串行了,浪费服务器资源。这种情况可以有几种处理办法:

    1. 数据库加唯一索引,如果有唯一列可以标识的话

    2. 两行重复在事务完成之后做一个删除判断,将id比较小的(大的也OK,只要逻辑一致)几条删掉,只保留一条

    3. 加分布式锁,这也需要唯一标识来加锁

    4. 不完美的解决办法,前端保证短时间内只发一次请求(正常用户没有问题,容易被hack,但可以挡正常流量,这应该是必须要做的)

    增加分布式锁,注意释放锁死锁情况。

    楼上说的比较ID大小的方法仅限于ID是自增情况,如果是UUID不适用。

     

    ######

     事务和并发问题

    事务和并发,这两个并不是一个对等的概念。

    先给出简单解决方案,具体的实现在下文会给出。

     

    第一种方式(推荐):

      给数据添加唯一索引,这种方式能解决,但是会影响效率。

     

    第二种方式:

      如果是分布式项目,可以使用分布式锁,具体可以通过redis或者zookeeper来实现,

      如果是单点项目,可以使用同步代码块来实现。

     

    第三种方式(推荐):

      使用insert where not exists 语句来限制插入。

     

    第四种方式:

      使用redis的`SETNX`方法来实现。

     

      在具体业务中,我们更推荐第一种方式和第三种方式相结合的形式,但是大多数业务场景中,往往只采用第一种方式即可。

     

    具体解决方案和思路

     

    在关系型数据库中(如MySql),一个事务可以是一条SQL语句,或者一组SQL语句。

    其展现形式大致如下:

     

    ```

    BEGIN; /*开启事务*/

    SQL 1;

    SQL 2;

    SQL 3;

    COMMIT;/ROLLBACK; /*提交或回滚*/

    ```

     

    他的具体表现是,上面一组SQL(SQL 1/SQL 2/SQL 3)在执行时,他们同时生效或者同时失败。

     

    并发场景重现

     

    如题所诉,假设`报名表`由下列字段构成

     

    ```

    CREATE TABLE `sign_up` (

      `user_id` varchar(32) NOT NULL COMMENT '用户ID',

      `create_time` datetime NOT NULL COMMENT '用户报名时间'

    ) ENGINE=InnoDB DEFAULT CHARSET=utf8

    ```

     

    题中目前的操作应该大致如下:

     

    ```

    BEGIN;

    /*step1:从数据库获取当前用户是否已经报名*/

    SELECT su.user_id,su.create_time FROM sign_up su WHERE user_id = '';

    /*step2:如果用户未报名,则在数据库中插入数据*/

    INSERT INTO sign_up values('',NOW());

    COMMIT;

    ```

    此时代码本身是有漏洞的,当请求并发时,可能触发下列场景。

     

    请求A:

    `SELECT su.user_id,su.create_time FROM sign_up su WHERE user_id = '123';`

    请求B:

    `SELECT su.user_id,su.create_time FROM sign_up su WHERE user_id = '123';`

    请求A:

    `INSERT INTO sign_up values('123',NOW());`

    请求B:

    `INSERT INTO sign_up values('123',NOW());`

     

    数据库在未添加唯一索引的场景下会插入两条数据,添加唯一索引的场景下则会报错`唯一索引冲突`。

     

    此时虽然开启了事务,但是在整个执行过程中,如果没有开启唯一索引,SQL都是执行成功的,不会触发`ROLLBACK`;

    如果开启了唯一索引,此时应该也就没有这个疑问了。

     

    解决方案

    针对这种问题,其实可以采取几种常见的方式来解决。

    第一种方式:

    在单点部署的工程中,可以通过对核心代码部分添加同步来解决,比如使用`synchronized`或者`ReentrantLock`来实现,

    限制部分代码的并发访问,但是这样必然会降低该接口的效率,而且,在分布式工程内,该解决方法并不适用

    ,所以不建议使用

     

    第二种方式: 通过分布式一致性锁来实现

    针对第一种方案,通过分布式一致性锁取代常规同步块,进而实现在分布式工程中将并发转为同步。

    分布式一致锁的实现方案有很多种,常见的有基于redis实现和基于zookeeper实现。

     

    第三种方式:给数据库字段添加唯一索引

    `ALTER TABLE sign_up ADD UNIQUE INDEX `user_id`(`user_id`);`

    或者

    `CREATE UNIQUE INDEX user_id ON sign_up(user_id); `

    这种方式通过在数据库端来限制表中不得同时存在同一用户的多条数据,这种方式实现比较简单,推荐使用,但是通过抛异常的形式来实现功能,会损失部分效率。

     

    第四种方式: 使用`insert where not exists` 类型的语句来实现

    ```

    INSERT INTO sign_up (user_id, create_time) SELECT

        '123', NOW()

    FROM

        DUAL

    WHERE

        NOT EXISTS (

            SELECT

                user_id

            FROM

                sign_up

            WHERE

                user_id = '123'

        );

    ```

     

    这种方式,实现上将select 语句和insert语句合并到一起执行,避免了题中描述的并发问题,因为从实现上`insert`语句的执行依赖于`select`语句的查询结果

    ,从根本上就避免了题中涉及到的并发问题,使用这种方式调用端可以根据`SQL`执行影响的行数来判断是否插入成功,进而执行对应的业务逻辑

    ,这种方式普适性较强,推荐使用

     

    第五种方式,借助`redis`的`SETNX`方法来实现

    ```

    SETNX 是 ‘SET if Not eXists’的简称,命令格式大致如下:SETNX [key] [value].

    作用是:将指定的[key]的值设为[value],如果给定的key已经存在,则SETNX不做任何操作。

    设置成功,该方法返回1,设置失败,该方法返回0.

    ```

    借助`SETNX`命令,我们可以将题中的`select`语句改为该方式,根据`SETNX`的返回值来执行相应的业务逻辑。

    tips: 该方法需要注意redis的key值失效时间。

     

    上诉五种方式都可以解决该问题。

     

    问题产生的本质原因

     

    下面再简单聊一下,并发和事务的问题。

     

    事务有四大特性:A(原子性),C(一致性),I(隔离性),D(持久性)。

     

    其中

     

    - 原子性表示:事务所包含的所有操作,要么全部成功,要么全部失败。

     

    - 一致性表示:事务执行前后必须处于一致性状态。

     

    - 隔离性:当多个用户并发访问数据库的时候,多个并发线程相互隔离。

     

    - 持久性:事务一旦被提交,对数据库的改变是永久性的,即使数据库系统遭遇故障也不会丢失提交的事务。

     

    出现题中的问题,应该是混淆了原子性和隔离性的概念,原子性只是保证了事务中包含的操作要么同时成功,要么同时失败

    他并不会帮助我们处理业务代码中产生的并发问题,同理隔离性要求处理的是数据库并发,而不是业务并发

     

    在题中,业务代码内的两条SQL在没有配置唯一索引的场景下,并发时,并不会产生SQL执行失败的场景,两条语句默认都是成功的

    ,这也就意味着事务最终是提交(`COMMIT`)的,进而导致数据库出现两条数据。

     

    为了解决这种问题,我们的思路往往可以放在如何在业务层面将会出现并发问题的代码原子化,比如本文给出的解决方案,均是基于此而实现的。

     

    ######

    加锁处理、唯一索引、基于redis防止重复提交

    ######

    1.数据库的唯一索引

    2.如果不是分布式部署的话上java锁

    3.如果是分布式的话上基于redis的分布式锁

    4.最好用lock锁 锁代码就可 没必要锁整个方法

    2020-06-07 22:25:21
    赞同 1 展开评论 打赏
问答排行榜
最热
最新

相关电子书

更多
面向失败设计 立即下载
事务、全局索引、透明分布式 立即下载
“静态调用链路发现”应用场景分析及实践探索 立即下载