一、应用场景
1、多人并发场景,例如秒杀,抢购,设置总数量一百,并发一次减1,减到0为止。
2、单人并发场景,例如连续点击事件,抢票脚本,事件往表插入一条记录,一个用户只能插入一条记录。
二、表结构
-- Create table create table IPLN_REDIS_TEST ( id VARCHAR2(32), key VARCHAR2(100), value NUMBER, remark VARCHAR2(100) ) tablespace TS_VER_EPM pctfree 10 initrans 1 maxtrans 255 storage ( initial 64K next 1M minextents 1 maxextents unlimited ); -- Add comments to the table comment on table IPLN_REDIS_TEST is 'redis测试'; |
三、代码实现-使用关系型数据库处理并发
1、多人并发代码,请求成功,数量减1,减到0为止
@ResponseBody @RequestMapping(value = {"multipleConcurrency.do"}, method = {RequestMethod.POST}) public String multipleConcurrency(@RequestBody Map<String, Object> params){ //校验数据库中是否有key // ----------采用关系型数据库 处理并发---------- Map<String, Object> keyMap = iRedisTestService.getRedisTestByKey(params); if(keyMap != null && Integer.parseInt(keyMap.get("VALUE").toString()) > 0){ iRedisTestService.updateRedisTest(params); }else{ return "没有Key,或者并发数为0"; } return "并发成功,value-1"; } |
2、单人并发代码,请求成功,插入一条数据,同一操作人只能插入一条数据
@ResponseBody @RequestMapping(value = {"singleConcurrency.do"}, method = {RequestMethod.POST}) public String singleConcurrency(@RequestBody Map<String, Object> params){ // ----------采用关系型数据库 处理并发---------- Map<String, Object> keyMap = iRedisTestService.getRedisTestByKey(params); if(keyMap == null){ params.put("value", 1); params.put("remark", "一个用户并发"); iRedisTestService.insertRedisTest(params); }else{ return "已有并发数"; } return "并发成功,插入一条数据"; } |
四、PostMan进行测试
1、多用户并发postman测试
2、单用户并发测试
3、测试结果
五、Jmeter并发测试
1、编写测试计划,
单个并发测试,线程数5000并发
多个并发测试,线程数5000并发
2、测试结果
多用户并发测试,扣减为负数,并发处理失败
单用户并发测试,插入了多条记录,并发处理失败
六、Redis处理并发
1、加入redis处理并发,100数量写入redis中
2、代码优化,采用Redis自减指令扣除减1
@ResponseBody @RequestMapping(value = {"multipleConcurrency.do"}, method = {RequestMethod.POST}) public String multipleConcurrency(@RequestBody Map<String, Object> params){ //校验数据库中是否有key // ----------采用关系型数据库 处理并发---------- // Map<String, Object> keyMap = iRedisTestService.getRedisTestByKey(params); // if(keyMap != null && Integer.parseInt(keyMap.get("VALUE").toString()) > 0){ // iRedisTestService.updateRedisTest(params); // }else{ // return "没有Key,或者并发数为0"; // } // return "并发成功,value-1"; // // ----------采用 Redis 处理并发---------- String key = params.get("key").toString(); Map<Object, Object> keyMap = redisTemplate.opsForHash().entries(key); if(keyMap != null){ Integer value = Integer.parseInt(keyMap.get("VALUE").toString()); if(value > 0){ long count = redisTemplate.opsForHash().increment(key,"VALUE",-1); if(count < 0){ return "并发数为0"; } }else { return "并发数为0"; } }else{ return "没有Key"; } return "并发成功,value-1"; } |
3、Jmeter并发测试结果
并发结果并没有变化,还是扣除超限,扣减为负数,并发处理失败
七、原因分析
因为不管是在关系型数据库还是redis中,这里实际上是一个查询库存再扣除库存的操作,减库存时,我们需要判断系统库存够不够,然后才能减掉,这里是两个操作,在大量并发场景下依然会出现问题,因为客户端是多线程的,如果分开独立执行,那么有可能会出现错误,那么我们就需保证两个操作在同一个线程中执行即可,也就是保证它的原子性。如何实现呢?
我们可以采用redis+lua脚本将两步操作放到一起同时在Redis中执行,Redis是单线程操作,故不会出现安全问题。
八、Redis + lua 脚本处理并发
1、lua脚本代码
Lua脚本是redis已经内置的一种轻量小巧语言,用标准C语言编写并以源代码形式开放,其执行是通过redis的eval /evalsha 命令来运行,把操作封装成一个Lua脚本,如论如何都是一次执行的原子操作。
if (redis.call('hexists', KEYS[1], KEYS[2]) == 1) then local stock = tonumber(redis.call('hget', KEYS[1], KEYS[2])); if (stock > 0) then redis.call('hincrby', KEYS[1], KEYS[2], -1); return stock; end; return 0; end; |
2、lua配置类
@Bean public DefaultRedisScript<Long> stockScript() { DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); //放在和application.yml 同层目录下 redisScript.setLocation(new ClassPathResource("stock.lua")); redisScript.setResultType(Long.class); return redisScript; } |
3、Redis + Lua 代码实现
@ResponseBody @RequestMapping(value = {"multipleConcurrency.do"}, method = {RequestMethod.POST}) public String multipleConcurrency(@RequestBody Map<String, Object> params){ //校验数据库中是否有key // ----------采用关系型数据库 处理并发---------- // Map<String, Object> keyMap = iRedisTestService.getRedisTestByKey(params); // if(keyMap != null && Integer.parseInt(keyMap.get("VALUE").toString()) > 0){ // iRedisTestService.updateRedisTest(params); // }else{ // return "没有Key,或者并发数为0"; // } // return "并发成功,value-1"; // // ----------采用 Redis 处理并发---------- // String key = params.get("key").toString(); // Map<Object, Object> keyMap = redisTemplate.opsForHash().entries(key); // // if(keyMap != null){ // Integer value = Integer.parseInt(keyMap.get("VALUE").toString()); // if(value > 0){ // long count = redisTemplate.opsForHash().increment(key,"VALUE",-1); // if(count < 0){ // return "并发数为0"; // } // }else { // return "并发数为0"; // } // }else{ // return "没有Key"; // } // return "并发成功,value-1"; // ----------采用 Redis + Lua 处理并发---------- String key = params.get("key").toString(); List<String> keys = new ArrayList<>(); keys.add(key); keys.add("VALUE"); Long value = (Long) redisTemplate.execute(defaultRedisScript, keys); if(value < 0){ return "并发数为0"; } return "并发成功,value-1"; } |
4、测试结果,并没有扣减到负数,并发测试通过
九、单个并发处理,分布式锁
采用Redis分布式锁限制一人一个并发
锁是一种保护机制,在多线程的情况下,保证数据操作的一致性。
分布式锁实现:方式有三种:基于数据库;基于Zookeeper调度中心;基于Redis
分布式锁条件:实现分布式锁要满足3点:多进程可见,互斥,可重入。
1、使用RedisTemplate实现分布式锁
// 加锁 public Boolean tryLock(String key, String value, long timeout, TimeUnit unit) { return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); } // 解锁,防止删错别人的锁,以uuid为value校验是否自己的锁 public void unlock(String lockName, String uuid) { if(uuid.equals(redisTemplate.opsForValue().get(lockName)){ redisTemplate.opsForValue().del(lockName); } } // 结构 if(tryLock){ // todo }finally{ unlock; } |
缺陷:这是锁没错,但get和del操作非原子性,并发一旦大了,无法保证进程安全
2、使用redis+lua实现分布式锁
lua脚本
if redis.call('get', KEYS[1]) == ARGV[1] then -- 执行删除操作 return redis.call('del', KEYS[1]) else -- 不成功,返回0 return 0 end |
delete操作时执行Lua命令
// 解锁脚本 DefaultRedisScript<Object> unlockScript = new DefaultRedisScript(); unlockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lockDel.lua"))); // 执行lua脚本解锁 redisTemplate.execute(unlockScript, Collections.singletonList(keyName), value); |
缺陷:不可重入,一个方法获取到锁之后,可能在方法内调这个方法此时就获取不到锁了,必须先释放锁才能再次获取锁。这个时候我们就需要把锁改进成可重入锁了。
3、可重入锁设计
假设锁的key为“ lock ”,hashKey是当前线程的id:“ threadId ”,锁自动释放时间假设为20 获取锁的步骤: 1、判断lock是否存在 EXISTS lock 2、不存在,则自己获取锁,记录重入层数为1. 2、存在,说明有人获取锁了,下面判断是不是自己的锁,即判断当前线程id作为hashKey是否存在:HEXISTS lock threadId 3、不存在,说明锁已经有了,且不是自己获取的,锁获取失败. 3、存在,说明是自己获取的锁,重入次数+1: HINCRBY lock threadId 1 ,最后更新锁自动释放时间, EXPIRE lock 20 释放锁的步骤: 1、判断当前线程id作为hashKey是否存在: HEXISTS lock threadId 2、不存在,说明锁已经失效,不用管了 2、存在,说明锁还在,重入次数减1: HINCRBY lock threadId -1 , 3、获取新的重入次数,判断重入次数是否为0,为0说明锁全部释放,删除key: DEL lock |
设计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'); -- 如果是自己,则重入次数+1 redis.call('expire', key, releaseTime); -- 设置有效期 return 1; -- 返回结果 end; return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败 |
设计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); -- 是自己的锁,则重入次数-1 if (count == 0) then -- 判断是否重入次数是否已经为0 redis.call('DEL', key); -- 等于0说明可以释放锁,直接删除 return nil; end; |
十、Redisson分布式锁
Redisson是一个基于Redis实现的分布式工具,有基本分布式对象和高级又抽象的分布式服务,除了提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。可以让使用者对Redis的关注分离,从而让使用者能够将精力更集中地放在处理业务逻辑上。
1、引入依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.13.6</version> </dependency> |
2、添加配置类
@Configuration public class RedissionConfig { @Value("${spring.redis.host}") private String redisHost; @Value("${spring.redis.password}") private String password; private int port = 6379; private int database = 1; @Bean public RedissonClient getRedisson() { Config config = new Config(); config.useSingleServer(). setAddress("redis://" + redisHost + ":" + port). setPassword(password). setDatabase(database); config.setCodec(new JsonJacksonCodec()); return Redisson.create(config); } } |
3、代码改造
@Resource private RedissonClient redissonClient; @ResponseBody @RequestMapping(value = {"singleConcurrency.do"}, method = {RequestMethod.POST}) public String singleConcurrency(@RequestBody Map<String, Object> params){ // ----------采用关系型数据库 处理并发---------- // Map<String, Object> keyMap = iRedisTestService.getRedisTestByKey(params); // if(keyMap == null){ // params.put("value", 1); // params.put("remark", "一个用户并发"); // iRedisTestService.insertRedisTest(params); // }else{ // return "已有并发数"; // } // // return "并发成功,插入一条数据"; // ----------采用 Redission 处理并发---------- Map<String, Object> keyMap = iRedisTestService.getRedisTestByKey(params); if(keyMap != null){ return "已有并发数"; } String lockName = params.get("key").toString(); RLock lock = redissonClient.getLock(lockName); long expireTime = 20000; try { boolean isLocked = lock.tryLock(expireTime, TimeUnit.MILLISECONDS); if(isLocked){ params.put("value", 1); params.put("remark", "一个用户并发"); iRedisTestService.insertRedisTest(params); } } catch (InterruptedException e) { lock.unlock(); e.printStackTrace(); } return "并发成功,插入一条数据"; } |
4、压力测试通过
并发量5000,只插入一条数据