Redis实战 - 04 Redis 分布式锁应用之抢购代金券
文章目录
- 1. 数据库表结构
- 1. 代金券表
- 2. 抢购活动表
- 3. 订单表
- 2. 秒杀场景的解决方案
- 3. 创建秒杀服务 ms-seckill
- 4. 代金券抢购功能开发 - 关系型数据库实现
- 1. Rest配置类 RestTemplateConfiguration
- 2. 全局异常处理类 GlobalExceptionHandler
- 3. 添加抢购代金券
- 1. 代金券实体类 SeckillVouchers
- 2. 秒杀控制层 SeckillController
- 3. 秒杀业务逻辑层 SeckillService
- 4. 秒杀代金券 SeckillVouchersMapper
- 5. 启动类 SeckillApplication
- 6. 在网关服务 ms-gateway 中放行此接口
- 7. 启动项目测试
- 4. 对抢购代金券下单
- 1. 秒杀控制层 SeckillController
- 2. 秒杀业务逻辑层 SeckillService
- 3. 代金券订单 VoucherOrdersMapper
- 4. 秒杀代金券 SeckillVouchersMapper
- 5. 在网关服务 ms-gateway 中配置路由
- 5. 压力测试
- 1. 数据准备
- 2. ms-oauth2-server 服务中生成登录token
- 3. 测试多人抢购代金券
- 4. 模拟某个用户多次抢购
- 5. 代金券抢购功能开发 - Redis 防止订单超卖
- 1. RedisKeyConstan
- 2. Redis 配置类 RedisTemplateConfiguration
- 3. 添加抢购代金券
- 4. 对抢购代金券下单
- 5. 测试多人抢购代金券
- 1. 向redis中添加一个抢购代金券活动
- 2. 5000并发2000用户同时抢购代金券
- 3. lua 脚本解决扣库存问题
- 6. 代金券抢购功能开发 - Redis 限制一人一单
- 1. 不可重入锁
- 2. 可重入锁
- 3. Redis 可重入锁
- 1. 设计 lock.lua 脚本
- 2. 设计 unlock.lua 脚本
- 3. RedisLock类
- 4. RedisLockConfiguration类
- 5. 修改抢购代金券逻辑限制一人一单
- 6. 启动项目测试
- 4. Redission 分布式锁的应用
1. 数据库表结构
1. 代金券表
CREATE TABLE `t_vouchers` (
`id` int(10) NOT NULL AUTO_INCREMENT ,
`title` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '代金券标题' ,
`thumbnail` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '缩略图' ,
`amount` int(11) NULL DEFAULT NULL COMMENT '抵扣金额' ,
`price` decimal(10,2) NULL DEFAULT NULL COMMENT '售价' ,
`status` int(10) NULL DEFAULT NULL COMMENT '-1=过期 0=下架 1=上架' ,
`start_use_time` datetime NULL DEFAULT NULL COMMENT '开始使用时间' ,
`expire_time` datetime NULL DEFAULT NULL COMMENT '过期时间' ,
`redeem_restaurant_id` int(10) NULL DEFAULT NULL COMMENT '验证餐厅' ,
`stock` int(11) NULL DEFAULT 0 COMMENT '库存' ,
`stock_left` int(11) NULL DEFAULT 0 COMMENT '剩余数量' ,
`description` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述信息' ,
`clause` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '使用条款' ,
`create_date` datetime NULL DEFAULT NULL ,
`update_date` datetime NULL DEFAULT NULL ,
`is_valid` tinyint(1) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=utf8 COLLATE=utf8_general_ci
AUTO_INCREMENT=1
ROW_FORMAT=COMPACT
;
2. 抢购活动表
CREATE TABLE `t_seckill_vouchers` (
`id` int(11) NOT NULL AUTO_INCREMENT ,
`fk_voucher_id` int(11) NULL DEFAULT NULL ,
`amount` int(11) NULL DEFAULT NULL ,
`start_time` datetime NULL DEFAULT NULL ,
`end_time` datetime NULL DEFAULT NULL ,
`is_valid` int(11) NULL DEFAULT NULL ,
`create_date` datetime NULL DEFAULT NULL ,
`update_date` datetime NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_general_ci
AUTO_INCREMENT=1
ROW_FORMAT=COMPACT
;
3. 订单表
CREATE TABLE `t_voucher_orders` (
`id` int(11) NOT NULL AUTO_INCREMENT ,
`order_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL ,
`fk_voucher_id` int(11) NULL DEFAULT NULL ,
`fk_diner_id` int(11) NULL DEFAULT NULL ,
`qrcode` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '图片地址' ,
`payment` tinyint(4) NULL DEFAULT NULL COMMENT '0=微信支付 1=支付宝支付' ,
`status` tinyint(1) NULL DEFAULT NULL COMMENT '订单状态:-1=已取消 0=未支付 1=已支付 2=已消费 3=已过期' ,
`fk_seckill_id` int(11) NULL DEFAULT NULL COMMENT '如果是抢购订单时,抢购订单的id' ,
`order_type` int(11) NULL DEFAULT NULL COMMENT '订单类型:0=正常订单 1=抢购订单' ,
`create_date` datetime NULL DEFAULT NULL ,
`update_date` datetime NULL DEFAULT NULL ,
`is_valid` int(11) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_general_ci
AUTO_INCREMENT=1
ROW_FORMAT=COMPACT
;
2. 秒杀场景的解决方案
秒杀场景有以下几个特点:大量用户同时进行抢购操作,系统流量激增,服务器瞬时压力很大;请求数量远大于商品库存量,只有少数客户可以成功抢购;业务流程不复杂,核心功能是下订单。秒杀场景的应对,一般要从以下几个方面进行处理,如下:
① 限流:从客户端层面考虑,限制单个客户抢购频率;服务端层面,加强校验,识别请求是否来源于真实的客户端,并限制请求频率,防止恶意刷单;应用层面,可以使用漏桶算法或令牌桶算法实现应用级限流。
② 缓存:热点数据都从缓存获得,尽可能减小数据库的访问压力;
③ 异步:客户抢购成功后立即返回响应,之后通过消息队列,异步处理后续步骤,如发短信、更新数据库等,从而缓解服务器峰值压力。
④ 分流:单台服务器肯定无法应对抢购期间大量请求造成的压力,需要集群部署服务器,通过负载均衡共同处理客户端请求,分散压力。
3. 创建秒杀服务 ms-seckill
① 添加依赖:
<dependencies><!-- eureka client --><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-netflix-eureka-client</artifactId></dependency><!-- spring web --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- mybatis --><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId></dependency><!-- mysql --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><!-- spring data redis --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- commons --><dependency><groupId>com.imooc</groupId><artifactId>commons</artifactId><version>1.0-SNAPSHOT</version></dependency><dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.13.6</version></dependency><dependency><groupId>javax.xml.bind</groupId><artifactId>jaxb-api</artifactId><version>2.3.0</version></dependency>
</dependencies>
② 配置文件:
server:port: 8083 # 端口spring:application:name: ms-oauth2-server # 应用名# 数据库datasource:driver-class-name: com.mysql.cj.jdbc.Driverusername: rootpassword: rooturl: jdbc:mysql://127.0.0.1:3306/orgnization?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false# Redisredis:port: 6379host: 192.168.38.22timeout: 3000database: 1# swaggerswagger:base-package: com.hh.oauth2title: 美食社交食客API接口文档# 配置 Eureka Server 注册中心
eureka:instance:prefer-ip-address: trueinstance-id: ${spring.cloud.client.ip-address}:${server.port}client:service-url:defaultZone: http://localhost:8080/eureka/mybatis:configuration:map-underscore-to-camel-case: true # 开启驼峰映射service:name:ms-oauth-server: http://ms-oauth2-server/logging:pattern:console: '%d{HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n'
4. 代金券抢购功能开发 - 关系型数据库实现
1. Rest配置类 RestTemplateConfiguration
@Configuration
public class RestTemplateConfiguration {@LoadBalanced@Beanpublic RestTemplate restTemplate() {RestTemplate restTemplate = new RestTemplate();MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();converter.setSupportedMediaTypes(Collections.singletonList(MediaType.TEXT_PLAIN));restTemplate.getMessageConverters().add(converter);return restTemplate;}
}
2. 全局异常处理类 GlobalExceptionHandler
@RestControllerAdvice // 将输出的内容写入 ResponseBody 中
@Slf4j
public class GlobalExceptionHandler {@Resourceprivate HttpServletRequest request;@ExceptionHandler(ParameterException.class)public ResultInfo<Map<String, String>> handlerParameterException(ParameterException ex) {String path = request.getRequestURI();ResultInfo<Map<String, String>> resultInfo =ResultInfoUtil.buildError(ex.getErrorCode(), ex.getMessage(), path);return resultInfo;}@ExceptionHandler(Exception.class)public ResultInfo<Map<String, String>> handlerException(Exception ex) {log.info("未知异常:{}", ex);String path = request.getRequestURI();ResultInfo<Map<String, String>> resultInfo =ResultInfoUtil.buildError(path);return resultInfo;}
}
3. 添加抢购代金券
1. 代金券实体类 SeckillVouchers
package com.hh.commons.model.pojo;@Setter
@Getter
@ApiModel(description = "抢购代金券信息")
public class SeckillVouchers extends BaseModel {@ApiModelProperty("代金券外键")private Integer fkVoucherId;@ApiModelProperty("数量")private int amount;@ApiModelProperty("抢购开始时间")@JsonFormat(pattern = "yyyy-MM-dd HH:mm", timezone = "GMT+8")@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")private Date startTime;@ApiModelProperty("抢购结束时间")@JsonFormat(pattern = "yyyy-MM-dd HH:mm", timezone = "GMT+8")@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")private Date endTime;
}
2. 秒杀控制层 SeckillController
package com.hh.seckill.controller;/*** 秒杀控制层*/
@RestController
public class SeckillController {@Resourceprivate SeckillService seckillService;@Resourceprivate HttpServletRequest request;/*** 新增秒杀活动*/@PostMapping("add")public ResultInfo<String> addSeckillVouchers(@RequestBody SeckillVouchers seckillVouchers) {seckillService.addSeckillVouchers(seckillVouchers);return ResultInfoUtil.buildSuccess(request.getServletPath(),"添加成功");}
}
3. 秒杀业务逻辑层 SeckillService
package com.hh.seckill.service;/*** 秒杀业务逻辑层*/
@Service
public class SeckillService {@Resourceprivate SeckillVouchersMapper seckillVouchersMapper;/*** 添加需要抢购的代金券*/@Transactional(rollbackFor = Exception.class)public void addSeckillVouchers(SeckillVouchers seckillVouchers) {// 非空校验AssertUtil.isTrue(seckillVouchers.getFkVoucherId() == null, "请选择需要抢购的代金券");AssertUtil.isTrue(seckillVouchers.getAmount() == 0, "请输入抢购总数量");Date now = new Date();AssertUtil.isNotNull(seckillVouchers.getStartTime(), "请输入开始时间");// 生产环境下面一行代码需放行,这里注释方便测试// AssertUtil.isTrue(now.after(seckillVouchers.getStartTime()), "开始时间不能早于当前时间");AssertUtil.isNotNull(seckillVouchers.getEndTime(), "请输入结束时间");AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "结束时间不能早于当前时间");AssertUtil.isTrue(seckillVouchers.getStartTime().after(seckillVouchers.getEndTime()), "开始时间不能晚于结束时间");// 验证数据库中是否已经存在该券的秒杀活动SeckillVouchers seckillVouchersFromDb = seckillVouchersMapper.selectVoucher(seckillVouchers.getFkVoucherId());AssertUtil.isTrue(seckillVouchersFromDb != null, "该券已经拥有了抢购活动");// 插入数据库seckillVouchersMapper.save(seckillVouchers);}
}
验证数据库表 t_seckill_vouchers 中是否已经存在该券的秒杀活动:
- 如果存在则抛出异常;
- 如果不存在则将添加一个代金券抢购活动到 t_seckill_vouchers 表中;
4. 秒杀代金券 SeckillVouchersMapper
package com.hh.seckill.mapper;/*** 秒杀代金券 Mapper*/
public interface SeckillVouchersMapper {/*** 根据代金券 ID 查询该代金券是否参与抢购活动*/@Select("select id, fk_voucher_id, amount, start_time, end_time, is_valid " +" from t_seckill_vouchers where fk_voucher_id = #{voucherId}")SeckillVouchers selectVoucher(Integer voucherId);/*** 新增秒杀活动*/@Insert("insert into t_seckill_vouchers (fk_voucher_id, amount, start_time, end_time, is_valid, create_date, update_date) " +" values (#{fkVoucherId}, #{amount}, #{startTime}, #{endTime}, 1, now(), now())")@Options(useGeneratedKeys = true, keyProperty = "id")int save(SeckillVouchers seckillVouchers);
}
5. 启动类 SeckillApplication
@SpringBootApplication
@MapperScan("com.hh.seckill.mapper")
public class SeckillApplication {public static void main(String[] args) {SpringApplication.run(SeckillApplication.class,args);}
}
6. 在网关服务 ms-gateway 中放行此接口
在 ms-gateway 中放行,此接口为平台后台调用,不需要食客登录。
secure:ignore:urls: # 配置白名单路径- /actuator/**- /auth/oauth/**- /diners/signin- /diners/send- /diners/checkPhone- /diners/register- /seckill/add
7. 启动项目测试
数据库表:
4. 对抢购代金券下单
1. 秒杀控制层 SeckillController
/*** 秒杀控制层*/
@RestController
public class SeckillController {@Resourceprivate SeckillService seckillService;@Resourceprivate HttpServletRequest request;/*** 秒杀下单*/@PostMapping("{voucherId}")public ResultInfo<String> doSeckill(@PathVariable Integer voucherId, String access_token) {return seckillService.doSeckill(voucherId, access_token, request.getServletPath());}
}
2. 秒杀业务逻辑层 SeckillService
package com.hh.seckill.service;/*** 秒杀业务逻辑层*/
@Service
public class SeckillService {@Resourceprivate SeckillVouchersMapper seckillVouchersMapper;@Resourceprivate VoucherOrdersMapper voucherOrdersMapper;@Value("${service.name.ms-oauth-server}")private String oauthServerName;@Resourceprivate RestTemplate restTemplate;/*** 抢购代金券** @param voucherId 代金券 ID* @param accessToken 登录token* @Para path 访问路径*/public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {// 基本参数校验AssertUtil.isTrue(voucherId == null || voucherId < 0, "请选择需要抢购的代金券");AssertUtil.isNotEmpty(accessToken, "请登录");// 判断此代金券是否加入抢购SeckillVouchers seckillVouchers = seckillVouchersMapper.selectVoucher(voucherId);AssertUtil.isTrue(seckillVouchers == null, "该代金券并未有抢购活动");// 判断是否有效AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束");// 判断是否开始、结束Date now = new Date();AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始");AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束");// 判断是否卖完AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完了");// 获取登录用户信息String url = oauthServerName + "user/me?access_token={accessToken}";ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {resultInfo.setPath(path);return resultInfo;}// 这里的data是一个LinkedHashMap,SignInDinerInfoSignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new SignInDinerInfo(), false);// 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),seckillVouchers.getId());AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢");// 扣库存int count = seckillVouchersMapper.stockDecrease(seckillVouchers.getId());AssertUtil.isTrue(count == 0, "该券已经卖完了");// 下单VoucherOrders voucherOrders = new VoucherOrders();voucherOrders.setFkDinerId(dinerInfo.getId());voucherOrders.setFkSeckillId(seckillVouchers.getId());voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();voucherOrders.setOrderNo(orderNo);voucherOrders.setOrderType(1);voucherOrders.setStatus(0);count = voucherOrdersMapper.save(voucherOrders);AssertUtil.isTrue(count == 0, "用户抢购失败");return ResultInfoUtil.buildSuccess(path, "抢购成功");}
}
判断登录用户是否已抢到(一个用户针对这次活动只能买一次),如果没有抢到,则新增一个代金券下单。
3. 代金券订单 VoucherOrdersMapper
/*** 代金券订单 Mapper*/
public interface VoucherOrdersMapper {// 根据食客 ID 和秒杀 ID 查询代金券订单@Select("select id, order_no, fk_voucher_id, fk_diner_id, qrcode, payment," +" status, fk_seckill_id, order_type, create_date, update_date, " +" is_valid from t_voucher_orders where fk_diner_id = #{dinerId} " +" and fk_voucher_id = #{voucherId} and is_valid = 1 and status between 0 and 1 ")VoucherOrders findDinerOrder(@Param("dinerId") Integer dinerId,@Param("voucherId") Integer voucherId);// 新增代金券订单@Insert("insert into t_voucher_orders (order_no, fk_voucher_id, fk_diner_id, " +" status, fk_seckill_id, order_type, create_date, update_date, is_valid)" +" values (#{orderNo}, #{fkVoucherId}, #{fkDinerId}, #{status}, #{fkSeckillId}, " +" #{orderType}, now(), now(), 1)")int save(VoucherOrders voucherOrders);}
4. 秒杀代金券 SeckillVouchersMapper
/*** 秒杀代金券 Mapper*/
public interface SeckillVouchersMapper {/*** 根据代金券 ID 查询该代金券是否参与抢购活动*/@Select("select id, fk_voucher_id, amount, start_time, end_time, is_valid " +" from t_seckill_vouchers where fk_voucher_id = #{voucherId}")SeckillVouchers selectVoucher(Integer voucherId);/*** 新增秒杀活动*/@Insert("insert into t_seckill_vouchers (fk_voucher_id, amount, start_time, end_time, is_valid, create_date, update_date) " +" values (#{fkVoucherId}, #{amount}, #{startTime}, #{endTime}, 1, now(), now())")@Options(useGeneratedKeys = true, keyProperty = "id")int save(SeckillVouchers seckillVouchers);/*** 减库存*/@Update("update t_seckill_vouchers set amount = amount - 1 " +" where id = #{seckillId}")int stockDecrease(@Param("seckillId") int seckillId);
}
5. 在网关服务 ms-gateway 中配置路由
spring:application:name: ms-gatewaycloud:gateway:discovery:locator:enabled: true # 开启配置注册中心进行路由功能lower-case-service-id: true # 将服务名称转小写routes:- id: ms-seckilluri: lb://ms-seckillpredicates:- Path=/seckill/**filters:- StripPrefix=1
启动项目测试:
5. 压力测试
1. 数据准备
① 压测工具下载地址:https://jmeter.apache.org/download_jmeter.cgi;
② 在解压目录下的bin目录下找到 jemeter.bat,这是window启动脚本,双击启动;
③ 导入注册diners数据,新增2000个食客用户,数据库运行 init_diners_data.sql
文件;
2. ms-oauth2-server 服务中生成登录token
① 测试基类 OAuth2ServerApplicationTests
package com.hh.oauth2.server;@SpringBootTest
@AutoConfigureMockMvc
public class OAuth2ServerApplicationTests {@Resourceprotected MockMvc mockMvc;}
② 测试类 OAuthControllerTest
往 tokens.txt 文件中生成 2000 个 登录用户的 token
package com.hh.oauth2.server.controller;public class OAuthControllerTest extends OAuth2ServerApplicationTests {@Testpublic void writeToken() throws Exception {String authorization = Base64Utils.encodeToString("appId:123456".getBytes());StringBuffer tokens = new StringBuffer();for (int i = 0; i < 2000; i++) {MvcResult mvcResult = super.mockMvc.perform(MockMvcRequestBuilders.post("/oauth/token").header("Authorization", "Basic " + authorization).contentType(MediaType.APPLICATION_FORM_URLENCODED).param("username", "test" + i).param("password", "123456").param("grant_type", "password").param("scope", "api")).andExpect(status().isOk())// .andDo(print()).andReturn();String contentAsString = mvcResult.getResponse().getContentAsString();ResultInfo resultInfo = (ResultInfo) JSONUtil.toBean(contentAsString, ResultInfo.class);JSONObject result = (JSONObject) resultInfo.getData();String token = result.getStr("accessToken");tokens.append(token).append("\r\n");}Files.write(Paths.get("tokens.txt"), tokens.toString().getBytes());}
}
3. 测试多人抢购代金券
① 数据库中有一个抢购代金券活动,库存数量100个:
② 使用 jmeter 工具创建一个多人抢购代金券的线程组,该线程组发起5000个并发请求:
③ 模拟5000个并发,2000个账号同时抢购代金券下单 :
选择我们生成的登录token.txt 文件,2000个登录用户的 token:
④ 启动测试
查看 t_vouchers_orders 表的抢购订单数量,抢购订单有175个,但是代金券库存只有100个,说明卖超了:
同时查看数据库 t_seckill_vouchers 表中代金券的数量 amount=-75,也说明卖超了:
4. 模拟某个用户多次抢购
① 模拟10000个并发,1个账号进行抢购,类似于黄牛刷票操作:
② 启动测试:
结果后台会报错,同时订单表会出现一个用户对同一个代金券下单多次的现象:
5. 代金券抢购功能开发 - Redis 防止订单超卖
将抢购代金券活动写入Redis中,通过Redis自减指令扣除库存
1. RedisKeyConstan
package com.imooc.commons.constant;@Getter
public enum RedisKeyConstant {/*** redis 的 key*/verify_code("verify_code:", "验证码"),seckill_vouchers("seckill_vouchers:", "秒杀券的key"),private String key;private String desc;RedisKeyConstant(String key, String desc) {this.key = key;this.desc = desc;}
}
2. Redis 配置类 RedisTemplateConfiguration
package com.imooc.seckill.config;@Configuration
public class RedisTemplateConfiguration {/*** redisTemplate 序列化使用的jdkSerializeable, 存储二进制字节码, 所以自定义序列化类*/@Beanpublic RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();redisTemplate.setConnectionFactory(redisConnectionFactory);// 使用Jackson2JsonRedisSerialize 替换默认序列化Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);ObjectMapper objectMapper = new ObjectMapper();objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);jackson2JsonRedisSerializer.setObjectMapper(objectMapper);// 设置key和value的序列化规则redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);redisTemplate.setKeySerializer(new StringRedisSerializer());redisTemplate.setHashKeySerializer(new StringRedisSerializer());redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);redisTemplate.afterPropertiesSet();return redisTemplate;}
}
3. 添加抢购代金券
在SeckillVoucherService中,修改addSeckillVouchers()方法将数据存入Redis中,以HashMap的方式存储:
/*** 秒杀业务逻辑层*/
@Service
public class SeckillService {@Resourceprivate SeckillVouchersMapper seckillVouchersMapper;@Resourceprivate VoucherOrdersMapper voucherOrdersMapper;@Value("${service.name.ms-oauth-server}")private String oauthServerName;@Resourceprivate RestTemplate restTemplate;@Resourceprivate RedisTemplate redisTemplate;/*** 添加需要抢购的代金券*/@Transactional(rollbackFor = Exception.class)public void addSeckillVouchers(SeckillVouchers seckillVouchers) {// 非空校验AssertUtil.isTrue(seckillVouchers.getFkVoucherId() == null, "请选择需要抢购的代金券");AssertUtil.isTrue(seckillVouchers.getAmount() == 0, "请输入抢购总数量");Date now = new Date();AssertUtil.isNotNull(seckillVouchers.getStartTime(), "请输入开始时间");AssertUtil.isNotNull(seckillVouchers.getEndTime(), "请输入结束时间");AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "结束时间不能早于当前时间");AssertUtil.isTrue(seckillVouchers.getStartTime().after(seckillVouchers.getEndTime()), "开始时间不能晚于结束时间"); // ----------采用 Redis ----------String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + seckillVouchers.getFkVoucherId();// 验证 Redis 中是否已经存在该券的秒杀活动Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);AssertUtil.isTrue(!seckillVoucherMaps.isEmpty() && (int) seckillVoucherMaps.get("amount") > 0, "该券已经拥有了抢购活动");// 将数量同步到 RedisseckillVouchers.setIsValid(1);seckillVouchers.setCreateDate(now);seckillVouchers.setUpdateDate(now);seckillVoucherMaps = BeanUtil.beanToMap(seckillVouchers);redisTemplate.opsForHash().putAll(redisKey, seckillVoucherMaps);}
}
验证数据库表 t_seckill_vouchers 中是否已经存在该券的秒杀活动:
- 如果存在则抛出异常;
- 如果不存在则将添加一个代金券抢购活动到 t_seckill_vouchers 表中;
验证 Redis 中是否已经存在该券的秒杀活动:
- 如果存在则抛出异常;
- 如果不存在则将添加一个代金券抢购活动到redis中;
4. 对抢购代金券下单
/*** 秒杀业务逻辑层*/
@Service
public class SeckillService {@Resourceprivate SeckillVouchersMapper seckillVouchersMapper;@Resourceprivate VoucherOrdersMapper voucherOrdersMapper;@Value("${service.name.ms-oauth-server}")private String oauthServerName;@Resourceprivate RestTemplate restTemplate;@Resourceprivate RedisTemplate redisTemplate;/*** 抢购代金券** @param voucherId 代金券 ID* @param accessToken 登录token* @Para path 访问路径*/@Transactional(rollbackFor = Exception.class)public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {// 基本参数校验AssertUtil.isTrue(voucherId == null || voucherId < 0, "请选择需要抢购的代金券");AssertUtil.isNotEmpty(accessToken, "请登录");// ----------采用 Redis 解问题----------// 判断此代金券是否加入抢购String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + voucherId;Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);SeckillVouchers seckillVouchers = BeanUtil.mapToBean(seckillVoucherMaps, SeckillVouchers.class, true, null);// 判断是否有效AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束");// 判断是否开始、结束Date now = new Date();AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始");AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束");// 判断是否卖完通过 Lua 脚本扣库存时判断//AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完了");// 获取登录用户信息String url = oauthServerName + "user/me?access_token={accessToken}";ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {resultInfo.setPath(path);return resultInfo;}// 这里的data是一个LinkedHashMap,SignInDinerInfoSignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new SignInDinerInfo(), false);// 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),seckillVouchers.getId());AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢");// ----------采用 Redis 解问题----------// 扣库存long count = redisTemplate.opsForHash().increment(redisKey, "amount", -1);AssertUtil.isTrue(count < 0, "该券已经卖完了");// 下单VoucherOrders voucherOrders = new VoucherOrders();voucherOrders.setFkDinerId(dinerInfo.getId());// Redis 中不需要维护外键信息//voucherOrders.setFkSeckillId(seckillVouchers.getId());voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();voucherOrders.setOrderNo(orderNo);voucherOrders.setOrderType(1);voucherOrders.setStatus(0);count = voucherOrdersMapper.save(voucherOrders);AssertUtil.isTrue(count == 0, "用户抢购失败");return ResultInfoUtil.buildSuccess(path, "抢购成功");}
}
- 判断此代金券是否加入抢购;
- 扣库存;
5. 测试多人抢购代金券
1. 向redis中添加一个抢购代金券活动
查看 redis 的抢购代金券活动:
2. 5000并发2000用户同时抢购代金券
模拟5000个并发,2000个账号同时抢购代金券下单 :
查看redis中代金券的库存数量,库存数量变为-2085,明显有问题:
查看订单数量为100个和库存数量是一致的:
为什么订单数量是正常的呢?
当扣库存后会判断库存数量是否小于0,如果小于0会抛出异常,那么扣库存之后的下单逻辑就不会执行了,因此下单数量就是正常的。
为什么库存的数量是负数呢?
因为没有查询库存数量,直接进行了扣库存操作,应该先查询库存,然后执行扣库存操作。但是在高并发的场景下,即使先查询库存再扣除库存,仍然会出现超卖的问题,那么如何解决呢?
让查询库存数量和扣库存操作是一个原子操作,在一个线程中完成即可,采用 Lua 脚本解决。
3. lua 脚本解决扣库存问题
减库存时,我们需要判断系统库存够不够,然后才能减掉,这里是两个操作,如果分开独立执行,那么有可能会出现错误(因为客户端是多线程),因此我们采用 lua 脚本将两步操作放到一起同时在 Redis 中执行(Redis是单线程操作,故不会出现安全问题)。
① 将 stock.lua
脚本放入resources文件夹下:
if (redis.call('hexists', KEYS[1], KEYS[2]) == 1) thenlocal stock = tonumber(redis.call('hget', KEYS[1], KEYS[2]));if (stock > 0) thenredis.call('hincrby', KEYS[1], KEYS[2], -1);return stock;end;return 0;
end;
② 在 Redis 配置类中添加以下代码:
@Configuration
public class RedisTemplateConfiguration {@Beanpublic DefaultRedisScript<Long> stockScript() {DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();// 放在和application.yml 同层目录下redisScript.setLocation(new ClassPathResource("stock.lua"));redisScript.setResultType(Long.class);return redisScript;}
}
③ 修改扣库存逻辑,改为采用 lua 脚本扣库存:
package com.hh.seckill.service;/*** 秒杀业务逻辑层*/
@Service
public class SeckillService {@Resourceprivate SeckillVouchersMapper seckillVouchersMapper;@Resourceprivate VoucherOrdersMapper voucherOrdersMapper;@Value("${service.name.ms-oauth-server}")private String oauthServerName;@Resourceprivate RestTemplate restTemplate;@Resourceprivate RedisTemplate redisTemplate;@Resourceprivate DefaultRedisScript defaultRedisScript;/*** 抢购代金券** @param voucherId 代金券 ID* @param accessToken 登录token* @Para path 访问路径*/@Transactional(rollbackFor = Exception.class)public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {// 基本参数校验AssertUtil.isTrue(voucherId == null || voucherId < 0, "请选择需要抢购的代金券");AssertUtil.isNotEmpty(accessToken, "请登录");// 判断此代金券是否加入抢购String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + voucherId;Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);SeckillVouchers seckillVouchers = BeanUtil.mapToBean(seckillVoucherMaps, SeckillVouchers.class, true, null);// 判断是否有效AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束");// 判断是否开始、结束Date now = new Date();AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始");AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束");// 判断是否卖完通过 Lua 脚本扣库存时判断//AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完了");// 获取登录用户信息String url = oauthServerName + "user/me?access_token={accessToken}";ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {resultInfo.setPath(path);return resultInfo;}// 这里的data是一个LinkedHashMap,SignInDinerInfoSignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new SignInDinerInfo(), false);// 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),seckillVouchers.getId());AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢");// 下单VoucherOrders voucherOrders = new VoucherOrders();voucherOrders.setFkDinerId(dinerInfo.getId());voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();voucherOrders.setOrderNo(orderNo);voucherOrders.setOrderType(1);voucherOrders.setStatus(0);long count = voucherOrdersMapper.save(voucherOrders);AssertUtil.isTrue(count == 0, "用户抢购失败");// 采用 Redis + Lua 解决问题// 扣库存List<String> keys = new ArrayList<>();keys.add(redisKey);keys.add("amount");Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys);AssertUtil.isTrue(amount == null || amount < 1, "该券已经卖完了");return ResultInfoUtil.buildSuccess(path, "抢购成功");}
}
④ 启动项目测试:
查看redis,可以看到有一个抢购代金券的活动:
继续模拟5000并发2000用户同时抢购代金券的操作,可以看到代金券的扣减数量和下单数量是一致的:
超卖的问题已经解决了,但是此时运行某个用户发起多个抢购请求的测试计划仍然会出错,会出现一个人购买多份的情况,下面我们来解决一下限制一人一单的问题。
6. 代金券抢购功能开发 - Redis 限制一人一单
1. 不可重入锁
不可重入锁:
public class Lock {private boolean isLocked = false;public synchronized void lock() {while (isLocked) {try {wait();} catch (InterruptedException e) {e.printStackTrace();}}isLocked = true;}public synchronized void unlock() {isLocked = false;notify();}
}
测试不可重入锁:
package com.imooc.reentrant;public class UnReentrantLockDemo {private int count = 0;private Lock lock = new Lock();private void call() {lock.lock();inc();lock.unlock();}private void inc() {lock.lock();for (int i =0; i < 500; i++) {count ++;}lock.unlock();}public static void main(String[] args) throws Exception {UnReentrantLockDemo unReentrantLockDemo = new UnReentrantLockDemo();Thread t1 = new Thread(()-> unReentrantLockDemo.call());Thread t2 = new Thread(() -> unReentrantLockDemo.call());t1.start();t2.start();t1.join();t2.join();System.out.println(unReentrantLockDemo.count);}
}
当前线程执行call()方法首先获取lock,接下来执行inc()方法就无法执行inc()中的逻辑,必须先释放锁。这个例子很好的说明了不可重入锁。
2. 可重入锁
重入锁,指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。可重入锁的意义在于防止死锁。
实现原理是通过为每个锁关联一个请求计数器和一个占有它的线程。当计数为0时,认为锁是未被占有的;线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数器置为1 。如果同一个线程再次请求这个锁,计数将递增;每次占用线程退出同步块,计数器值将递减。直到计数器为0,锁被释放。
/*** 为每个锁关联一个请求计数器和一个占有它的线程。* 当计数为0时,认为锁是未被占有的;* 线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数器置为1 。*/
public class ReentrantLock {boolean isLocked = false;Thread lockBy = null; // 独占线程int lockedCount = 0; // 计数器public synchronized void lock() throws InterruptedException {Thread thread = Thread.currentThread();while (isLocked && lockBy != thread) { // 判断加锁,而且线程不是当前线程wait();}isLocked = true;lockedCount++; // 计数器 +1lockBy = thread;}public synchronized void unlock() {if (Thread.currentThread() == this.lockBy) { // 判断是否是当前线程lockedCount--;if (lockedCount == 0) { // 计数器为0时,释放锁isLocked = false;notify();}}}
}
测试可重入锁:
public class ReentrantLockDemo {private int count = 0;private ReentrantLock lock = new ReentrantLock();private void call() {try {lock.lock();} catch (InterruptedException e) {e.printStackTrace();}inc();lock.unlock();}private void inc() {try {lock.lock();} catch (InterruptedException e) {e.printStackTrace();}for (int i =0; i < 500; i++) {count ++;}lock.unlock();}public static void main(String[] args) throws Exception {ReentrantLockDemo demo = new ReentrantLockDemo();Thread t1 = new Thread(()-> demo.call());Thread t2 = new Thread(() -> demo.call());t1.start();t2.start();t1.join();t2.join();System.out.println(demo.count);}
}
所谓可重入,意味着线程可以进入它已经拥有的锁的同步代码块儿。
我们设计两个线程调用call()方法,第一个线程调用call()方法获取锁,进入lock()方法,由于初始lockedBy是null,所以不会进入while而挂起当前线程,而是是增量lockedCount并记录lockBy为第一个线程。接着第一个线程进入inc()方法,由于同一进程,所以不会进入while而挂起,接着增量lockedCount,当第二个线程尝试lock,由于isLocked=true,所以他不会获取该锁,直到第一个线程调用两次unlock()将lockCount递减为0,才将标记为isLocked设置为false。
3. Redis 可重入锁
假设锁的key为“ lock ”,hashKey是当前线程的id:“ threadId ”,锁自动释放时间假设为20
① 获取锁的步骤:判断lock是否存在
- 不存在,则自己获取锁,记录重入层数为1;
- 存在,说明有人获取锁了,下面判断是不是自己的锁,即判断当前线程 id 作为 hashKey 是否存在;
- 不存在,说明锁已经有了,且不是自己获取的,锁获取失败;
- 存在,说明是自己获取的锁,重入次数+1
- 最后更新锁自动释放时间;
② 释放锁的步骤:判断当前线程id作为hashKey是否存在
- 不存在,说明锁已经失效,不用管了 ;
- 存在,说明锁还在,重入次数减1;
- 获取新的重入次数,判断重入次数是否为0,为0说明锁全部释放,删除key ;
因此,存储在锁中的信息就必须包含:key、线程标识、重入次数。不能再使用简单的key-value结构, 这里推荐使用hash结构。而且要让所有指令都在同一个线程中操作,那么使用lua脚本
1. 设计 lock.lua 脚本
local key = KEYS[1]; -- 第1个参数,锁的key
local threadId = ARGV[1]; -- 第2个参数,线程唯一标识
local releaseTime = ARGV[2]; -- 第3个参数,锁的自动释放时间if(redis.call('exists', key) == 0) then -- 判断锁是否已存在redis.call('hset', key, threadId, '1'); -- 不存在, 则获取锁redis.call('expire', key, releaseTime); -- 设置有效期return 1; -- 返回结果
end;if(redis.call('hexists', key, threadId) == 1) then -- 锁已经存在,判断threadId是否是自己 redis.call('hincrby', key, threadId, '1'); -- 如果是自己,则重入次数+1redis.call('expire', key, releaseTime); -- 设置有效期return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
2. 设计 unlock.lua 脚本
local key = KEYS[1]; -- 第1个参数,锁的key
local threadId = ARGV[1]; -- 第2个参数,线程唯一标识if (redis.call('HEXISTS', key, threadId) == 0) then -- 判断当前锁是否还是被自己持有return nil; -- 如果已经不是自己,则直接返回
end;
local count = redis.call('HINCRBY', key, threadId, -1); -- 是自己的锁,则重入次数-1if (count == 0) then -- 判断是否重入次数是否已经为0redis.call('DEL', key); -- 等于0说明可以释放锁,直接删除return nil;
end;
3. RedisLock类
package com.hh.seckill.model;@Getter
@Setter
public class RedisLock {private RedisTemplate redisTemplate;private DefaultRedisScript<Long> lockScript;private DefaultRedisScript<Object> unlockScript;public RedisLock(RedisTemplate redisTemplate) {this.redisTemplate = redisTemplate;// 加载释放锁的脚本this.lockScript = new DefaultRedisScript<>();this.lockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock.lua")));this.lockScript.setResultType(Long.class);// 加载释放锁的脚本this.unlockScript = new DefaultRedisScript<>();this.unlockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlock.lua")));}/*** 获取锁* @param lockName 锁名称* @param releaseTime 超时时间(单位:秒)* @return key 解锁标识*/public String tryLock(String lockName, long releaseTime) {// 存入的线程信息的前缀,防止与其它JVM中线程信息冲突String key = UUID.randomUUID().toString();// 执行脚本Long result = (Long)redisTemplate.execute(lockScript,Collections.singletonList(lockName),key + Thread.currentThread().getId(), releaseTime);// 判断结果if(result != null && result.intValue() == 1) {return key;}else {return null;}}/*** 释放锁* @param lockName 锁名称* @param key 解锁标识*/public void unlock(String lockName, String key) {// 执行脚本redisTemplate.execute(unlockScript,Collections.singletonList(lockName),key + Thread.currentThread().getId(), null);}
}
4. RedisLockConfiguration类
package com.hh.seckill.config;@Configuration
public class RedisLockConfiguration {@Resourceprivate RedisTemplate redisTemplate;@Beanpublic RedisLock redisLock() {RedisLock redisLock = new RedisLock(redisTemplate);return redisLock;}
}
5. 修改抢购代金券逻辑限制一人一单
/*** 秒杀业务逻辑层*/
@Service
public class SeckillService {@Resourceprivate SeckillVouchersMapper seckillVouchersMapper;@Resourceprivate VoucherOrdersMapper voucherOrdersMapper;@Value("${service.name.ms-oauth-server}")private String oauthServerName;@Resourceprivate RestTemplate restTemplate;@Resourceprivate RedisTemplate redisTemplate;@Resourceprivate DefaultRedisScript defaultRedisScript;@Resourceprivate RedisLock redisLock;/*** 抢购代金券** @param voucherId 代金券 ID* @param accessToken 登录token* @Para path 访问路径*/@Transactional(rollbackFor = Exception.class)public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {// 基本参数校验AssertUtil.isTrue(voucherId == null || voucherId < 0, "请选择需要抢购的代金券");AssertUtil.isNotEmpty(accessToken, "请登录");// ----------采用 Redis 解问题----------// 判断此代金券是否加入抢购String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + voucherId;Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);SeckillVouchers seckillVouchers = BeanUtil.mapToBean(seckillVoucherMaps, SeckillVouchers.class, true, null);// 判断是否有效AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束");// 判断是否开始、结束Date now = new Date();AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始");AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束");// 获取登录用户信息String url = oauthServerName + "user/me?access_token={accessToken}";ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {resultInfo.setPath(path);return resultInfo;}// 这里的data是一个LinkedHashMap,SignInDinerInfoSignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new SignInDinerInfo(), false);// 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),seckillVouchers.getId());AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢");// 使用 Redis 锁一个账号只能购买一次// 自定义 Redis 分布式锁String lockName = RedisKeyConstant.lock_key.getKey() + dinerInfo.getId() + ":" + voucherId;long expireTime = seckillVouchers.getEndTime().getTime() - now.getTime();String lockKey = redisLock.tryLock(lockName, expireTime);try {// 拿到锁了,执行下单if(StringUtils.isNotBlank(lockKey)){// 下单VoucherOrders voucherOrders = new VoucherOrders();voucherOrders.setFkDinerId(dinerInfo.getId());voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();voucherOrders.setOrderNo(orderNo);voucherOrders.setOrderType(1);voucherOrders.setStatus(0);long count = voucherOrdersMapper.save(voucherOrders);AssertUtil.isTrue(count == 0, "用户抢购失败");// 扣库存// 采用 Redis + Lua 解决问题List<String> keys = new ArrayList<>();keys.add(redisKey);keys.add("amount");Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys);AssertUtil.isTrue(amount == null || amount < 1, "该券已经卖完了");}}catch (Exception e){// 手动回滚事务TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();// 自定义 Redis 解锁redisLock.unlock(lockName, lockKey);if (e instanceof ParameterException) {return ResultInfoUtil.buildError(0, "该券已经卖完了", path);}}return ResultInfoUtil.buildSuccess(path, "抢购成功");}
}
6. 启动项目测试
模拟10000并发下,同一个账号下单抢购代金券:
可以看到数据库中的下单数量只有一单:
库存扣减数量也是1:
4. Redission 分布式锁的应用
① 引入依赖:
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.13.6</version>
</dependency>
② 修改抢购代金券逻辑限制一人一单:
/*** 秒杀业务逻辑层*/
@Service
public class SeckillService {@Resourceprivate SeckillVouchersMapper seckillVouchersMapper;@Resourceprivate VoucherOrdersMapper voucherOrdersMapper;@Value("${service.name.ms-oauth-server}")private String oauthServerName;@Resourceprivate RestTemplate restTemplate;@Resourceprivate RedisTemplate redisTemplate;@Resourceprivate DefaultRedisScript defaultRedisScript;@Resourceprivate RedisLock redisLock;@Resourceprivate RedissonClient redissonClient;/*** 抢购代金券** @param voucherId 代金券 ID* @param accessToken 登录token* @Para path 访问路径*/@Transactional(rollbackFor = Exception.class)public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {// 基本参数校验AssertUtil.isTrue(voucherId == null || voucherId < 0, "请选择需要抢购的代金券");AssertUtil.isNotEmpty(accessToken, "请登录");// ----------采用 Redis 解问题----------// 判断此代金券是否加入抢购String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + voucherId;Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);SeckillVouchers seckillVouchers = BeanUtil.mapToBean(seckillVoucherMaps, SeckillVouchers.class, true, null);// 判断是否有效AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束");// 判断是否开始、结束Date now = new Date();AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始");AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束");// 获取登录用户信息String url = oauthServerName + "user/me?access_token={accessToken}";ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {resultInfo.setPath(path);return resultInfo;}// 这里的data是一个LinkedHashMap,SignInDinerInfoSignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new SignInDinerInfo(), false);// 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),seckillVouchers.getId());AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢");// 使用 Redis 锁一个账号只能购买一次// 自定义 Redis 分布式锁String lockName = RedisKeyConstant.lock_key.getKey() + dinerInfo.getId() + ":" + voucherId;long expireTime = seckillVouchers.getEndTime().getTime() - now.getTime();// String lockKey = redisLock.tryLock(lockName, expireTime);// 使用redission分布式锁获取锁RLock lock = redissonClient.getLock(lockName);try {// 拿到锁了,执行下单// redission 分布式锁加锁boolean isLocked = lock.tryLock(expireTime, TimeUnit.MILLISECONDS);if(isLocked){// 下单VoucherOrders voucherOrders = new VoucherOrders();voucherOrders.setFkDinerId(dinerInfo.getId());voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();voucherOrders.setOrderNo(orderNo);voucherOrders.setOrderType(1);voucherOrders.setStatus(0);long count = voucherOrdersMapper.save(voucherOrders);AssertUtil.isTrue(count == 0, "用户抢购失败");// 扣库存// 采用 Redis + Lua 解决问题List<String> keys = new ArrayList<>();keys.add(redisKey);keys.add("amount");Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys);AssertUtil.isTrue(amount == null || amount < 1, "该券已经卖完了");}}catch (Exception e){// 手动回滚事务TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();// redission 分布式锁解锁lock.unlock();if (e instanceof ParameterException) {return ResultInfoUtil.buildError(0, "该券已经卖完了", path);}}return ResultInfoUtil.buildSuccess(path, "抢购成功");}
}
③ 启动项目测试:压力测试通过
Redis实战 - 04 Redis 分布式锁应用之抢购代金券相关推荐
- redisson redlock(基于redisson框架和redis集群使用分布式锁)
一.关于分布式锁的两篇文章 文章1 文章2 二.redis分布式锁存在的问题 redis实现分布式锁有很多种方案,比较完善的方案应该是用setNx + lua进行实现.简单实现如下: java代码-加 ...
- Redis与Zookeeper实现分布式锁的区别
Redis与Zookeeper实现分布式锁的区别 1.分布式锁解决方案 1.采用数据库 不建议 性能不好 jdbc 2.基于Redis实现分布式锁(setnx)setnx也可以存入key,如果存入ke ...
- 在 Redis 上实现的分布式锁
由于近排很忙,忙各种事情,还有工作上的项目,已经超过一个月没写博客了,确实有点惭愧啊,没能每天或者至少每周坚持写一篇博客.这一个月里面接触到很多新知识,同时也遇到很多技术上的难点,在这我将对每一个有用 ...
- redis set 超时_redis分布式锁3种实现方式对比分析总结
我在这篇文章提到了分布式锁,但没有展开来讲,抛砖引玉,今天就来说说高并发服务编程中的redis分布式锁. 这里罗列出3种redis实现的分布式锁,并分别对比说明各自特点. Redis单实例分布式锁 实 ...
- Redis应用学习——Redis事务与实现分布式锁
2019独角兽企业重金招聘Python工程师标准>>> 1. Redis事务机制 1. 与MySQL等关系数据库相同,Redis中也有事务机制,Redis的事务实质上是命令的集合,但 ...
- Redis使用setnx实现分布式锁及其问题、优化
最近在工作中用到了分布式锁,然后查了很多分布式锁的实现方式.比较熟悉redis或者说,redis的用法比较简单,所以查了一下redis使用setnx实现分布式锁的方式.其中有一篇文章搜索到的次数最多, ...
- Day137-139.尚品汇:制作SKU、商品详情、项目优化:Redis缓存、redssion分布式锁
目录 Day5 制作SKU 1. 制作SKU 2. 多表查询如何写? 3. 制作SKU 4. Thymeleaf Day06 商品详情 1. 获取分类信息 2. 获取最新价格信息 3. 获取销售信息 ...
- Redis实战之Redis + Jedis
用Memcached,对于缓存对象大小有要求,单个对象不得大于1MB,且不支持复杂的数据类型,譬如SET 等.基于这些限制,有必要考虑Redis! 相关链接: Redis实战 Redis实战之Redi ...
- redis(二)redis实战 使用redis进行文章的排序
2019独角兽企业重金招聘Python工程师标准>>> http://www.beckbi.cn/?p=172 redis实战使用redis进行文章的排序 转载于:https://m ...
最新文章
- n2n windows 编译安装文件
- BZOJ1196: [HNOI2006]公路修建问题
- Android学习:自定义ViewGroup方法总结
- c语言求上升沿次数,[求助]上升沿信号在C语言中怎么写啊
- 微信小程序:背景图片在电脑可以显示,真机测试时无法显示
- 占空比50%的奇数分频
- 配置SQL Server 2012 AlwaysOn ——step1 建立AD域及DNS配置
- 线切割机上的DOS系统
- 十分钟快速了解 ES6 Promise
- iOS中用到的唯一标示符
- git pull命令模式
- 如何对第一个值相同的列表中的元组求和
- gom引擎没有和账号服务器连接,GOM引擎提示脚本错误,找不到脚本
- 【开源编码分享】Python古三式∶太乙神数丶奇门遁甲丶大六壬
- 巴别鱼雏形,谷歌推出端到端语音翻译技术,还能模仿你说话
- 【Angular】使用高德地图比例尺心得总结
- 【马克思主义基本原理概论】
- 【网络流量监控工具之Nethogs】
- 新业务员如何开发外贸客户?
- 下载STM32CubeMX软件固件库包的方法