一篇文章带你入门 锁和高并发

这个城市不是我们的故乡,却有我们的主场                      

前言:高并发是互联网避免不了的一个问题,电商的秒杀,春运的抢票,微信群的抢红包,各大企业有自己的架构来保证数据的安全,本篇博客带你入门高并发和锁。采用抢红包让你明白在具体案例中锁的应用,以及常用的中间件Redis在高并发场景中扮演的角色,各种技术的结合是如何确保数据的一致性,以及性能的提升。我们需要明白的是没有最好的技术,只有最适合的,各位,让我们开始 想象一下30000(3万)个人抢20000(2万)个红包的情景

目录

1. 数据不一致性(具体案例)

2. 悲观锁 抢红包

3. 乐观锁

3.1 CAS原理概述

3.2 ABA问题

3.2.1 加入版本号解决ABA问题

3.3 乐观锁实现抢红包业务

3.3.1 CAS原理实例抢红包

3.3.2 乐观锁重入机制一 抢红包

3.3.3 乐观锁重入机制二 抢红包

4.Redis 实现抢红包

5. 对比分析


1. 数据不一致性(具体案例)

一个例子秒懂高并发容易产生的数据不一致问题。

以抢红包为例子,发放一个20万元的红包,拆分为2万个可抢的红包,每个红包都是10元,来供网站会员抢夺,网站会员同时有3万人抢夺红包。这会出现 多个线程 同时享有 红包数据的场景,由于线程每一步访问的顺序都不一样,就会导致数据的不一致性问题。比如抢夺最后一个红包,就可能出现以下场景:

高并发可能出现红包错扣,导致数据错误。任何系统的核心就是两点:性能和数据一致。为了保证数据一致,我们可以加锁,但是加锁影响并发,不加难以保证数据一致性,这就是高并发和锁矛盾

首先表的结构如下(重要的是理解锁和高并发)

T_RED_PACKET  红包表

T_USER_RED_PACKET   用户抢夺红包记录表

不加锁,超发红包演示

RedPacket.xml中

<!-- 查询红包具体信息 -->
<select id="getRedPacket" parameterType="long"
   resultType="com.ssm.chapter22.pojo.RedPacket">
   select id, user_id as userId, amount, send_date as
   sendDate, total,
   unit_amount as unitAmount, stock, version, note from
   T_RED_PACKET
   where id = #{id}
</select>

<!-- 扣减抢红包库存 -->
<update id="decreaseRedPacket">
   update T_RED_PACKET set stock = stock - 1 where id =
   #{id}
</update>

userRedPacket.xml 中

<!-- 插入抢红包信息 -->
<insert id="grapRedPacket" useGeneratedKeys="true" 
keyProperty="id" parameterType="com.ssm.chapter22.pojo.UserRedPacket">
 insert into T_USER_RED_PACKET( red_packet_id, user_id, amount, grab_time, note)
 values (#{redPacketId}, #{userId}, #{amount}, now(), #{note}) 
</insert>

service中的方法

@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
    public int grapRedPacket(Long redPacketId, Long userId) {
        // 获取红包信息
        RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
        // 悲观锁
        //RedPacket redPacket = redPacketDao.getRedPacketForUpdate(redPacketId);
        // 当前小红包库存大于0
        if (redPacket.getStock() > 0) {
            redPacketDao.decreaseRedPacket(redPacketId);
            // 生成抢红包信息
            UserRedPacket userRedPacket = new UserRedPacket();
            userRedPacket.setRedPacketId(redPacketId);
            userRedPacket.setUserId(userId);
            userRedPacket.setAmount(redPacket.getUnitAmount());
            userRedPacket.setNote("抢红包 " + redPacketId);
            // 插入抢红包信息
            int result = userRedPacketDao.grapRedPacket(userRedPacket);
            return result;
        }
        // 失败返回
        return FAILED;
    }

测试方法:采用JavaScript模仿3万人同时抢红包,建议使用火狐来测试,chrome丢失了好多包

33秒完成20005个红包的抢夺,性能还不错。但是出现了超发现象

此类问题,当前互联网主要采用悲观锁和乐观锁来处理,当然这两种方法的性能是不一样的。

2. 悲观锁 抢红包

 悲观锁是一种利用数据库内部机制提供的锁方法,也就是对更新的数据加锁,这样在并发期间一旦有一个事务持有了数据库记录的锁,其他的线程将不能再对数据进行更新,这就是悲观锁的实现方式。

抢红包的逻辑是 : 先查询红包信息 , 再进行 更新。我们在 查询红包信息时采用悲观锁,后续的更新操作 就不会出现 数据不一致的情况。

    <!-- 查询红包具体信息   采用悲观锁-->
    <select id="getRedPacketForUpdate" parameterType="long"
        resultType="com.ssm.chapter22.pojo.RedPacket">
        select id, user_id as userId, amount, send_date as
        sendDate, total,
        unit_amount as unitAmount, stock, version, note
        from
        T_RED_PACKET where id = #{id} for update
    </select>

SQL中加入的  for update 语句,意味着将持有对数据库记录的 行更新锁(因为这里使用主键查询,所以只会对行加锁)。如果使用的是非主键查询,要考虑是否对全表进行加锁,加锁后可能引起其他查询的阻塞),这意味着在高并发的场景下,当一条事务有了这个更新锁才能往下操作,其他的线程如果要更新这条记录,都需要等待。---------保证数据一致性

结果:

花费大概44秒完成2万个红包的抢夺。

需要注意的是目前只是对数据库加了一个锁,当加的锁比较多时,数据库的性能还会持续下降。悲观锁被称为独占锁,只有1个线程可以独占这个资源,造成其他线程堵塞。大量的线程被挂起和恢复,这将十分消耗资源。无论如何悲观锁都会造成并发能力的下降。为了解决这个问题,乐观锁机制产生了。


3. 乐观锁

乐观锁的原理是CAS原理,CAS原理有一个问题,ABA问题

3.1 CAS原理概述

 https://blog.csdn.net/qq_34337272/article/details/81072874

3.2 ABA问题

ps: 看了好多文章,具体的ABA问题没有具体的场景是很难说明白的,看了下面的表格,你将会一清二楚

 T6时刻,采用CAS原理的旧值进行了判断,线程1会认为X值没有被修改过,于是执行了更新。但是 我们难以判断的是 在T4时刻,线程1在X=B的时候,对于线程1的业务逻辑是否正确的问题。由于X在线程2的值改变的过程为 A->B->A ,才引发这样的问题,因此人们形象的称这类问题为ABA问题。、

理解ABA问题有个简单的例子,好比你在银行存了10000元,但是银行的工作人员给拿出去偷偷投资了,虽然最后还回去了,10000元还在银行,但是不在银行的这段时间是非法的

3.2.1 加入版本号解决ABA问题

ABA问题的发生,是因为业务逻辑存在回退的可能性,如果加入一个非业务逻辑的属性,比如在一个数据中加入版本号(version),对于版本号有一个约定,就是只要修改X变量的值,强制版本号(version) 只能递增,而不会回退,即使是其他业务数据回退,这个版本号也只会递增,如下图所示,解决ABA问题

3.3 乐观锁实现抢红包业务

3.3.1 CAS原理实例抢红包

<!-- 通过版本号扣减抢红包 每更新一次,版本增1, 其次增加对版本号的判断 -->
<update id="decreaseRedPacketForVersion">
   update T_RED_PACKET
   set stock = stock - 1,
   version = version + 1
   where id = #{id}
   and version = #{version}
</update>

 在扣减红包的时候,增加了对版本号的判断,其次每次扣减都会对版本号加1,这样保证每次更新在版本号上有记录,从而避免ABA问题。对于查询也不使用 for update 语句,避免锁的发生,这样就没有线程阻塞的问题

// 乐观锁,无重入
@Override
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
   // 获取红包信息,注意version值(普通查询,没有 for update语句)
   RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
   // 当前小红包库存大于0
   if (redPacket.getStock() > 0) {
      // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
      int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
      // 如果没有这个版本号的记录,则说明其他线程已经修改过数据,本次抢红包失败
      if (update == 0) {
         return FAILED;
      }
      // 生成抢红包信息
      UserRedPacket userRedPacket = new UserRedPacket();
      userRedPacket.setRedPacketId(redPacketId);
      userRedPacket.setUserId(userId);
      userRedPacket.setAmount(redPacket.getUnitAmount());
      userRedPacket.setNote("抢红包 " + redPacketId);
      // 插入抢红包信息
      int result = userRedPacketDao.grapRedPacket(userRedPacket);
      return result;
   }
   // 失败返回
   return FAILED;
}

version值一开始就保存到了对象中,当扣减的时候,再次传递给SQL(看上面代码的注释 你就会明白),让SQL对数据库的version和当前线程的旧值version进行比较。如果一致就插入抢红包的数据,否则就不进行操作。

结果:

可以看到33秒完成所有抢红包的功能,但是存在大量的红包因为版本并发的原因没有被抢到,而且这个概率非常高,为了提高成功率,使用重入机制。

3.3.2 乐观锁重入机制一 抢红包

也就是一旦因为版本原因没有抢到红包,就重新进行尝试,但是过多的重入会造成大量的SQL执行,所以目前流行的冲入会加入两种限制,一种是按 时间戳的重入(比如说100毫秒),不成功的会循环到成功为止,直至超过时间戳,不成功才会退出,返回失败。

// 乐观锁,按时间戳重入
    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED, propagation =
            Propagation.REQUIRED)
    public int grapRedPacketForVersion(Long redPacketId, Long userId) {
        // 记录开始时间
        long start = System.currentTimeMillis();
        // 无限循环,等待成功或者时间满100毫秒退出
        while (true) {
            // 获取循环当前时间
            long end = System.currentTimeMillis();
            // 当前时间已经超过100毫秒,返回失败
            if (end - start > 100) {
                return FAILED;
            }
            // 获取红包信息,注意version值
            RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
            // 当前小红包库存大于0
            if (redPacket.getStock() > 0) {
                // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
                int update = redPacketDao.decreaseRedPacketForVersion(redPacketId,
                        redPacket.getVersion());
                // 如果没有这个版本号的记录,则说明其他线程已经修改过数据,则重新抢夺
                if (update == 0) {
                    continue;
                }
                // 生成抢红包信息
                UserRedPacket userRedPacket = new UserRedPacket();
                userRedPacket.setRedPacketId(redPacketId);
                userRedPacket.setUserId(userId);
                userRedPacket.setAmount(redPacket.getUnitAmount());
                userRedPacket.setNote("抢红包 " + redPacketId);
                // 插入抢红包信息
                int result = userRedPacketDao.grapRedPacket(userRedPacket);
                return result;
            } else {
                // 一旦没有库存,则马上返回
                return FAILED;
            }
        }
    }

3.3.3 乐观锁重入机制二 抢红包

按照次数,比如限定3次,程序尝试超过3次抢红包后,就判断为请求失败

//乐观锁,按次数重入
    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED, propagation =
            Propagation.REQUIRED)

    public int grapRedPacketForVersion(Long redPacketId, Long userId) {
        for (int i = 0; i < 3; i++) {
            // 获取红包信息,注意version值
            RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
            // 当前小红包库存大于0
            if (redPacket.getStock() > 0) {
                // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
                int update = redPacketDao.decreaseRedPacketForVersion(redPacketId,
                        redPacket.getVersion());
                // 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
                if (update == 0) {
                    continue;
                }
                // 生成抢红包信息
                UserRedPacket userRedPacket = new UserRedPacket();
                userRedPacket.setRedPacketId(redPacketId);
                userRedPacket.setUserId(userId);
                userRedPacket.setAmount(redPacket.getUnitAmount());
                userRedPacket.setNote("抢红包 " + redPacketId);
                // 插入抢红包信息
                int result = userRedPacketDao.grapRedPacket(userRedPacket);
                return result;
            } else {
                // 一旦没有库存,则马上返回
                return FAILED;
            }
        }
        return FAILED;
    }

这个是限定3次的结果,用了将近1分钟才执行完20000条数据,效果不太好,不知道是不是因为我机器的原因。

4.Redis 实现抢红包

如果我们不想用数据库作为抢红包时刻的数据库保存载体,那么Redis就是你的不二之选。虽然Redis的事务不是原子性的,但是Redis的Lua语言是原子性的,为此我们可以绕开数据库,用Redis来处理高并发的请求。

但是你不可能将红包数据永远放在Redis中,因为Redis强大的是缓存,而不是存储,所以当红包库存为0或者红包超时时,将红包数据存储到数据库中,这样才能保证数据的安全性和严格性。

// Lua脚本
    String script = "local listKey = 'red_packet_list_'..KEYS[1] \n"
            + "local redPacket = 'red_packet_'..KEYS[1] \n"
            + "local stock = tonumber(redis.call('hget', redPacket, 'stock')) \n"
            + "if stock <= 0 then return 0 end \n"
            + "stock = stock -1 \n"
            + "redis.call('hset', redPacket, 'stock', tostring(stock)) \n"
            + "redis.call('rpush', listKey, ARGV[1]) \n"
            + "if stock == 0 then return 2 end \n"
            + "return 1 \n";

    // 在缓存LUA脚本后,使用该变量保存Redis返回的32位的SHA1编码,使用它去执行缓存的LUA脚本[加入这句话]
    String sha1 = null;

	@Override
	public Long grapRedPacketByRedis(Long redPacketId, Long userId) {
		// 当前抢红包用户和日期信息
		String args = userId + "-" + System.currentTimeMillis();
		Long result = null;
		// 获取底层Redis操作对象
		Jedis jedis = (Jedis) redisTemplate.getConnectionFactory().getConnection().getNativeConnection();
		try {
			// 如果脚本没有加载过,那么进行加载,这样就会返回一个sha1编码
			if (sha1 == null) {
				sha1 = jedis.scriptLoad(script);
			}
			// 执行脚本,返回结果
			Object res = jedis.evalsha(sha1, 1, redPacketId + "", args);
			result = (Long) res;
			// 返回2时为最后一个红包,此时将抢红包信息通过异步保存到数据库中
			if (result == 2) {
				// 获取单个小红包金额
				String unitAmountStr = jedis.hget("red_packet_" + redPacketId, "unit_amount");
				// 触发保存数据库操作
				Double unitAmount = Double.parseDouble(unitAmountStr);
				System.err.println("thread_name = " + Thread.currentThread().getName());
				redisRedPacketService.saveUserRedPacketByRedis(redPacketId, unitAmount);
			}
		} finally {
			// 确保jedis顺利关闭
			if (jedis != null && jedis.isConnected()) {
				jedis.close();
			}
		}
		return result;
	}

 流程:

判断是否存在可抢红包,没有 返回0,结束流程。

有红包,红包库存减1,重新设置库存。

将抢红包数据保存到Redis的链表中,链表的Key为 red_packet_list_{id}

如果当前库存为0,则返回2,触发数据库对Redis链表数据的保存(保存链表数据到数据库是一个大数据量的保存,为了不影响最后一次抢红包的响应,所以我们创建一个新的线程去运行保存 异步保存,这样保存链表数据和抢最后一个红包 线程分离,体验较好),链表的key为red_packet_list_{id},value为 userId-系统时间

如果当前库存不为20,那么将返回1,这说明抢红包信息保存成功。



@Service
public class RedisRedPacketServiceImpl implements RedisRedPacketService {

    private static final String PREFIX = "red_packet_list_";
    // 每次取出1000条,避免一次取出消耗太多内存
    private static final int TIME_SIZE = 1000;

    @Autowired
    private RedisTemplate redisTemplate = null; // RedisTemplate

    @Autowired
    private DataSource dataSource = null; // 数据源

    @Override
    // 开启新线程运行
    @Async
    public void saveUserRedPacketByRedis(Long redPacketId, Double unitAmount) {
        Long start = System.currentTimeMillis();
        System.err.println(start+"开始保存数据");
        // 获取列表操作对象
        BoundListOperations ops = redisTemplate.boundListOps(PREFIX + redPacketId);
        Long size = ops.size();
        Long times = size % TIME_SIZE == 0 ? size / TIME_SIZE : size / TIME_SIZE + 1;
        int count = 0;
        List<UserRedPacket> userRedPacketList = new ArrayList<UserRedPacket>(TIME_SIZE);
        for (int i = 0; i < times; i++) {
            long l = System.currentTimeMillis();
            // 获取至多TIME_SIZE个抢红包信息
            List userIdList = null;
            if (i == 0) {
                userIdList = ops.range(i * TIME_SIZE, (i + 1) * TIME_SIZE);
            } else {
                userIdList = ops.range(i * TIME_SIZE + 1, (i + 1) * TIME_SIZE);
            }
            userRedPacketList.clear();
            // 保存红包信息
            for (int j = 0; j < userIdList.size(); j++) {
                String args = userIdList.get(j).toString();
                String[] arr = args.split("-");
                String userIdStr = arr[0];
                String timeStr = arr[1];
                Long userId = Long.parseLong(userIdStr);
                Long time = Long.parseLong(timeStr);
                // 生成抢红包信息
                UserRedPacket userRedPacket = new UserRedPacket();
                userRedPacket.setRedPacketId(redPacketId);
                userRedPacket.setUserId(userId);
                userRedPacket.setAmount(unitAmount);
                userRedPacket.setGrabTime(new Timestamp(time));
                userRedPacket.setNote("抢红包 " + redPacketId);
                userRedPacketList.add(userRedPacket);
            }
            // 插入抢红包信息
            count += executeBatch(userRedPacketList);
            long l1 = System.currentTimeMillis();
            System.out.println(l1 - l + "毫秒结束了1000条数据的插入");
        }
        System.err.println(System.currentTimeMillis()-start + "结束,当然这个结束 没有删除redis中的数据");
        // 删除Redis列表
        redisTemplate.delete(PREFIX + redPacketId);
        Long end = System.currentTimeMillis();
        System.err.println("保存数据结束,耗时" + (end - start) + "毫秒,共" + count + "条记录被保存。");
    }

    /**
     * 使用JDBC批量处理Redis缓存数据.
     *
     * @param userRedPacketList -- 抢红包列表
     * @return 抢红包插入数量.
     */
    private int executeBatch(List<UserRedPacket> userRedPacketList) {
        Connection conn = null;
        Statement stmt = null;
        int[] count = null;
        try {
            conn = dataSource.getConnection();
            conn.setAutoCommit(false);
            stmt = conn.createStatement();
            for (UserRedPacket userRedPacket : userRedPacketList) {
                String sql1 =
                        "update T_RED_PACKET set stock = stock-1 where id=" + userRedPacket.getRedPacketId();
                DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                String sql2 = "insert into T_USER_RED_PACKET(red_packet_id, user_id, " + "amount, " +
                        "grab_time, note)"
                        + " values (" + userRedPacket.getRedPacketId() + ", " + userRedPacket.getUserId() + ", "
                        + userRedPacket.getAmount() + "," + "'" + df.format(userRedPacket.getGrabTime()) +
                        "'," + "'"
                        + userRedPacket.getNote() + "')";
                stmt.addBatch(sql1);
                stmt.addBatch(sql2);
            }
            System.out.println("即将开始批量查询");
            // 执行批量
            count = stmt.executeBatch();
            // 提交事务
            conn.commit();
        } catch (SQLException e) {
            /********* 错误处理逻辑 ********/
            throw new RuntimeException("抢红包批量执行程序错误");
        } finally {
            try {
                if (conn != null && !conn.isClosed()) {
                    conn.close();
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
        // 返回插入抢红包数据记录
        // count 记录的是更新红包表和用户抢红包表记录 的 数据记录数,所以/2 就是一个表中更新的条数
        System.out.println("一共更新了" + count.length / 2);
        return count.length / 2;
    }
}

注解:@Async表示让Spring自动创建另外一条线程去运行方法,达到异步运行方法

结果:24秒抢夺完成2万个红包,24秒中 更新了红包表2万次,插入了2万条抢红包记录。火狐24秒,chrome17秒,不知道为啥这么长时间。方法中已经采用了 JDBC的批处理 ,按理说4秒即可完成所有工作,试了好多次,就先这样,以后再解决。

5. 对比分析

悲观锁使用了数据库的锁机制,消除了数据的不一致性,但是因为大量的线程被挂起,需要有大量的恢复过程,需要进一步改变算法来提高系统的并发能力。

乐观锁的原理CAS,以及如何解决ABA问题,提高了并发性能。但是版本号的冲突,导致服务失败概率提高,我们不得不加入重入(按照时间戳和重入次数限定)来提高成功的概率,这并不稳定。使用乐观锁的弊端在于,导致大量的SQL被执行,对于数据库的性能比较高,容易引起数据库性能的瓶颈,开发人员要考虑重入,开发难度加大。

Redis是缓存,性能是足够优秀的,但是不稳定也是存在的,Redis本身不是原子性的,我们借助了LUA脚本来保证原子性,整个过程只有最后一次的请求会涉及数据库,所以我们新开启了一个线程,达到异步的目的。实际开发中应该采用消息队列,通知另一个服务器开始保存数据,因为Redis的事务和存储都是不稳定的。更多的时候,我们都是使用单独的Redis服务器做高并发业务,当然集群更好,避免宕机

妥善规避风险,保证系统的高可用和高效。

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值