这个城市不是我们的故乡,却有我们的主场
前言:高并发是互联网避免不了的一个问题,电商的秒杀,春运的抢票,微信群的抢红包,各大企业有自己的架构来保证数据的安全,本篇博客带你入门高并发和锁。采用抢红包让你明白在具体案例中锁的应用,以及常用的中间件Redis在高并发场景中扮演的角色,各种技术的结合是如何确保数据的一致性,以及性能的提升。我们需要明白的是没有最好的技术,只有最适合的,各位,让我们开始 想象一下,30000(3万)个人抢20000(2万)个红包的情景
目录
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服务器做高并发业务,当然集群更好,避免宕机
妥善规避风险,保证系统的高可用和高效。