文章目录

  • 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 分布式锁应用之抢购代金券相关推荐

  1. redisson redlock(基于redisson框架和redis集群使用分布式锁)

    一.关于分布式锁的两篇文章 文章1 文章2 二.redis分布式锁存在的问题 redis实现分布式锁有很多种方案,比较完善的方案应该是用setNx + lua进行实现.简单实现如下: java代码-加 ...

  2. Redis与Zookeeper实现分布式锁的区别

    Redis与Zookeeper实现分布式锁的区别 1.分布式锁解决方案 1.采用数据库 不建议 性能不好 jdbc 2.基于Redis实现分布式锁(setnx)setnx也可以存入key,如果存入ke ...

  3. 在 Redis 上实现的分布式锁

    由于近排很忙,忙各种事情,还有工作上的项目,已经超过一个月没写博客了,确实有点惭愧啊,没能每天或者至少每周坚持写一篇博客.这一个月里面接触到很多新知识,同时也遇到很多技术上的难点,在这我将对每一个有用 ...

  4. redis set 超时_redis分布式锁3种实现方式对比分析总结

    我在这篇文章提到了分布式锁,但没有展开来讲,抛砖引玉,今天就来说说高并发服务编程中的redis分布式锁. 这里罗列出3种redis实现的分布式锁,并分别对比说明各自特点. Redis单实例分布式锁 实 ...

  5. Redis应用学习——Redis事务与实现分布式锁

    2019独角兽企业重金招聘Python工程师标准>>> 1. Redis事务机制 1. 与MySQL等关系数据库相同,Redis中也有事务机制,Redis的事务实质上是命令的集合,但 ...

  6. Redis使用setnx实现分布式锁及其问题、优化

    最近在工作中用到了分布式锁,然后查了很多分布式锁的实现方式.比较熟悉redis或者说,redis的用法比较简单,所以查了一下redis使用setnx实现分布式锁的方式.其中有一篇文章搜索到的次数最多, ...

  7. Day137-139.尚品汇:制作SKU、商品详情、项目优化:Redis缓存、redssion分布式锁

    目录 Day5  制作SKU 1. 制作SKU 2. 多表查询如何写? 3. 制作SKU 4. Thymeleaf Day06 商品详情 1. 获取分类信息 2. 获取最新价格信息 3. 获取销售信息 ...

  8. Redis实战之Redis + Jedis

    用Memcached,对于缓存对象大小有要求,单个对象不得大于1MB,且不支持复杂的数据类型,譬如SET 等.基于这些限制,有必要考虑Redis! 相关链接: Redis实战 Redis实战之Redi ...

  9. redis(二)redis实战 使用redis进行文章的排序

    2019独角兽企业重金招聘Python工程师标准>>> http://www.beckbi.cn/?p=172 redis实战使用redis进行文章的排序 转载于:https://m ...

最新文章

  1. n2n windows 编译安装文件
  2. BZOJ1196: [HNOI2006]公路修建问题
  3. Android学习:自定义ViewGroup方法总结
  4. c语言求上升沿次数,[求助]上升沿信号在C语言中怎么写啊
  5. 微信小程序:背景图片在电脑可以显示,真机测试时无法显示
  6. 占空比50%的奇数分频
  7. 配置SQL Server 2012 AlwaysOn ——step1 建立AD域及DNS配置
  8. 线切割机上的DOS系统
  9. 十分钟快速了解 ES6 Promise
  10. iOS中用到的唯一标示符
  11. git pull命令模式
  12. 如何对第一个值相同的列表中的元组求和
  13. gom引擎没有和账号服务器连接,GOM引擎提示脚本错误,找不到脚本
  14. 【开源编码分享】Python古三式∶太乙神数丶奇门遁甲丶大六壬
  15. 巴别鱼雏形,谷歌推出端到端语音翻译技术,还能模仿你说话
  16. 【Angular】使用高德地图比例尺心得总结
  17. 【马克思主义基本原理概论】
  18. 【网络流量监控工具之Nethogs】
  19. 新业务员如何开发外贸客户?
  20. 下载STM32CubeMX软件固件库包的方法

热门文章

  1. 银河麒麟V10高级服务器操作系统clickhouse数据迁移技术全网唯一
  2. Springboot漫画网站j846u计算机毕业设计-课程设计-期末作业-毕设程序代做
  3. css设置各种中文字体,雅黑,黑体,宋体,楷体等等
  4. mysql 未将对象引用设置到对象的实例_未将对象引用设置到对象的实例 总结
  5. 中山大学计算机考研资料汇总
  6. 计算机网络之三层交换机VLAN配置
  7. python向上取整_Python 向上取整的算法
  8. Revit中幕墙放置墙装饰条做法及墙体包络问题
  9. Robocup-Rescue仿真救援简介
  10. HTML 标签、转义字符及相应的 Java 过滤方法