在上期的 SSM 初始框架下开发SSM 初始项目实例
一、表和数据准备-mysql
1、t_red_packet
存储发红包信息
DROP TABLE IF EXISTS `t_red_packet`;
CREATE TABLE `t_red_packet` (
`id` int(12) NOT NULL AUTO_INCREMENT COMMENT '红包编号',
`user_id` int(12) NOT NULL COMMENT '发红包用户',
`amount` decimal(16, 2) NOT NULL COMMENT '红包总金额',
`send_date` timestamp(0) NOT NULL COMMENT '发红包时间',
`total` int(12) NOT NULL COMMENT '小红包总数',
`unit_amount` decimal(12, 2) NOT NULL COMMENT '单个小红包金额',
`stock` int(12) NOT NULL COMMENT '剩余小红包个数',
`version` int(12) NOT NULL DEFAULT 0 COMMENT '版本',
`note` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
表结构
image.png
2、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 COMMENT '编号',
`red_packet_id` int(12) NOT NULL COMMENT '红包编号',
`user_id` int(12) NOT NULL COMMENT '用户编号',
`amount` decimal(16, 2) NOT NULL COMMENT '抢到的金额',
`grab_time` timestamp(0) NOT NULL COMMENT '抢红包时间',
`note` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
表结构
image.png
3、插入一条红包信息
INSERT INTO t_red_packet(id,user_id,amount,send_date,total,unit_amount,stock,note)
VALUES(1,1, 2000.00, now(), 2000, 1, 2000, '2000总额,分为2000个,每个1块钱');
一个红包,等额分为2000份发出。
二、生成 pojo
和 mapper
修改generatorConfig.xml
, 添加配置
<table tableName="t_red_packet" domainObjectName="RedPacket" enableCountByExample="false" enableUpdateByExample="false" enableDeleteByExample="false" enableSelectByExample="false" selectByExampleQueryId="false">
<property name="useActualColumnNames" value="false" />
<generatedKey column="id" sqlStatement="MySql" identity="true" />
</table>
<table tableName="t_user_red_packet" domainObjectName="UserRedPacket" enableCountByExample="false" enableUpdateByExample="false" enableDeleteByExample="false" enableSelectByExample="false" selectByExampleQueryId="false">
<property name="useActualColumnNames" value="false" />
<generatedKey column="id" sqlStatement="MySql" identity="true" />
</table>
运行mvn mybatis-generator:generate
命令,自动生成文件
代码更改:
Github Commit
三、编写 Service
1、新增接口IRedPacketService
package com.wishuok.service;
import com.wishuok.pojo.RedPacket;
public interface IRedPacketService {
public RedPacket getRedPacket(int id);
public int decreaseRedPacket(int id);
}
实现类
package com.wishuok.service.impl;
import com.wishuok.mapper.RedPacketMapper;
import com.wishuok.pojo.RedPacket;
import com.wishuok.service.IRedPacketService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
public class RedPacketService implements IRedPacketService {
@Autowired
private RedPacketMapper redPacketMapper = null;
// 事物隔离级别: 读/写提交
// 传播行为:调用方法时,若没有事物,则创建事物,否则沿用当前事物
@Override
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public RedPacket getRedPacket(int id) {
return redPacketMapper.selectByPrimaryKey(id);
}
@Override
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int decreaseRedPacket(int id) {
return redPacketMapper.decreaseRedPacket(id); // 对红包进行剩余个数-1操作
}
}
2、新增接口 IUserRedPacketService
package com.wishuok.service;
public interface IUserRedPacketService {
int grabRedPacket(int redPacketId, int userId);
}
实现类
package com.wishuok.service.impl;
import com.wishuok.mapper.RedPacketMapper;
import com.wishuok.mapper.UserRedPacketMapper;
import com.wishuok.pojo.RedPacket;
import com.wishuok.pojo.UserRedPacket;
import com.wishuok.service.IUserRedPacketService;
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 java.util.Date;
@Service("userRedPacketService")
public class UserRedPacketService implements IUserRedPacketService {
@Autowired
private UserRedPacketMapper userRedPacketMapper = null;
@Autowired
private RedPacketMapper redPacketMapper = null;
private static final int FAILED = -1;
// 事物隔离级别: 读/写提交
// 传播行为:调用方法时,若没有事物,则创建事物,否则沿用当前事物
@Override
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grabRedPacket(int redPacketId, int userId) {
RedPacket redPacket = redPacketMapper.selectByPrimaryKey(redPacketId);
if(redPacket.getStock() > 0){
redPacketMapper.decreaseRedPacket(redPacketId);
UserRedPacket userRedPacket = new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setAmount(redPacket.getUnitAmount());
userRedPacket.setUserId(userId);
userRedPacket.setGrabTime(new Date());
userRedPacket.setNote("抢红包" + redPacketId);
int result = userRedPacketMapper.insert(userRedPacket);
return result;
}
return FAILED;
}
}
四、编写 Controller
1、返回数据处理器使用MappingJackson2HttpMessageConverter
spring-mvc.xml
修改项:
<!-- 配置springmvc返回数据的数据格式,注意必须放在<mvc:annotation-driven>之前 -->
<bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
<property name="messageConverters">
<list>
<bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter">
<property name="supportedMediaTypes">
<list>
<!--
<value>text/plain;charset=UTF-8</value>
<value>text/html;charset=UTF-8</value> -->
<value>application/json;charset=UTF-8</value>
</list>
</property>
</bean>
</list>
</property>
</bean>
2、新增 UserRedPacketController
package com.wishuok.controller;
import com.wishuok.service.IUserRedPacketService;
import com.wishuok.service.impl.UserRedPacketService;
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 java.util.HashMap;
import java.util.Map;
@Controller
@RequestMapping("/userRedPacket")
public class UserRedPacketController {
@Autowired
private IUserRedPacketService userRedPacketService = null;
@RequestMapping(value = "/grab")
@ResponseBody // 使用Json转换器处理返回值
public Map<String, Object> grabRedPacket(int redPacketId, int userId) {
int result = userRedPacketService.grabRedPacket(redPacketId, userId);
Map<String, Object> retMap = new HashMap<String, Object>();
boolean flag = result > 0;
retMap.put("success", flag);
retMap.put("message", flag ? "抢红包成功" : "抢红包失败");
return retMap;
}
// 调用url 示例:http://localhost/userRedPacket/doTest
// 直接返回 `redPacketTest.jsp`
@RequestMapping(value = "/doTest")
public String doTest(){
return "redPacketTest";
}
}
3、新增页面 redPacketTest.jsp
<%--
Created by IntelliJ IDEA.
User: junguoguo
Date: 2019/7/21
Time: 10:43
To change this template use File | Settings | File Templates.
--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>模拟抢红包</title>
<script type="text/javascript" src="https://code.jquery.com/jquery-3.2.0.js"></script>
<script type="text/javascript">
$(document).ready(function(){
// 模拟 2500 个异步请求并发
var max = 2500;
for (var i=1; i <= max; i++){
$.post({
url:"./grab?redPacketId=1&userId=" + i,
success:function(result){
}
});
}
})
</script>
</head>
<body>
</body>
</html>
代码更改:
Github Commit
五、测试
调试后,访问网页http://localhost/userRedPacket/doTest
jsp加载后会并发发起2500个请求
可以看到存在红包超发的情况
select * from t_red_packet;
select * from t_user_red_packet order by user_id desc;
select max(grab_time) - min(grab_time) costtime from t_user_red_packet
image.png
一共有 2400 个人抢到了红包,多发了4 个人,数据记录插入总耗时大概
23
秒
六、使用悲观锁解决超发问题
1、RedPacketMapper 新增加行锁的方法
<select id="selectByPrimariKeyForUpdate" parameterType="java.lang.Integer" resultMap="BaseResultMap">
select
<include refid="Base_Column_List" />
from t_red_packet
where id = #{id,jdbcType=INTEGER} for update
</select>
相比原来的selectByPrimariKey
,多了一个 select 语句后的 for update
2、修改 service
使用新的selectForUpdate方法获取数据,其余不变
3、测试
执行sql,清除数据
DELETE FROM t_red_packet;
DELETE FROM t_user_red_packet;
INSERT INTO t_red_packet(id,user_id,amount,send_date,total,unit_amount,stock,note)
VALUES(1,1, 2000.00, now(), 2000, 1, 2000, '2000总额,分为2000个,每个1块钱');
再调用,会发现没有超发的情况,但是这种方法耗时比较长
代码更改:
Github Commit
七、使用乐观锁
上面的方式,在多个请求到达服务器以后,每个线程都会阻塞在获取行锁的地方,从而导致同一时间会有很多的线程堵塞,同时仅能有一个线程运行,非常消耗服务器资源。
CAS原理
对于多个线程共同的资源,先保存一个旧值( Old Value ),然后经过一定的逻辑处理,当需要修改数据库时,先比较数据库当前的值和旧值是否一致,如果一致则进行更新,否则不再进行操作
image.png
CAS 原理并不排斥并发,也不独占资源,只是在线程开始阶段就读入线程共享数据,
保存为旧值。当处理完逻辑,需要更新数据的时候,会进行一次 比较,即比较各个线程当前共享的数据是否和旧值保持一致。如果一致,就开始更新数据 ;如果不一致,则认为该数据已经被其他线程修改了,那么就不再更新数据 ,可以考虑重试或者放弃。有时候可 以重试,这样就是一个可重入锁,但是 CAS 原理会有一个问题,那就是 ABA 问题。ABA 问题的发生 , 是因为业务逻辑存在回退的可能性。在一个数据中加入版本号( version ),对于版本号有一个约定 ,就是只要修改 X变量 的数据,强制版本号( version )只能递增,而不会回退,即使是其他业务数据回退,它也会递增,那么 ABA 问题就解决了。但是CAS往往会导致调用请求过多时失败率过高,所以需要加入失败重试机制
乐观锁重入机制
因为乐观锁造成大量更新失败的问题,使用时间戳执行乐观锁重入,是一种提高成功率的方法,比如考虑在 100 毫秒内允许重入;或者使用重试次数限制,比如失败三次以内自动重试。
代码修改
- 主要修改在 update 红包表时增加了对 version 字段的新旧比较
mapper
配置文件
<update id="decreaseRedPackWithVersion">
update t_red_packet
set
stock = stock - 1,
version = version + 1
where id = #{id,jdbcType=INTEGER}
and version = #{version,jdbcType=INTEGER}
</update>
mapper
类
// 使用 version 而不是 stock 字段判断,是因为防止ABA问题
// 此处使用 stock 和 version 都行,因为只有对 stock 的 +1 操作
int decreaseRedPackWithVersion(@Param("id") int id, @Param("version") int version, @Param("stock") int stock);
注意要在 配置文件中使用#{id,jdbcType=INTEGER}
的话,方法参数必须用@Param("id")
修饰(Param 类为 【org.apache.ibatis.annotations.Param】)
使用乐观锁的弊端在于
导致大量的 SQL 被执行,对于数据库的性能要求较高,容易引起数据库性能的瓶颈,而且对于开发还要考虑重入机制,从而导致开发难度加大。
八、使用 redis
对于使用 Redis 实现抢红包 ,首先需要知道的是 Redis 的功能不如数据库强大,不完整,因此要保证数据的正确性,数据的正确性可以通过严格的验证得以保证。Lua 语言是原子性的,且功能更为强大,所以优先选择使用 Lua 语言来实现抢红包。此外,Redis 并非一个长久储存数据的地方,它存储的数据是非严格和安全的环境,更多的时候只是为了提供更为快速的缓存,所以当红包金额为 0 或者红包超时的时候,将红包数据保存到数据库中,能够保证数据的安全性和严格性。
1、redis 基础使用
参考上一篇文章8、SSM项目使用redis
2、新增一个stringRedisTemplate
spring-service.xml 配置
<bean id= "stringRedisTemplate" class= "org.springframework.data.redis.core.RedisTemplate" >
<property name="connectionFactory" ref="jedisConnectionFactory" />
<property name="keySerializer" ref="stringRedisSerializer"/>
<property name="valueSerializer" ref="stringRedisSerializer" />
<property name="defaultSerializer" ref="stringRedisSerializer"/>
</bean>
3、新增用于redis抢红包后保存记录到数据库的service
接口 IRedisRedPacketService
package com.wishuok.service;
public interface IRedisRedPacketService {
/** 保存 redis 抢红包列表
* @param redPacketId --红包编号
* @param unitAmout --红包金额
*/
void saveUserRedPacketByRedis(int redPacketId, double unitAmout);
}
实现类 RedisRedPacketService
package com.wishuok.service.impl;
import com.wishuok.pojo.UserRedPacket;
import com.wishuok.service.IRedPacketService;
import com.wishuok.service.IRedisRedPacketService;
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 javax.sql.DataSource;
import java.math.BigDecimal;
import java.math.MathContext;
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;
@Service
public class RedisRedPacketService implements IRedisRedPacketService {
// 每次取出 800 条 ,避免一次取出消耗太多内存
private static final int TIME_SIZE = 800;
// redis中每个用户抢红包结果的list key
private static final String REDIS_PREFIX = "red_packet_list_";
@Autowired
private RedisTemplate stringRedisTemplate = null;
@Autowired
private DataSource dataSource = null;
@Override
@Async // 开启新线程运行
public void saveUserRedPacketByRedis(int redPacketId, double unitAmout) {
System.err.println("开始保存数据 " + "thread_name = " + Thread.currentThread().getName());
String redisKey = REDIS_PREFIX + redPacketId;
Long startTime = System.currentTimeMillis();
BoundListOperations ops = stringRedisTemplate.boundListOps(redisKey);
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++) {
// 每次获取 TIME_SIZE 个信息
List useridList = null;
if (i == 0) {
useridList = ops.range(0, 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(); // 存储的值为 userId + "-" + System.currentTimeMillis();
String[] arr = args.split("-");
String userIdStr = arr[0];
String timeStr = arr[1];
Integer userId = Integer.parseInt(userIdStr);
Long time = Long.parseLong(timeStr);
UserRedPacket userRedPacket = new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(new BigDecimal(unitAmout, MathContext.DECIMAL32));
userRedPacket.setGrabTime(new Timestamp(time));
userRedPacket.setNote("抢红包" + redPacketId);
userRedPacketList.add(userRedPacket);
}
// 插入数据库
count += executeBatch(userRedPacketList);
}
// 删除 redis 列表
stringRedisTemplate.delete(redisKey) ;
Long end = System.currentTimeMillis();
System.err.println("保存数据结束,耗时" + (end-startTime) +"毫秒,共" + count +"条记录被保存。");
}
/** 使用 JDBC 批量处理redis数据
* @param userRedPacketList
* @return 插入数量
*/
private int executeBatch(List<UserRedPacket> userRedPacketList) {
DateFormat dt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Connection conn = null;
Statement stmt = null;
int[] count = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false);
stmt = conn.createStatement();
for (UserRedPacket packet: userRedPacketList) {
String sql1 = "update t_red_packet set stock=stock-1 where id=" + packet.getRedPacketId();
String sql2 = "insert into t_user_red_packet(red_packet_id,user_id,amount,grab_time,note)" +
" values(" +packet.getRedPacketId()+","+packet.getUserId()+","+packet.getAmount()+","
+"'"+dt.format(packet.getGrabTime()) +"','" +packet.getNote()+ "')";
stmt.addBatch(sql1);
stmt.addBatch(sql2);
}
count = stmt.executeBatch(); // 执行批量脚本
conn.commit(); // 提交事物
} catch (SQLException e) {
e.printStackTrace();
throw new RuntimeException("抢红包批量执行程序错误:"+ e.getMessage());
}finally {
try {
if(conn != null && !conn.isClosed()){
conn.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
// 返回插入的记录数
return count.length / 2;
}
}
4、修改UserRedPacketService
添加使用 redis 抢红包的方法
@Autowired
private RedisTemplate stringRedisTemplate = null;
@Autowired
private IRedisRedPacketService redisRedPacketService = null;
//抢红包的LUA脚本
private static final String RedisRedPacketLuaScript = "local listKey = 'red_packet_list_'..KEYS[1] \n"
+ " local redPacket = 'red_packet_'.. KEYS[1] \n"
+ " local stock= tonumber(redis.call('hget', redPacket,'stock'))"
+ " 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脚本后得到的 sha1 值,用来调用脚本
private static String RedisLuaScriptSha1 = null;
/**
* 通过 redis 实现抢红包
*
* @param redPacketId
* @param userId
* @return 0-没有库存,失败;1-成功,且不是最后一个红包;2-成功且是最后一个红包
*/
@Override
public int grabRedPacketByRedis(int redPacketId, int userId) {
String args = userId + "-" + System.currentTimeMillis();
int result = 0;
Jedis jedis = (Jedis)stringRedisTemplate.getConnectionFactory().getConnection().getNativeConnection();
try{
if(RedisLuaScriptSha1 == null) {// 加载脚本
RedisLuaScriptSha1 = jedis.scriptLoad(RedisRedPacketLuaScript);
}
long res = (Long)jedis.evalsha(RedisLuaScriptSha1, 1, redPacketId + "", args);
result = (int)res;
if(result == 2){
// 最后一个红包
String unitAmout = jedis.hget("red_packet_"+redPacketId, "unit_amount");
double unitAmount = Double.parseDouble(unitAmout);
System.err.println("thread_name = " + Thread.currentThread().getName());
redisRedPacketService.saveUserRedPacketByRedis(redPacketId, unitAmount); // 调用保存记录到数据库的service方法
}
} finally {
if(jedis != null && jedis.isConnected())
jedis.close();
}
return result;
}
5、Controller
新增接口
@RequestMapping(value = "/grabWithRedis")
@ResponseBody
public Map<String, Object> grabWithRedis(int redPacketId, int userId) {
int result = userRedPacketService.grabRedPacketByRedis(redPacketId, userId);
Map<String, Object> retMap = new HashMap<String, Object>();
boolean flag = result > 0;
retMap.put("success", flag);
String msg = flag ? "抢红包成功 " : "抢红包失败 ";
msg += result;
retMap.put("message",msg);
return retMap;
}
6、测试
- 清除数据库数据,重新插入红包记录【SQL前面有】
INSERT INTO t_red_packet(id,user_id,amount,send_date,total,unit_amount,stock,note)
VALUES(1,1, 3000.00, now(), 3000, 1, 3000, '2000总额,分为2000个,每个1块钱');
-
redis 设置初始值
image.png - 调试运行
image.png
红包扣减正确
image.png
redis
耗时 6 秒
记录落地到数据库log如下:
thread_name = http-nio-80-exec-9
开始保存数据 thread_name = SimpleAsyncTaskExecutor-1
..........................something else...............................
保存数据结束,耗时3814毫秒,共3000条记录被保存。
可以看到,因为方法启用了@Async
注解,所以调用时系统另外开启了一个线程,这样不会阻塞最后一个成功抢到的用户
网友评论