业务模型
这里以秒杀系统为例,在短时间内会发生大量的并发访问。我们需要精确的控制数据的存储与修改。
Service层
这里实现两个简单的功能,一个是查询库存,一个是扣除库存。 因为是模拟业务情景,所以可以暂时不用设计到数据库的访问。
@Service
public class SecKillServiceImpl {
// 模拟数据库中的数据。 产品, 库存, 订单
static Map<String, Integer> products;
static Map<String, Integer> stock;
static Map<String, String> orders;
static {
products = new HashMap<>();
stock = new HashMap<>();
orders = new HashMap<>();
products.put("123456", 100000);
stock.put("123456", 100000);
}
/**
* 根据商品id获取
* @param productId 商品id
* @return 商品所剩库存和成功下单的用户
*/
public String queryMap(String productId) {
return "限量:" + products.get(productId) + "份, 还剩:" + stock.get(productId) + "份, 成功下单用户:" + orders.size();
}
/**
* 下单操作
* @param productId 要购买的商品id
*/
public void orderProductMockDiffUser(String productId) {
// 查看对应的商品是否还有库存
Integer stockNum = stock.get(productId);
if(stockNum == 0) {
throw new SellException(100, "活动结束");
} else {
// 生成随机id,当作购买用户
orders.put(KeyUtil.genUniqueKey(), productId);
stockNum = stockNum - 1;
// 模拟一些IO操作
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 库存减少
stock.put(productId, stockNum);
}
}
}
以上就是Service层的代码。 主要模拟了两个操作,一个是根据商品id查看剩余数量和下单用户数,一个是下单操作。
Controller层
@RestController
@Slf4j
@RequestMapping("/skill")
public class SecKillController {
@Autowired
SecKillServiceImpl secKillService;
@GetMapping("/query/{productId}")
public String query(@PathVariable("productId") String productId) {
return secKillService.queryMap(productId);
}
@GetMapping("/order/{productId}")
public String skill(@PathVariable("productId") String productId) {
log.info("secKill request, productId:" + productId);
secKillService.orderProductMockDiffUser(productId);
return secKillService.queryMap(productId);
}
}
Controller层比较简单,就是两个接口,对应Service层的两个方法。
测试
先调用几次下单接口,然后再查询一下剩余数量。看看业务代码的表现情况。
这里先用手动调用的方法,多调用几次下单接口,再看。
这里可以看出来,剩余数量和成功下单用户之和等于总数。那么可以说明代码是没有问题的
接下来,我们模拟一下高并发的情况。在此使用apache ab来进行压测。
这里就先简单的介绍一下apache ab的使用:
ab -n 1000 -c 100 http://localhost:8080/sell/skill/order/123456
-n 后面的参数是表示 发送1000个请求
-c 后面的参数是表示 模拟100个并发
后面的url就是要测试访问的地址了
ab -t 60 -c 100 http://localhost:8080/sell/skill/order/123456
当然还有如上的另外一种测试, -t 表示60s内发送请求
这里我们使用第一种方式来进行测试。测试完之后,再调用query接口查看
可以发现,这里的数量就不合适了。
至于为什么会产生这样的情况,是因为这里的业务操作不是线程安全的。 当A线程正在修改库存剩余数量时(此时还未修改完成), 但是B线程又来读取当前剩余库存了。这时两者读取到的剩余库存是一样的,所以当订单orders的size增加了,但是库存量只减少了一个。
synchronized处理并发
对于多线程并发有过了解的同学可能会想到,可以用synchronized来处理刚才所出现的情况。 那么真的可以很好的解决吗? 我们再来测试一下
我们只需要在Service层下单的方法上加上synchronized就可以了
public synchronized void orderProductMockDiffUser(String productId) {
// 代码省略
}
那么这时,我们再用apache ab进行一下压测。
可以发现,虽然结果能够正确,但是非常耗时。 如果我们只是对一个商品进行秒杀活动,那么这样做是没有什么问题的。但是如果当有多个商品都要做秒杀活动,A商品的购买量很大,但是B商品的购买量相对小很多。但是在秒杀A的时候就已经阻塞了下单的方法,那么也会导致B商品的下单方法阻塞。
Redis分布式锁
Redis之所以可以作为分布式锁,是因为它是单线程的。
我们在进行库存,订单操作之前,先添加一个锁,当操作完之后再解锁。在被锁上的情况下,又想有库存,订单操作那么就直接返回。
加锁
这里先介绍两个redis的命令:
SETNX
将key设置值为value,如果key不存在,这种情况下等同SET命令。 当key存在时,什么也不做。SETNX是”SET if Not eXists”的简写。
// 对应的java方法
boolean result = redisTemplate.opsForValue().setIfAbsent(key, value);
GETSET
自动将key对应到value并且返回原来key对应的value。如果key存在但是对应的value不是字符串,就返回错误。
// 对应的java方法
String value = redisTemplate.opsForValue().getAndSet(key, value);
Redis加锁的基本思路:
1. 根据商品id为key,时间戳(当前时间+超时时间)为value。调用SETNX设置redis
2. 如果设置成功则直接返回true,表示加锁成功。 如果返回false,则继续向下判断
3. 如果直接设置value失败,还需要进一步判断锁是否过期。如果未过期则直接返回false,表示加锁失败。如果过期继续向下判断
4. 如果锁过期,那么调用GETSET方法,获取旧的value值,并将当前的value设置进去。
5. 获取到旧的value值之后,再判断一下旧的value是否等于当前value值。如果等于则返回true。 否则返回false
/**
* 加锁
* @param key
* @param value 当前时间+超时时间
* @return 返回true表示加锁成功
*/
public boolean lock(String key, String value) {
// 1.如果不存在key,则存入redis。并返回true
// 如果存在该key,则返回false
if(redisTemplate.opsForValue().setIfAbsent(key, value)) {
return true;
}
String currentValue = redisTemplate.opsForValue().get(key);
// 3.如果锁过期
if(!StringUtils.isNotBlank(currentValue)
&& Long.valueOf(currentValue) < System.currentTimeMillis()) {
// 4.获取上一个锁的时间
// 将value设置到key, 并返回原来的value值
String oldValue = redisTemplate.opsForValue().getAndSet(key, value);
// 5.此处的判断处于多线程
if(!StringUtils.isNotBlank(oldValue) && oldValue.equals(currentValue)) {
return true;
}
}
return false;
}
这里重点说一下流程5。 可能有的人会疑问,为什么锁都过期了,还要再进行一次判断呢?
我们先想象一下这样的场景: 先有一个A线程来访问了,它来加了锁,并且设置了一个value值A。这时有两个线程B,C来了。它们先判断是否加锁了,
// 这个时候获取到的currentValue值就是A
String currentValue = redisTemplate.opsForValue().get(key);
我们假设现在锁已经过期了,此时来到了
String oldValue = redisTemplate.opsForValue().getAndSet(key, value);
这里虽然是B,C两个线程,但是这个方法的调用肯定是有一个先后顺序的,因为Redis是单线程的。
如果B线程先调用,那么oldValue还是A,这时C线程再调用,oldValue就不再是A了。
所以这时再去判断一下oldValue是否等于currentValue。如果等于就说明加锁成功了,不等于说明属于线程C那样,已经被B给加锁了。
解锁
解锁就很好理解了,就是将Redis中的key给清空
public void unlock(String key, String value) {
String currentValue = redisTemplate.opsForValue().get(key);
if(StringUtils.isNotBlank(currentValue)
&& currentValue.equals(value)) {
redisTemplate.opsForValue().getOperations().delete(key);
}
}
测试
所以加锁后的代码如下
public void orderProductMockDiffUser(String productId) {
// 加锁
Long value = System.currentTimeMillis() + OVERTIME;
boolean lock = redisLock.lock(productId, String.valueOf(value));
if(!lock) {
throw new SellException(100, "哎哟喂,抢购的人太多了,请重试..");
}
Integer stockNum = stock.get(productId);
if(stockNum == 0) {
throw new SellException(100, "活动结束");
} else {
orders.put(KeyUtil.genUniqueKey(), productId);
stockNum = stockNum - 1;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stock.put(productId, stockNum);
}
// 解锁
redisLock.unlock(productId, String.valueOf(value));
}
结果如下:
可以看到,这样结果就是对的了。因为锁的时间是随便设置的,所以成功下单的用户比较少