以抢红包为例带你走进高并发编程

版权声明:转载注明出处 https://blog.csdn.net/czr11616/article/details/84346769

目录

1.写在前面

2.模拟超发现象

2.1 概述

2.2 数据库建表

2.3 编写SQL

2.4 编写Mapper接口

2.5 编写Service

2.6 编写Controller

2.7 模拟用户请求

2.8 测试超发现象

3.解决并发之-悲观锁

3.1 概述

3.2 改写Sql

3.3 测试悲观锁

3.4 悲观锁存在的问题

4.解决并发之-乐观锁

4.1 概述

4.2 改写sql

4.3 乐观锁重入机制

4.3.1 概述

4.3.2 代码实现

4.4 测试乐观锁

5.解决并发之-Redis+Lua

6.总结 


1.写在前面


本案例模拟的是5000个人同时抢红包池中的3000个红包的场景,以此案例来讨论高并发编程会遇到的问题以及如何解决。

本案例在全注解SSM环境中编写,关于如何搭建SSM,可以参考博主其他博客:

《全注解搭建SSM》https://blog.csdn.net/czr11616/article/details/84325586


2.模拟超发现象


2.1 概述

什么是超发现象?

在本案例下,红包池中一共只有3000个红包,5000个人同时抢,最后会出现有超过3000个人抢到了红包,红包池中的剩余红包数变成了负数,这就是超发现象

超发现象产生的原因?

先来看看抢红包的逻辑,在抢红包之前,需要判断红包池中是否还有红包,如果有,则红包池中的剩余红包数减1,并新增一条抢到红包的记录,如果没有,直接返回抢红包失败。

在这样的逻辑下,假设最后红包池中只有一个红包了,有5个人在同一时刻去抢,这时他们会同时判断红包池中是否还有红包,发现有,红包池中剩余红包数减一并新增抢红包记录。最后就会出现有3004个人抢到了红包,红包池中的红包数变成了负4,超发现象就这样产生了。

综上,超发现象产生的根本原因就是由于多线程操作同一条数据从而导致数据不一致。。

接下来模拟超发现象

2.2 数据库建表

创建红包池表 t_red_packet ,并插入红包数据,下面是建表和插入数据sql语句

-- ----------------------------
-- Table structure for t_red_packet
-- ----------------------------
DROP TABLE IF EXISTS `t_red_packet`;
CREATE TABLE `t_red_packet` (
  `id` int(12) NOT NULL AUTO_INCREMENT,
  `user_id` int(12) NOT NULL,
  `amount` decimal(16,2) NOT NULL,
  `send_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `total` int(12) NOT NULL,
  `unit_amount` decimal(12,0) NOT NULL,
  `stock` int(12) NOT NULL,
  `version` int(12) NOT NULL DEFAULT '0',
  `note` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

-- ----------------------------
-- Records of t_red_packet
-- ----------------------------
INSERT INTO `t_red_packet` VALUES ('1', '1', '30000.00', '2018-11-22 18:25:29', '3000', '10', '3000', '0', '3万元金额,3千个小红包,每个10元');

建好的红包池表如下图

其中amount为红包池总金额,total为红包池总红包数,unit_amount为每个红包金额,stock为红包池剩余红包数。

 创建用户抢红包表 t_user_red_packet

-- ----------------------------
-- Table structure for t_user_red_packet
-- ----------------------------
DROP TABLE IF EXISTS `t_user_red_packet`;
CREATE TABLE `t_user_red_packet` (
  `id` int(12) NOT NULL AUTO_INCREMENT,
  `red_packet_id` int(12) NOT NULL,
  `user_id` int(12) NOT NULL,
  `amount` decimal(16,2) NOT NULL,
  `grap_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `note` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=240 DEFAULT CHARSET=utf8;

建好的抢红包表如下:

red_packet_id为红包池id,user_id为抢到红包的用户id,amount为抢到的金额,grap_time为抢到红包的时间。 

2.3 编写SQL

根据之前描述的抢红包逻辑,在抢红包之前先检查红包池,如果有剩余,则红包池剩余红包数减一,并新增一条抢到红包的记录。所以一共有三条sql:查看红包池、更新红包池库存、新增抢到红包记录。

在你的红包池映射器配置文件中加入以下sql

查看红包池sql

<select id="getRedPacket" parameterType="long" resultType="com.ssm.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>

更新红包池库存sql

<update id="decreaseRedPacket" parameterType="long">
	update T_RED_PACKET set stock = stock-1 where id = #{id}
</update>

 在你的用户抢红包映射器配置文件中加入

新增抢到红包记录sql

<insert id="grapRedPacket" parameterType="com.ssm.pojo.UserRedPacket" useGeneratedKeys="true" keyProperty="id">
	insert into T_USER_RED_PACKET (red_packet_id,user_id,amount,grap_time,note) values (#{redPacketId},#{userId},#{amount},now(),#{note})
</insert>

2.4 编写Mapper接口

操作数据库除了映射器还需要接口,创建红包池接口(RedPacketDao)和用户抢红包接口(UserRedPacketDao)

 RedPacketDao:

package com.ssm.dao;

import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

import com.ssm.pojo.RedPacket;

@Repository
public interface RedPacketDao {

	/*
	 * 获取红包池信息
	 * @param id 红包池id
	 * @return 红包池具体信息
	 */
	public RedPacket getRedPacket(Long id);
	/*
	 * 更新红包池库存
	 * @param id -- 红包池id
	 * @return 更新记录条数
	 */
	public int decreaseRedPacket(Long id);
}

UserRedPacketDao:

package com.ssm.dao;

import org.springframework.stereotype.Repository;

import com.ssm.pojo.UserRedPacket;

@Repository
public interface UserRedPacketDao {

	/*
	 * 插入抢红包记录
	 * @param userRedPacket 抢红包记录
	 * @return 影响记录条数
	 */
	public int grapRedPacket(UserRedPacket userRedPacket);
}

2.5 编写Service

编写红包池service接口RedPacketService

根据抢红包逻辑,接口中编写了查看红包池信息和更新红包池库存方法

package com.ssm.service;

import com.ssm.pojo.RedPacket;

public interface RedPacketService {

	//查看红包池
	public RedPacket getRedPacket(Long id);
	//更新红包池库存
	public int decreaseRedPacket(Long id);
}

编写实现类RedPacketServiceImpl

package com.ssm.service.impl;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import com.ssm.dao.RedPacketDao;
import com.ssm.pojo.RedPacket;
import com.ssm.service.RedPacketService;

@Service
public class RedPacketServiceImpl implements RedPacketService {

	@Autowired
	private RedPacketDao redPacketDao = null;
	/*
	 * 获取红包池信息
	 * @param id 红包池id
	 * @return 红包池具体信息
	 */
	@Override
	//指定数据库事务的隔离级别和传播行为
	@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
	public RedPacket getRedPacket(Long id) {
		
		return redPacketDao.getRedPacket(id);
	}
	/*
	 * 更新红包池库存
	 * @param id -- 红包池id
	 * @return 更新记录条数
	 */
	@Override
	@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
	public int decreaseRedPacket(Long id) {
		int result = redPacketDao.decreaseRedPacket(id);
		return result;
	}

}

编写用户抢红包接口UserRedPacketService

package com.ssm.service;

public interface UserRedPacketService {

	/*
	 * 用户抢红包
	 * @param redPacketId 红包池id
	 * @param userId 用户id
	 * @return 抢红包是否成功
	 */
	public int grapRedPacket(Long redPacketId,Long userId);

}

编写用户抢红包接口实现类UserRedPacketServiceImpl

根据抢红包逻辑,在抢之前,先判断红包池中是否有红包,若没有,直接返回失败,若有,红包池库存减一并新增一条抢红包记录。

package com.ssm.service.impl;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import com.ssm.dao.RedPacketDao;
import com.ssm.dao.UserRedPacketDao;
import com.ssm.pojo.RedPacket;
import com.ssm.pojo.UserRedPacket;
import com.ssm.service.UserRedPacketService;

@Service
public class UserRedPacketServiceImpl implements UserRedPacketService {
	
	@Autowired
	private RedPacketDao redPacketDao = null;

	@Autowired
	private UserRedPacketDao userRedPacketDao = null;
	
	private static final int FAILED = 0;
	/*
	 * 用户抢红包方法
	 * @param redPacketId 红包池id
	 * @param userId 用户id
	 * @return 抢红包成功或失败
	 */
	@Override
	@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
	public int grapRedPacket(Long redPacketId, Long userId) {
		
		//获取红包池信息
		RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
		//判断红包池中是否有剩余红包
		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);
			//返回插入记录数1,即成功
			return result;
		}
		//若没有库存直接返回失败
		return FAILED;
	}
}

2.6 编写Controller

 核心逻辑写完了,接下来该写和前端交互的controller,controller中调用了用户抢红包的接口UserRedPacketService 

package com.ssm.controller;

import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.ssm.service.UserRedPacketService;

@Controller
//配置请求路径
@RequestMapping("/userRedPacket")
public class UserRedPacketController {
	
	@Autowired
	private UserRedPacketService userRedPacketService = null;
	//配置请求路径
	@RequestMapping("/grapRedPacket")
	@ResponseBody
	public Map<String,Object> grapRedPacket(Long redPacketId,Long userId){
		
		//调用抢红包接口
		int result = userRedPacketService.grapRedPacket(redPacketId, userId);
            //根据接口返回值给前端返回抢红包是否成功
		Map<String,Object> resultMap = new HashMap<String,Object>();
		boolean flag = result > 0;
		resultMap.put("success", flag);
		resultMap.put("message", flag?"抢红包成功":"抢红包失败");
		return resultMap;
	}

}

2.7 模拟用户请求

controller写好了,接下来根据controller中配置的请求路径模拟5000个用户同时抢红包的场景

<input type="button" value="开始抢红包" onclick="grapRedPacket()">
<script type="text/javascript">
	function grapRedPacket(){
		//模拟5000个异步请求,进行并发
		var max = 5000;
		for(var i=1;i<=max;i++){
			$.post({
				url:"../userRedPacket/grapRedPacket.do?redPacketId=1&userId="+i,
				success:function(result){	
				}
			})
		}
	}
</script>

2.8 测试超发现象

点击页面按钮开始抢红包,稍等片刻,观察数据库中红包池表

你会惊奇的发现红包池中的库存变成了负数,没错,超发现象产生了,我们再来看看用户抢红包表:

果然有3002个用户抢到了红包,这更加验证了超发现象的产生,那么怎么解决超发现象呢?


3.解决并发之-悲观锁


3.1 概述

什么是悲观锁?

顾名思义,就是悲观的认为会发生并发冲突,因此,在数据处理时会给数据库中的数据加锁,避免其他线程操作数据,直到本线程事务提交并释放锁,其他线程才能操作数据。

悲观锁的实现方式?

我们知道悲观锁是给数据库数据加锁,所以悲观锁是依靠数据库提供的锁机制,我们往往通过sql语句给指定的数据库记录加锁,例如sql语句:select * from table_name where id = 1 for update,加上for update后,数据库执行sql时就会给id=1的这条记录上锁,我们就称执行此sql语句的事务持有了悲观锁,事务提交后锁才会释放,释放之前,其他事务不能操作id=1的这条记录。

知道了悲观锁的概念以后,我们来讲一讲如何解决之前的红包超发现象。

3.2 改写Sql

回顾之前的抢红包逻辑,第一步是查看红包池信息判断是否还有剩余红包,如果我们在这条查询sql中加锁,那么其他线程就会等待该事务最后抢完红包并提交以后才继续执行,就避免了并发操作数据的问题,下面改写这条查询语句:

<select id="getRedPacketForUpdate" parameterType="long" resultType="com.ssm.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>

当然还需要在RedPacketDao接口中去创建对应的方法去执行这条sql。

3.3 测试悲观锁

加入悲观锁后进行测试:

红包池表:

用户抢红包表:

可以看到超发现象解决了,红包刚刚好发完。

3.4 悲观锁存在的问题

 当一条线程操作数据库的时候给数据库记录上锁,其他的线程就会被挂起,等待该线程中的事务提交,事务提交以后锁释放,被挂起的线程恢复运行,开始抢夺cpu执行权,抢到的线程执行数据库操作的时候又上锁,其他线程又都挂起.......这样线程频繁地挂起释放是非常消耗资源的,也会影响性能

那么有什么其他解决办法呢?下面来讲乐观锁


4.解决并发之-乐观锁


4.1 概述

什么是乐观锁?

与悲观锁不同,乐观锁是乐观的认为不会发生并发冲突,所以并不会像悲观锁那样“占着数据不放,给数据上锁”,其他线程也可以操作数据,但是乐观锁机制会在最后更新数据的时候比较当前的数据和之前的数据是否一致,如果不一致,那么就代表这条数据被其他线程修改过,则放弃更新,如果一致,则完成更新

如何实现乐观锁?

我们通常会给数据库表添加一个版本号字段(version),用于标示记录的版本,每当有事务修改记录的时候,版本号就加1。因此我们在更新数据库记录的时候就可以比较当前记录的版本号与之前的版本号是否一致,从而判断该记录是否被修改过,如果没有修改过,则完成更新同时给版本号加1。

4.2 改写sql

根据之前的描述我们来改写更新红包池的sql语句,通过更新执行结果来判断是否需要插入抢红包成功记录。

<update id="decreaseRedPacketForVersion">
	update T_RED_PACKET set stock = stock-1,version = version + 1
		where id = #{id} and version = #{version}
</update>

这里通过传递之前记录的版本号来判断是否需要更新库存和版本号。

4.3 乐观锁重入机制

4.3.1 概述

 根据之前的描述,如果通过版本号判断数据被修改过,则会放弃更新。那么就会出现一个问题,在高并发的场景下,会出现很多并发修改数据的情况,都会放弃更新,那么最后就会导致有很多失败的抢红包请求,5000个人抢3000个红包,红包没有被抢完,怎么解决呢?

在一次抢红包过程中发现剩余红包数被其他线程修改,并不会放弃,而会继续去抢,这种更新失败后继续判断的机制我们称为乐观锁重入机制,我们通过指定重试的次数来提高成功率。

4.3.2 代码实现

public int grapRedPacketForVersion(Long redPacketId, Long userId) {
		
		for(int i=0;i<3;i++) {
			//获取红包池信息
			RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
			//判断红包池中是否还有剩余红包
			if(redPacket.getStock() > 0 ) {
				//通过版本号判断数据库记录是否被修改过,如果被修改过,继续抢
				int IsChange = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
				if(IsChange == 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;
	}

4.4 测试乐观锁

点击前端抢红包按钮观察数据库:

 


 

 可以看到没有出现超发现象并且红包也被抢完了。


5.解决并发之-Redis+Lua


由于这里涉及到许多其他的知识点,我会新开一篇博客去讲,看这里:

《暂时没有啊哈哈哈哈哈》

在这里我们先讲一下Redis+Lua为什么可以解决并发问题,因为Redis本身是单线程的,这意味着每次只会有一条线程去执行redis命令,从而也就不会有并发问题,而Lua脚本的执行又是原子性的,这意味着你可以把对数据的操作写在Lua脚本里,从而保证脚本里的数据库操作可以同时成功或失败。

有读者可能会有疑问,Redis如此注重性能,为什么Redis是单线程的呢?难道单线程比多线程更快吗?对于Redis来说,单线程的确比多线程更快,为什么呢?看这里:

《Redis核心技术---单线程》https://blog.csdn.net/czr11616/article/details/84483130


6.总结 


我们通过三种方式去处理并发问题,分别为悲观锁、乐观锁和Redis+Lua,那么这三者有什么优劣呢?

对于悲观锁来说,由于是对数据库记录加锁,导致会有大量线程被阻塞,而且需要有大量的恢复过程,这非常的消耗资源,且数据库性能有所下降。

对于乐观锁来说,虽然不会有线程的阻塞,但是为了提高成功率,需要实现重入机制,这会导致大量多余的sql被执行,这对于数据库的性能要求比较高,容易引起数据库的瓶颈,且因为需要手动编写重入代码,开发难度加大。

对于Redis+Lua呢,首先它非常快,而且极大地减轻了数据库的负担,但是由于Redis是基于内存的数据库,不是很稳定,有可能发生宕机。

猜你喜欢

转载自blog.csdn.net/czr11616/article/details/84346769