抢红包高并发demo3

乐观锁重入机制

使用乐观锁造成大量的更新失败的问题,使用时间戳执行乐观锁重入,是一种提高成功率的方法,比如考虑在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秒就可抢完所有红包而且也没有超发少抢现象。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容