JAVA处理数据不存在插入存在更新

最近在做项目的时候碰到这样一个问题,做一个用户余额的需求。具体如下:
类似这样一张表:

CREATE TABLE `test_insert` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `token` varchar(10) NOT NULL DEFAULT '0' COMMENT '用户标志-唯一索引',
  `remark` varchar(20) NOT NULL DEFAULT '' COMMENT '备注描述',
  `balance` int(10) NOT NULL DEFAULT '0' COMMENT '余额',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_a` (`token`)
)

用户每次下单需要根据token查看用户在该表中是否有数据,若果有,就把它的balance增加相应的金额amount;如果没有就要在该表新增一条该用户的数据添加相应的余额。
但是在编码的时候注意到一个问题,由于我们系统中的并发是相当大的(日均达到近千万笔交易),对于一个用户也有比较高的并发情况。假设某个用户在表中没有记录,首笔交易还是并发过来的,都去进行insert操作会出问题。为防止这种情况,最开始的编码如下:

@Service
public class TestServiceImpl implements TestService {
    private static final String REDISKEY = "balance.insert.";
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Autowired
    private TestInsertDAO testInsertDAO;

    @Override
    public boolean addBalance(String token, Integer amount) {
        TestInsertDO testInsertDO = testInsertDAO.selectByToken(token);
        Long update = 0L;
        if (testInsertDO == null) {
            Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(REDISKEY + token, "1");
            stringRedisTemplate.expire(REDISKEY + token, 5, TimeUnit.SECONDS);
            if (aBoolean) {
                testInsertDO = new TestInsertDO();
                testInsertDO.setToken(token);
                testInsertDO.setRemark("111");
                testInsertDO.setBalance(amount);
                update = testInsertDAO.insert(testInsertDO);     //不存在就插入,balance是该笔订单的amount
                stringRedisTemplate.delete(REDISKEY + token);
            } else {
                update = testInsertDAO.updateByToken(token, amount);   //拿不到锁就直接更新
            }
        } else {
            update = testInsertDAO.updateByToken(token, amount);    //存在就直接更新
        }
        return update > 0;
    }
}

注意这里的updateByToken方法是在sql上做加操作:

    <operation name="updateByToken" paramtype="primitive" >
        UPDATE
        test_insert
        SET
        BALANCE = BALANCE + #{amount}
        WHERE
        token = #{token,jdbcType=VARCHAR}
    </operation>

这种方式只是利用了一下redis的分布式锁NX锁
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(REDISKEY + token, “1”);
来防止有两个线程同时去做插入操作,但是这个写法问题非常多:

  • 这个锁不是阻塞的,并发时有一个拿到了锁去走insert,另一个没拿到走了update,如果update走的比insert快就会造成update操作更新不到数据
  • stringRedisTemplate.delete(REDISKEY + token);这个解锁操作。如果解锁了后,正巧有个线程这个时候去拿锁,那它拿到了锁后又去执行insert操作,由于token有唯一索引会报错!

并发10笔的情况下,我们来看一下并发执行结果:
在这里插入图片描述在这里插入图片描述
数据库只加了9次,并且代码有一次因为唯一索引重复插入问题的报错!

对于这种方式,想了不少方法直接来改进但是都不是太好的方式,需要线程休眠或者其他的锁类型!

下面介绍几种方式来做优化:

1、利用mysql的on duplicate key update

网上百度了一下差不多都在推荐这种方式,只需要把insert语句改一下

    <operation name="insert" paramtype="object" remark="insert:TEST_INSERT">
        INSERT INTO TEST_INSERT(
        TOKEN
        ,REMARK
        ,BALANCE
        )VALUES(
        #{token,jdbcType=INTEGER}
        , #{remark,jdbcType=VARCHAR}
        , #{balance,jdbcType=INTEGER}
        )on duplicate key update BALANCE = BALANCE + #{amount}
    </operation>

这种方式的确最简单,但是在实践开发中并不建议使用这样的sql语句,首先这个是mysql特有的一个语句,可能有的持久层框架并不支持,另外这样的语句并不适合DBA来维护,其次,这种写法也有出现死锁的风险,详情参见:https://blog.csdn.net/pml18710973036/article/details/78452688

2、利用成熟的阻塞锁,比如redisson的

上面利用Jedis的分布式锁做的并发处理,其实最大的问题就是Jedis的NX锁是非阻塞的。利用redisson的阻塞锁可以更好地解决这个问题,编码如下:

public boolean addBalance(String token, Integer amount) {
        TestInsertDO testInsertDO = testInsertDAO.selectByToken(token);
        Long update = 0L;
        if (testInsertDO == null) {
            RLock lock = redisson.getLock("balanceinsert" + token);
            lock.lock(5, TimeUnit.SECONDS);
            testInsertDO = testInsertDAO.selectByToken(token); //双重检查;防止加锁过程中被插入数据
            if (testInsertDO == null) {
                testInsertDO = new TestInsertDO();
                testInsertDO.setToken(token);
                testInsertDO.setRemark("加余额");
                testInsertDO.setBalance(amount);
                update = testInsertDAO.insert(testInsertDO);     //不存在就插入,balance是该笔订单的amount
            } else {
                update = testInsertDAO.updateByToken(token, amount);  //有数据了就更新
            }
            lock.unlock();
        } else {
            update = testInsertDAO.updateByToken(token, amount);    //存在就直接更新
        }
        return update > 0;
    }

这里在获得锁之后再去检查一遍数据库的信息,防止在拿锁的过程中有信息被插入了!
关于redisson的分布式锁可以这个:https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers

3、利用唯一索引先插入默认值

这种方式不需要额外的工具来处理。上面的inert的时候把当前订单的余额一起插入了。现在我们分两步,先insert一个默认值,然后再update上该笔订单的amount,把insert异常捕捉,那么在并发时由于唯一索引的存在,一定是只有一条插入成功,其他的报错,捕捉异常后继续走update。

    public boolean addBalance(String token, Integer amount) {
        TestInsertDO testInsertDO = testInsertDAO.selectByToken(token);
        Long update = 0L;
        if (testInsertDO == null) {
            try {
                testInsertDO = new TestInsertDO();
                testInsertDO.setToken(token);
                testInsertDO.setBalance(0);
                testInsertDO.setRemark("加余额");
                update = testInsertDAO.insert(testInsertDO);     //不存在就插入,balance是该笔订单的amount
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        update = testInsertDAO.updateByToken(token, amount);    //存在就直接更新

        return update > 0;
    }

并发10条执行结果:
在这里插入图片描述
其实这种方式最简单易行,我们最后也采用的这种方式!其实就是把一步拆为2步。像这种并发的解决方案其实经常会遇到,多思考一下方案,跟同事多交流一下,发现代码世界还是无穷无尽的!

发布了4 篇原创文章 · 获赞 4 · 访问量 1419

猜你喜欢

转载自blog.csdn.net/weixin_40292704/article/details/86540220
今日推荐