一、概述
在该系列的前面几篇博文中我们已经分析过了使用不同的技术对初始版的秒杀系统进行性能优化,同时在上一篇博文中我们使用了 JVM 缓存技术来对系统进行优化。在单机的情况下我们使用 JVM 缓存技术来对已售罄的商品进行缓存是没有问题的,可以在一定程度上优化系统的性能,并且在代码抛出异常时我们也只需进行简单的回滚(移除列表中的商品)即可。
但是当我们的系统需要应对更多的流量时,我们可能会采用集群的方式来对系统进行部署,而我们知道 JVM 级别的缓存是保存在服务器本地的,因此在一定意义上来说集群中的数据是不互通不一致的。但是,因为集群所操作的数据库和 Redis 是唯一的,因此在一定程度上也还是可以实现最终一致性的(具体原因下面分析),再但是,当我们进一步思考,当集群中的一台服务器抛出异常时,此时回滚的也仅仅是抛出异常服务器的数据,而其余的服务器却没有进行回滚操作,这个时候就可能会发生数据库剩余库存的问题。
项目源码 GitHub:https://github.com/TIYangFan/SpikeSystem(如果可以帮到你,请帮我 star ~ ^_^)
二、缓存一致性
首先我们对当前的集群系统进行分析,当集群使用 Nginx 进行负载均衡时,访问流量会被均匀的分发到每一台服务器上,假如在其中的任意一台服务器上的某一类商品的最后一件被销售后,那么根据我们之前的代码逻辑,当下一个服务器再从 Redis 中获取该商品的库存时会发现当前商品已售罄,那么它就会将该商品的售罄信息保存在 JVM 缓存当中,之后在这台服务器上再去访问该类商品时,按照前面的代码逻辑是不会再去访问 Redis 了,它会根据本地的 JVM 缓存中的商品售罄列表判断出该商品已售罄,然后直接返回。
同样的道理,当用户再通过其他的服务器来从 Redis 当中获取商品的库存时,都会发现该类商品已经售罄,并且都会将其记录在本地 JVM 缓存的售罄列表中,这样就实现了集群 JVM 缓存的最终一致性,具体的逻辑代码如下。
@Autowired
private StringRedisTemplate stringRedisTemplate;
@PostConstruct
public void initRedis() {
// 将数据库中所有商品的库存信息同步到 Redis 中
List<Product> products = productService.getAllProduct();
for (Product product: products) {
stringRedisTemplate.opsForValue().set(Constants.PRODUCT_STOCK_PREFIX + product.getId(), product.getStock() + "");
}
}
@PostMapping("/{productId}")
public String spike(@PathVariable("productId") Long productId) {
try {
// Redis 缓存中减库存
Long stock = stringRedisTemplate.opsForValue().decrement(Constants.PRODUCT_STOCK_PREFIX + productId);
// 如果 Redis 当中的库存已被减完则直接打回
if (stock < 0) {
return "fail";
}
// 数据库中减库存
orderService.spike(productId);
} catch (Exception e){
return "fail";
}
return "success";
}
三、存在问题
通过上面的论述我们已经能够确定当前的秒杀系统在集群部署的情况下能够实现 JVM 缓存的最终一致性,但是我们再继续往下思考,跟前面的逻辑代码编写的过程一样,我们需要考虑一下假如当集群中的某一台服务器代码抛出异常时会出现什么样的情况。
假设当前集群中的一台服务器抛出了代码异常,此时 Redis 和服务器本地的 JVM 缓存都完成了数据回滚操作,即当前在该服务器 JVM 缓存的售罄列表中,该商品为为售罄状态。但是,在数据处理的过程中,可能还有其它的对该商品访问请求被分发到了其余的服务器中,因为我们再开始的时候就已经完成了 Redis 中的减库存操作(代码逻辑如上),所以当我们在处理该商品将其进行数据库减库存时,操作抛出异常前,对于其余服务器访问该商品的操作都会导致该商品被加入当前服务器的售罄列表中,而这就意味着在其余的服务器上已经无法再去对 Redis 中的该商品进行访问(访问 Redis 前会被本地的 JVM 缓存中的售罄列表拦截)。
因此,假如当这台服务器抛出异常后,其余的服务器都访问了该商品,那么该商品就会被加入到其余所有服务器的售罄列表中,而这也就意味着该商品在其余的服务器上再也无法被销售,仅能通过该服务器进行销售。但如果此时该服务器发生了宕机,那就惨了,因为这就意味着唯一那个能售出该商品的服务器挂了,再也没有服务器能销售该商品了,但是此时其实数据库中仍存在未销售的该商品,这也就造成了数据的不一致。
四、解决思路
通过上面的分析我们已经发现了问题所在,就是当集群中的一台服务器发生异常后回滚后,其余服务器的状态无法得到及时的更新,造成了集群中数据不一致性,导致当这台服务器宕机后会直接的影响到整个系统。所以,我们需要做的就是实现集群中 JVM 缓存的实时同步,即当集群中任意一个服务器的数据发生变化时,其余的服务器能够立即接收到这种数据的改变,并实时的对自己服务器中的缓存数据进行修改。
因此我们可以直接使用 zookeeper ,通过利用它的实时消息通知机制来实现我们集群中的数据实时同步,具体的思路也就是当每台服务器对某一类商品进行操作时,如果我们发现该商品已经售罄,那么我们就先去 zookeeper 服务器上查找该商品对应的节点(节点存储的内容是商品是否已售罄),如果不存在该节点则创建该节点,否则直接监听该节点。然后当代码操作抛出异常进行回滚操作时,我们立即将该商品售罄的状态及时通知其他的服务器(即改变该商品对应的 zookeeper 节点的存储内容),当其他的服务器监听接收到该消息后,也立即将该商品从本地的售罄列表中移除,这样就实现了集群中所有服务器的实时同步(任意一台服务器代码抛出异常回滚本地售罄列表时,其余的服务器同时进行回滚本地售罄列表的操作)。
五、实现代码
<!-- Zookeeper -->
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.4.10</version>
</dependency>
@Configuration
public class SpringConfig {
// 服务端 zookeeper 地址
private static final String serverZookeeperAddress = "192.168.177.128:2181";
@Bean
public ZooKeeper initZookeeper() throws Exception {
// 创建观察者
ZookeeperWatcher watcher = new ZookeeperWatcher();
// 创建 Zookeeper 客户端
ZooKeeper zooKeeper = new ZooKeeper(serverZookeeperAddress, 30000, watcher);
// 将客户端注册给观察者
watcher.setZooKeeper(zooKeeper);
// 将配置好的 zookeeper 返回
return zooKeeper;
}
}
@Service
public class ZookeeperWatcher implements Watcher {
private ZooKeeper zooKeeper;
public ZooKeeper getZooKeeper() {
return zooKeeper;
}
public void setZooKeeper(ZooKeeper zooKeeper) {
this.zooKeeper = zooKeeper;
}
@Override
public void process(WatchedEvent watchedEvent) {
System.out.println("get notification.");
if (watchedEvent.getType() == Event.EventType.None && watchedEvent.getPath() == null) {
System.out.println("connect successfully.");
try {
// 创建 zookeeper 商品售罄信息根节点
String path = "/" + Constants.PRODUCT_STOCK_PREFIX;
if (zooKeeper != null && zooKeeper.exists(path, false) == null) {
zooKeeper.create(path, "".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
} catch (KeeperException | InterruptedException e) {
e.printStackTrace();
}
} else if (watchedEvent.getType() == Event.EventType.NodeDataChanged) {
try {
// 获取节点路径
String path = watchedEvent.getPath();
// 获取节点数据
String soldOut = new String(zooKeeper.getData(path, true, new Stat()));
// 处理当前服务器对应 JVM 缓存
if ("false".equals(soldOut)) {
// 获取商品 Id
String productId = path.substring(path.lastIndexOf("/") + 1, path.length());
System.out.println("productId:" + productId);
// 同步当前 JVM 缓存
if (SpikeController.getProductSoldOutMap().contains(productId)) {
SpikeController.getProductSoldOutMap().remove(productId);
}
}
} catch (KeeperException | InterruptedException e) {
e.printStackTrace();
}
}
}
}
@RestController
@RequestMapping("/spike")
public class SpikeController {
@Autowired
private OrderService orderService;
@Autowired
private ProductService productService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 售罄商品列表
private static ConcurrentHashMap<Long, Boolean> productSoldOutMap = new ConcurrentHashMap<>();
@Autowired
private ZooKeeper zooKeeper;
@PostConstruct
public void initRedis() {
// 将数据库中所有商品的库存信息同步到 Redis 中
List<Product> products = productService.getAllProduct();
for (Product product: products) {
stringRedisTemplate.opsForValue().set(Constants.PRODUCT_STOCK_PREFIX + product.getId(), product.getStock() + "");
}
}
@PostMapping("/{productId}")
public String spike(@PathVariable("productId") Long productId) throws KeeperException, InterruptedException {
if (productSoldOutMap.get(productId) != null){
return "fail";
}
try {
Long stock = stringRedisTemplate.opsForValue().decrement(Constants.PRODUCT_STOCK_PREFIX + productId);
if (stock < 0) {
// 商品销售完后将其加入到售罄列表记录中
productSoldOutMap.put(productId, true);
// 保证 Redis 当中的商品库存恒为非负数
stringRedisTemplate.opsForValue().increment("/" + Constants.PRODUCT_STOCK_PREFIX + productId);
// zookeeper 中设置售完标记, zookeeper 节点数据格式 product/1 true
String productPath = "/" + Constants.PRODUCT_STOCK_PREFIX + "/" + productId;
if (zooKeeper.exists(productPath, true) == null) {
zooKeeper.create(productPath, "true".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
// 监听 zookeeper 售完节点
zooKeeper.exists(productPath, true);
return "fail";
}
// 数据库中减库存
orderService.spike(productId);
// 数据库异步减库存
// producer.spike(productId);
} catch (Exception e){
// 数据库减库存失败回滚已售罄列表记录
if (productSoldOutMap.get(productId) != null) {
productSoldOutMap.remove(productId);
}
// 通过 zookeeper 回滚其他服务器的 JVM 缓存中的商品售完标记
String path = "/" + Constants.PRODUCT_STOCK_PREFIX + "/" + productId;
if (zooKeeper.exists(path, true) != null){
zooKeeper.setData(path, "false".getBytes(), -1);
}
// 回滚 Redis 中的库存
stringRedisTemplate.opsForValue().increment(Constants.PRODUCT_STOCK_PREFIX + productId);
return "fail";
}
return "success";
}
public static ConcurrentHashMap<Long, Boolean> getProductSoldOutMap() {
return productSoldOutMap;
}
}