乐观锁重入机制
使用乐观锁造成大量的更新失败的问题,使用时间戳执行乐观锁重入,是一种提高成功率的方法,比如考虑在100毫秒内允许重入,把UserRedPacketServiceImpl中的方法grapRedPacketForVersion修改为如下代码:
/ *
* 乐观锁重入机制,时间戳限制
* /
long startTime = System.currentTimeMillis();
//记录开始时间
long start=startTime;
//无限循环,等待 成功或者满100毫秒退出
while(true) {
//获取循环当前时间
long end=System.currentTimeMillis();
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:"+redPacketId);
//插入抢红包信息
int result=userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}else {
long endTime = System.currentTimeMillis();
//输出程序运行时间
System.out.println("抢红包程序运行时间:" + (endTime - startTime) + "ms");
//失败返回
return FAILED;
}
}
当因为版本号原因更新失败的时候,会重新尝试抢红包,但是会实现判断时间戳,如果时间戳在100毫秒之内,就继续,否则就不再重新尝试,这样可以避免过多sql执行,维持系统稳定,但是有的时候时间戳也不是那么稳定,也会随着系统的空闲或者繁忙导致重试次数不一,有时候我们会考虑重试次数,比如3次,下面在改写上一个方法,如下:
/*
* 乐观锁重入机制,次数限制
* /
long startTime = System.currentTimeMillis();
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:"+redPacketId);
//插入抢红包信息
int result=userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}else {
long endTime = System.currentTimeMillis();
//输出程序运行时间
System.out.println("抢红包程序运行时间:" + (endTime - startTime) + "ms");
//失败返回
return FAILED;
}
}
return FAILED;
通过for循环限定重试3次,3次过后无论失败与否都会判定为失败而退出,这样就能避免过多的重试导致过多的sql被执行的问题,从而保证数据库的性能。进行了上述两种方法的测试,结果如下:
时间戳限制:抢红包程序运行时间:909231ms
次数限制:抢红包程序运行时间:311098ms
显然,使用次数限制的重入乐观锁取得了很好的效果,没有超发但是会有有抢红包失败!
还是没怎么解决高概率失败现象。因机器而异吧。
但是现在是使用数据库的情况,有时候并不想使用数据库作为抢红包的数据保存载体,而是选择性能优于数据库的redis。明天将使用redis处理高并发的请求。
开发设计
使用Redis实现抢红包
数据库最终会将数据保存到磁盘中,而Redis使用的是内存,内存的速度要比磁盘的速度快的多,所以这里将谈论使用Redis实现抢红包。
对于使用Redis实现抢红包,首先需要知道的是Redis功能不如数据库强大,事务也不完整,因此要保证数据的正确性,数据的正确性可以通过严格地验证进行保证。而Redis的Lua语言是原子性的,且功能更为强大,所以优先选择使用Lua语言来实现抢红包。但是无论如何对于数据而言,在Redis当中存储,始终都不是长久之计,因为Redis并非一个长久存储数据的地方,它存储的数据是非严格和安全的环境,更多的时候只是为了提供更多的缓存,所以当红包金额为0或者红包超时的时候(超时操作可以使用定时机制实现),会将红包数据保存到数据库中,这样才能保证数据的安全性和严格性。
使用注解方式配置Redis
想要使用
接下来进行编码,
首先在RootConfig上创建一个RedisTemplate对象,并将其装载到Spring Ioc容器中,如下:
@Bean(name="redisTemplate")
public RedisTemplate initRedisTemplate() {
JedisPoolConfig poolConfig=new JedisPoolConfig();
//最大空闲数
poolConfig.setMaxIdle(50);
//最大连接数
poolConfig.setMaxTotal(100);
//最大等待毫秒数
poolConfig.setMaxWaitMillis(20000);
//创建Jedis链接工厂
JedisConnectionFactory connectionFactory=new JedisConnectionFactory(poolConfig);
connectionFactory.setHostName("localhost");
connectionFactory.setPort(6379);
//调用初始化方法,没有它将抛出异常
connectionFactory.afterPropertiesSet();
//自定义Redis序列化器
RedisSerializer jdkSerializationRedisSerializer=new JdkSerializationRedisSerializer();
RedisSerializer stringRedisSerializer=new StringRedisSerializer();
//定义RedisTemplate,并设置连接工厂
RedisTemplate redisTemplate=new RedisTemplate();
redisTemplate.setConnectionFactory(connectionFactory);
//设置序列化器
redisTemplate.setDefaultSerializer(stringRedisSerializer);
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
redisTemplate.setHashValueSerializer(stringRedisSerializer);
return redisTemplate;
}
这样RedisTemplate就可以在Spring上下文中使用了。注意,JedisConnectionFactory对象在最后的时候需要自行调用afterPropertiesSet方法,它实现了InitializingBean接口,如果其配置在Spring Ioc容器中,Spring会自动调用它,但是这里我们是自行创建的,因此需要自行调用,否则在运用的时候会抛出异常,从而出现错误。
数据存储设计
Redis并不是一个严格的事务,而且事务功能也是有限的。加上Redis本身的命令也比较有限,功能性不强,为了增强功能性,还可以使用Lua语言。Redis中的Lua语言是一种原子性的操作,可以保证数据的一致性。依据这个原理可以避免超发现象,完成抢红包的功能,而且对性能而言,Redis要比数据库快的多。
第一次运行Lua脚本的时候,现在Redis中编译和缓存脚本,这样就可以得到一个SHAl字符串,之后通过SHAl字符串和参数就能调用Lua脚本了。先来编写Lua脚本,代码如下:
local listKey='red_packet_list_'..KEYS[1] "
+"local redPacket='red_packet_'..KEYS[1] "
+"local stock=tonumber(redis.call('hget',redPacket,'stock')) "
+"if stock<=0 then return 0 end "
+"stock=stock-1 "
+"redis.call('hset',redPacket,'stock',toString(stock)) "
+"redis.call('rpush',listKey,ARGV[1]) "
+"if stock ==0 then return 2 end "
+"return 1
这里可以看到这样一个流程:
判断是否存在可以抢夺的红包,对于红包的库存,如果已经没有可以抢夺的红包,则返回为0,结束流程。
有可抢夺的红包,对于红包的库存减1,然后重新设置库存。
将抢红包数据保存到Redis的链表当中,链表的key为red_packet_list_{id}。
如果当前库存为0,那么返回2,这说明可以触发数据库对Redis链表数据的保存。
链表的key为red_packet_list_{id}.它将保存抢红包的用户名和抢得时间。
如果当前库存不为0,那么返回1,这说明抢红包信息保存成功。
当返回为2的时候(现实中如果抢不完红包,可以使用超时机制触发,比较复杂),说明红包已经没有库存,会触发数据库对链表的数据的保存,这是一个大数据量的保存。为了不影响最后一次抢红包的响应,在实际的操作中往往会考虑使用JMS消息发送到别的服务器进行操作,这样会比较复杂,这里只是创建一条新的线程去运行保存Redis链表数据到数据库,为此我们需要一个新的服务类,如下
//RedisRedPacketService.java
package com.ssm.wdz.service;
public interface RedisRedPacketService {
/ **
* 保存redis抢红包列表
* @param redPacketId 抢红包编号
* @param unitAmount 红包金额
* /
public void saveUserRedPacketByRedis(Long redPacketId,Double unitAmount);
}
//RedisRedPacketServiceImpl.java
package com.ssm.wdz.service.impl;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import com.ssm.wdz.pojo.UserRedPacket;
import com.ssm.wdz.service.RedisRedPacketService;
@Service
public class RedisRedPacketServiceImpl implements RedisRedPacketService{
private static final String PREFIX="red_packet_list_";
//每次取出1000条,避免一次取出消耗太多内存
private static final int TIME_SIZE=1000;
@Autowired
//RedisTemplate
private RedisTemplate redisTemplate=null;
@Autowired
//数据源
DataSource dataSource=null;
@Override
//开启新线程运行
@Async
public void saveUserRedPacketByRedis(Long redPacketId,Double unitAmount) {
System.err.println("开始存数据");
Long start =System.currentTimeMillis();
//获取列表操作对象
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 userRedPacketList=new ArrayList(TIME_SIZE);
for(int i=0;i
//获取至多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
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("redPakcetId:"+redPacketId);
userRedPacketList.add(userRedPacket);
}
//插入抢红包信息
count+=executeBatch(userRedPacketList);
}
//删除Redis列表
redisTemplate.delete(PREFIX+redPacketId);
Long end=System.currentTimeMillis();
System.err.println("保存数据结束,耗时"+(end-start)+"毫秒,共"+count+"条记录被保存。");
}
/ **
*使用JDBC批量处理Redis缓存数据
* @param userRedPacketList --抢红包列表
* @return 抢红包插入数量
* /
private int executeBatch(List 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()+","
+"'"+userRedPacket.getNote()+"')";
stmt.addBatch(sql1);
stmt.addBatch(sql2);
}
//执行批量
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();
}
}
//返回插入抢红包数据记录
return count.length/2;
}
}
注意,注解@Async表示让Spring自动创建另外一条线程去运行它,这样它便不在抢最后一个红包的线程内。因为这个方法是一个较长时间的方法,如果在同一个线程内,那么对于最后一个抢红包的用户需要等待的时间太长,影响体验。这里是每次取出1000个抢红包的信息,之所以这样做是为了避免取出的数据过大,导致JVM消耗过多的内存影响系统性能。对于大批量的数据操作,这是我们在实际操作中要注意的,最后还会删除Redis保存的链表信息,这样就能帮助Redis释放内存了。对于数据库的保存,这里采用了JDBC的批量处理,每1000条批量保存一次,使用批量有助于性能的提高。
使用@Async的前提是提供一个任务池给Spring环境,这个时候要在原有的基础上改写配置类WebConfig,如下:
@EnableAsync
public class WebConfig extends AsyncConfigurerSupport{
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor taskExecutor=new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(5);
taskExecutor.setMaxPoolSize(10);
taskExecutor.setQueueCapacity(200);
taskExecutor.initialize();
return taskExecutor;
}
使用@EnableAsync表明支持异步调用,接着重写了抽象类AsyncConfigurerSupport的getAsyncExecutor方法,它是获取一个任务池,当在Spring环境中遇到注解@Async就会启动这个任务池的一条线程去运行对应的方法,这样便能够异步执行了。
使用Redsi实现抢红包
首先应该编写Lua语言,使用对应的链接发送给Redis服务器,那么Redis会返回一个SHAl字符串,我们保存它,之后的发送可以只发送这个字符和对应的参数。下面在UserRedPacketService中加入一个新的方法:
//UserRedPacketService.java
/ **
* 通过Redis实现抢红包
* @param redPacketId 红包编号
* @param userId 用户编号
* @return
* 0-没有库存,失败
* 1-成功,且不是最后一个红包
* 2-成功,且是最后一个红包
* /
public Long grapRedPacketByRedis(Long redPacketId,Long userId);
它的实现类UserRedPacketServiceImpl也要加入实现方法,如下:
//UserRedpacketServiceImpl.java
@Autowired
private RedisTemplate redisTemplate=null;
@Autowired
private RedisRedPacketService redisRedPacketService=null;
//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位SHAl编码,使用它去执行缓存的Lua脚本
String shal=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 {
//如果脚本没有加载过那么进行加载,这样就会返回一个SHAl编码
if(shal==null) {
shal=jedis.scriptLoad(script);
}
//执行脚本,返回结果
Object res=jedis.evalsha(shal,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;
}
这里使用了保存脚本返回的SHAl字符串,所以只会发送一次脚本到Redis服务器,之后只传输SHAl字符串和参数到Redis就能执行脚本了,当脚本返回2的时候,表示此时所有的红包都被抢光了,那么就会触发redisRedPacketService的saveUserRedPacketByRedis方法。由于在此方法加入了注解@Async,所以Spring会创建一条新的线程去运行它,这样就不会影响最后抢红包用户的响应时间了。
此时重新在控制器UserRedPacketController中加入新的方法作为响应就可以了,如下
//UserRedPacketController.java
@RequestMapping(value="/grapRedPacketByRedis")
@ResponseBody
public Map grapRedPacketByRedis(Long redPacketId,Long userId){
Map resultMap=new HashMap();
long result=userRedPacketService.grapRedPacketByRedis(redPacketId, userId);
boolean flag=result>0;
resultMap.put("result", flag);
resultMap.put("message", flag?"抢红包成功":"抢红包失败");
return resultMap;
}
为了测试它,我们现在Redis上添加红包信息,于是执行如下指令:
初始化了一个编号为5的大红包,其中库存为2万个,每个10元。最后测试时间仅需2秒就可抢完所有红包而且也没有超发少抢现象。