基于Redis+Zookeeper+MySQL实现高并发秒杀系统(一)

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 基于Redis+Zookeeper+MySQL实现高并发秒杀系统

为什么使用Redis : MySQL并发操作,单机最多支撑1000个,了不起了。无论是从性能还是安全来说,Redis的集成都大大解决了系统的并发问题。利用Redis的原子性操作。
为什么使用Zookeeper : 虽然Redis性能非常之高,但是少不了就是应用服务于Redis之间的通信,每一次的通信至少是需要时间的。所以我们应该在应用程序增加本地缓存,但是本地缓存会存在一个问题,在分布式部署下,多台服务器的多个应用程序,缓存不一致,一样会导致秒杀系统Bug(后续会做出介绍)。

单MySQL版本:
一般来说,如果并发性没那么高的话,我们通过以下语句也能做到安全。利用MySQL InnoDB的行级锁。更改库存的时候,使用以下SQL进行更新库存,就可以了。没什么大的问题。但性能非常低。所有的压力全部堆到数据库上。

update set stock = stock - 1 where stock > 0 and id = #{id}

Redis+MySQL:
用Redis做第一层拦截,防止很多无效的请求操作数据库。减少MySQL的压力。记住,Redis能支撑的并发数比MySQL多的多了。
使用Redis的SetNx,自增,自减,Redisson,Lua脚本(我们这里使用自增自减法)。
请求---Redis---MySQL

@PostMapping("/secKill/{productId}")
    public ResultRtn secKill(@PathVariable("productId") Long productId){
        //商品库存的Redis的Key
        String redisKey = "product:"+productId+":stock";
        //Redis库存减一
       Long count =  redisTemplate.opsForValue().decrement(redisKey);
       if(count<0){
           redisTemplate.opsForValue().increment(redisKey);
           return ResultRtn.error("此商品已经售完");
       }
        try{
            productService.updateStock(productId);
        }catch (Exception e){
            //出现异常Redis减的1,在加回来
            redisTemplate.opsForValue().increment(redisKey);
        }
        return ResultRtn.ok("抢购成功");
    }
贴心小课堂:
    大家看代码,可以看到,我们在catch里面加了redis的缓存增加操作。
  这里是为了避免,redis缓存减了之后,后续代码如果出现异常,其实库存应该是不减的。所以在异常的时候,我们需要把缓存减掉的库存在加回来。

JVM缓存+Redis+MySQL:
虽然Redis性能非常之高,但是少不了就是应用服务于Redis之间的通信,每一次的通信至少是需要时间的。并且在我们实际的秒杀场景当中,其实我们的很多请求都算是无效的。
比方说我们某个商品库存只有100个,现在有1000个用户来抢,每人只能抢一个。那其实900个用户都是抢不到的。那么这900个用户的请求其实也没有必要再去与Redis进行请求。
所以我们应该在应用程序增加本地缓存。所谓的本地缓存就是我们的JVM缓存。我们可以在ConcurrentHashMap中存储,标示着我们的商品是否还有库存。如果没有库存,直接返回请求结果“已经被抢完”。
请求---JVM缓存---Redis---数据库。
代码如下:

private static Map<String,Boolean> jvmCache = new ConcurrentHashMap<>();
@PostMapping("/secKill/{productId}/{userId}")
    public ResultRtn secKill(@PathVariable("productId") Long productId,@PathVariable("userId") Long userId){
        //JVM的Key
        String jvmKey = "product"+productId;
        //判断JVM缓存
        if(params.containsKey(jvmKey)){
            return ResultRtn.error("此商品已经售完");
        }
        //商品库存的Redis的Key
        String redisKey = "product:"+productId+":stock";
        //Redis库存减一
       Long count =  redisTemplate.opsForValue().decrement(redisKey);
       if(count<0){
           redisTemplate.opsForValue().increment(redisKey);
           params.put("product"+productId,true);
           return ResultRtn.error("此商品已经售完");
       }
        try{
            productService.updateStock(productId);
        }catch (Exception e){
            params.remove(jvmKey);
            //出现异常Redis减的1,在加回来
            redisTemplate.opsForValue().increment(redisKey);
        }
        return ResultRtn.ok("抢购成功");
    }
贴心小课堂:
    这里也是一样,在catch里面。把两个缓存(JVM与Redis)减去的库存,在加回去。

以上代码就是利用JVM与Redis双重缓存实现秒杀的典型案例。在单服务器部署下,以上代码可以说很安全,几乎应该没有什么大问题了。但是如果我们的应用是在分布式部署的情况下。那JVM缓存多台机器不一致。这个问题就非常严重。场景如下
比方说,现在有个商品iphone12,库存仅剩最后一个。A线程在A机器执行到代码14行的时候,此时B线程在B机器请求进来,发现库存不足,会将B机器的JVM缓存,商品的Key对应的Value设置为true。接着A机器在执行到代码20行的时候,报错了。那么Redis减的库存应该要加回去,也就说库存还是剩一个,但是B机器的JVM缓存已经标识这个商品被抢光了。那么倘若后面所有的请求或者A机器宕机了,所有的请求到B机器,导致所有人全部抢购失败。到最后库存并没有全部卖出去。出现了少卖现象。
大致执行流程顺序是这样的:
1 : A请求进入A机器,抢到了最后一个商品,Redis库存扣减为0
2 : 此时此刻A请求还没有完全结束的时候,B请求进来,发现库存不足。则B机器的JVM缓存该商品标识为True了。
3 : 此时A请求在A机器因为某些原因,抛出异常。则A请求刚刚在Redis减掉的库存要加回去。因为抛出异常了,意味着该商品没有抢成功。
4 : 但B机器的JVM缓存已经标识该商品被抢完了。那么如果后续所有的请求全部到B机器,是不是所有的请求都抢不到这个商品了。或者我们说A机器宕机了,所有请求全部到了B机器,所有抢购这个商品的全部都失败。那么这就造成了少卖现象。

解决上述问题,怎么解决呢?就涉及到我们的JVM缓存之间的同步问题。就是当A机器的JVM缓存变动了,B机器或者分布式下的其他机器对应这个缓存,也应该同步刷新。

我们可以通过分布式协调框架Zookeeper来解决这个问题。

image.png
前篇代码及问题描述:

private static Map<String,Boolean> params = new ConcurrentHashMap<>();
@PostMapping("/secKill/{productId}/{userId}")
    public ResultRtn secKill(@PathVariable("productId") Long productId,@PathVariable("userId") Long userId){
        //JVM的Key
        String jvmKey = "product"+productId;
         try{
        //判断JVM缓存
        if(params.containsKey(jvmKey)){
            return ResultRtn.error("此商品已经售完");
        }
        //商品库存的Redis的Key
        String redisKey = "product:"+productId+":stock";
        //Redis库存减一
       Long count =  redisTemplate.opsForValue().decrement(redisKey);
       if(count<0){
           redisTemplate.opsForValue().increment(redisKey);
           params.put("product"+productId,true);
           return ResultRtn.error("此商品已经售完");
       }
       
            productService.updateStock(productId);
        }catch (Exception e){
            params.remove(jvmKey);
            //出现异常Redis减的1,在加回来
            redisTemplate.opsForValue().increment(redisKey);
        }
        return ResultRtn.ok("抢购成功");
    }
贴心小课堂:
    这里也是一样,在catch里面。把两个缓存(JVM与Redis)减去的库存,在加回去。

以上代码就是利用JVM与Redis双重缓存实现秒杀的典型案例。在单服务器部署下,以上代码可以说很安全,几乎应该没有什么大问题了。但是如果我们的应用是在分布式部署的情况下。那JVM缓存多台机器不一致。这个问题就非常严重。场景如下
比方说,现在有个商品iphone12,库存仅剩最后一个。A线程在A机器执行到代码14行的时候,此时B线程在B机器请求进来,发现库存不足,会将B机器的JVM缓存,商品的Key对应的Value设置为true。接着A机器在执行到代码20行的时候,报错了。那么Redis减的库存应该要加回去,也就说库存还是剩一个,但是B机器的JVM缓存已经标识这个商品被抢光了。那么倘若后面所有的请求或者A机器宕机了,所有的请求到B机器,导致所有人全部抢购失败。到最后库存并没有全部卖出去。出现了少卖现象。
大致执行流程顺序是这样的:
1 : A请求进入A机器,抢到了最后一个商品,Redis库存扣减为0
2 :   此时此刻A请求还没有完全结束的时候,B请求进来,发现库存不足。则B机器的JVM缓存该商品标识为True了。
3 : 此时A请求在A机器因为某些原因,抛出异常。则A请求刚刚在Redis减掉的库存要加回去。因为抛出异常了,意味着该商品没有抢成功。
4 : 但B机器的JVM缓存已经标识该商品被抢完了。那么如果后续所有的请求全部到B机器,是不是所有的请求都抢不到这个商品了。或者我们说A机器宕机了,所有请求全部到了B机器,所有抢购这个商品的全部都失败。那么这就造成了少卖现象。

解决上述问题,怎么解决呢?就涉及到我们的JVM缓存之间的同步问题。就是当A机器的JVM缓存变动了,B机器或者分布式下的其他机器对应这个缓存,也应该同步刷新。

使用分布式协调框架(Zookeeper)解决JVM缓存不一致的问题:
解决思路如下:

当B机器请求商品为已售完的时候,会将B机器的此商品JVM缓存标识别True,并利用Zookeeper监听此商品节点。
那么在A机器发生异常需要回滚的时候,会利用Zookeeper通知B机器,B机器需要删除此商品的JVM缓存。
源码如下:

配置Zookeeper信息

@Configuration
public class ZookeeperConfig {


    /**
     * zookeeper IP
     */
    @Value("${zookeeper.ip}")
    private String ip;

    /**
     * Zookeeper 端口
     */
    @Value("${zookeeper.host}")
    private String host;

    /**
     * 当前应用的服务端口,用于打印测试用
     */
    @Value("${server.port}")
    private String serverPort;


    @Bean
    public ZooKeeper initZookeeper() throws Exception {
        // 创建观察者
        ZookeeperWatcher watcher = new ZookeeperWatcher(serverPort);
        // 创建 Zookeeper 客户端
        ZooKeeper zooKeeper = new ZooKeeper(ip+":"+host, 30000, watcher);
        // 将客户端注册给观察者
        watcher.setZooKeeper(zooKeeper);
        // 将配置好的 zookeeper 返回
        return zooKeeper;
    }


}

配置Zookeeper监听:

@Service
@Slf4j
public class ZookeeperWatcher  implements Watcher {
    private ZooKeeper zooKeeper;
    public ZooKeeper getZooKeeper() {
        return zooKeeper;
    }
    public void setZooKeeper(ZooKeeper zooKeeper) {
        this.zooKeeper = zooKeeper;
    }
    private String serverPort;
    public String getServerPort(){
        return this.serverPort;
    }
    public ZookeeperWatcher(){

    }
    public ZookeeperWatcher (String serverPort){
        this.serverPort = serverPort;
    }
    @Override
    public void process(WatchedEvent watchedEvent) {

        System.out.println("************************zookeeper***start*****************");

        if (watchedEvent.getType() == Event.EventType.None && watchedEvent.getPath() == null) {
            log.info("项目启动,初始化zookeeper节点"+getServerPort());
            try {
                // 创建 zookeeper 商品售完信息根节点
                String path = Constants.zoo_product_key_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()));
                // 获取商品 Id
                String productId = path.substring(path.lastIndexOf("/") + 1);
                // 处理当前服务器对应 JVM 缓存
                 if("false".equals(soldOut)){
                    log.info("端口:"+getServerPort()+",zookeeper节点:"+path+"标识为false【商品并未售完】");
                    if(RedisZookeeperController.getParams().containsKey(Constants.local_product_key_prefix+productId)){
                        RedisZookeeperController.getParams().remove(Constants.local_product_key_prefix+productId);
                    }
                }
            } catch (KeeperException | InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

}

秒杀接口修改

 private static ConcurrentHashMap<String,Boolean> params = new ConcurrentHashMap<>();

    @PostMapping("/secKill/{productId}/{userId}")
    public ResultRtn secKill(@PathVariable("productId") Long productId,
                             @PathVariable("userId") Long userId,
                             @RequestParam("isAndEx") String isAndEx
    ) throws KeeperException, InterruptedException {
        //JVM的Key
        String jvmKey = Constants.local_product_key_prefix+productId;
        //判断JVM缓存
        if(params.containsKey(jvmKey)){
            log.info("本地缓存已售完");
            return ResultRtn.ok("此商品已经售完");
        }
        //商品库存的Redis的Key
        String redisKey = Constants.redis_product_key_prefix+productId;
        //Redis库存减一
        Long count =  redisTemplate.opsForValue().decrement(redisKey);
        try{
        /**
         * 【isAndEx】纯属为了测试模拟并发使用,让我们能够看到zookeeper给我们带来的效果
         * 场景如下:
         * 某商品库存为1  启动两个应用  应用1端口:9001   应用2端口:9002
         * A线程在应用1,【isAndEx】传值为1,则A线程在会在此处卡顿10秒,此时Redis库存已为0,10秒倒计时开始
         *【A线程还在卡顿】- B线程在应用2,【isAndEx】传值为为0,则B线程,查询Redis库存为0,返回商品已售完,且应用2的JVM缓存【params】已设置为true
         * A线程在应用1-模拟的10秒结束,抛出异常,减1的库存要加回来。库存变为1,
         * 当因为应用1发生异常,将库存+1的时候,如果没有zookeeper将应用2的JVM缓存【params】删掉,那么后面所有的请求进入到B机器,则全部不会抢到商品。
         * 至此,则会出现少卖现象。
         */
        if("1".equals(isAndEx)){
            Thread.sleep(10000);
            throw new Exception("模拟异常,已延迟5秒");
        }
        if(count<0){
            redisTemplate.opsForValue().increment(redisKey);
            params.put(Constants.local_product_key_prefix+productId,true);
            // zookeeper 中设置售完标记, zookeeper 节点数据格式 product/1 true
            String productPath = Constants.zoo_product_key_prefix + "/" + productId;
            if(zooKeeper.exists(productPath, true)==null){
                zooKeeper.create(productPath, "true".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
            return ResultRtn.ok("此商品已经售完");
        }
            productService.updateStock(productId);
        }catch (Exception e){
            // 通过 zookeeper 回滚其他服务器的 JVM 缓存中的商品售完标记
            String path = Constants.zoo_product_key_prefix + "/" + productId;
            if (zooKeeper.exists(path, true) != null){
                zooKeeper.setData(path, "false".getBytes(), -1);
            }
            params.remove(jvmKey);
            //出现异常Redis减的1,在加回来
            redisTemplate.opsForValue().increment(redisKey);
            return ResultRtn.error("网络拥挤,请稍后重试");
        }
        return ResultRtn.ok("抢购成功");
    }


    public static ConcurrentHashMap<String,Boolean> getParams(){
        return params;
    }

测试过程:
为了测试,我们启动两个应用,应用 1(机器A)端口9001,应用2(机器B)端口9002。
为了测试出并发问题,我们在秒杀接口里面增加一个参数,来让9001线程暂停10秒钟。那么9002不进行卡顿。这样我们先调用9001,让库存减1且线程睡眠10秒。那么此时我们访问9002,应该是商品已售完。在9001接口返回结果前,反复调用9002,控制台应打印本地缓存已售完,接口返回此商品已售完,这就说明了9002的JVM缓存已经设置为True。等到9001接口因为10秒后抛出异常,Redis库存应在加回去,那么需要通知到9002,告诉9002,我这个商品没有抢成功,库存加回去了。你(9002)的缓存就不能是已售完的情况。否则如果后续所有请求到达9002,全部抢不成功,但实际上是有库存啊。此时我们在调用9002的秒杀接口,就应该是抢购成功。这样我们Zookeeper的目的就达到了。

利用Idea启动两个应用,复制一个原来的,将端口设置为9002

设置数据库商品库存为1

image.png

调用接口,将数据库库存同步到Redis

image.png

调用9001秒杀接口, isAndEx传1,Redis库存减之后,将卡顿10秒,给足其他线程秒杀的时间

http://127.0.0.1:9001/redisZookeeper/secKill/1337041728065032193/1?isAndEx=1

9001接口卡顿期间,去调用9002秒杀接口, isAndEx传0

http://127.0.0.1:9001/redisZookeeper/secKill/1337041728065032193/1?isAndEx=0

接口返回:

{
    "code": 0,
    "msg": "此商品已经售完",
    "data": "此商品已经售完"
}

等待9001接口返回结果之后,在去调用9002秒杀接口, isAndEx传0,则抢购成功

http://127.0.0.1:9001/redisZookeeper/secKill/1337041728065032193/1?isAndEx=0

接口返回:

{
    "code": 0,
    "msg": "抢购成功",
    "data": "抢购成功"
}
源码地址: https://gitee.com/stevenlisw/study-project.git
相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
2月前
|
安全 关系型数据库 MySQL
如何将数据从MySQL同步到其他系统
【10月更文挑战第17天】如何将数据从MySQL同步到其他系统
296 0
|
1月前
|
NoSQL 关系型数据库 MySQL
2024Mysql And Redis基础与进阶操作系列(4-2)作者——LJS[含MySQL非空、唯一性、PRIMARY KEY、自增列/自增约束举例说明等详解步骤及常见报错问题对应的解决方法]
24MySQL非空、唯一性、PRIMARY KEY、自增列/自增约束举例说明等详解步骤及常见报错问题对应的解决方法(4-2) 学不会你来砍我!!!
|
16天前
|
NoSQL Java 关系型数据库
Liunx部署java项目Tomcat、Redis、Mysql教程
本文详细介绍了如何在 Linux 服务器上安装和配置 Tomcat、MySQL 和 Redis,并部署 Java 项目。通过这些步骤,您可以搭建一个高效稳定的 Java 应用运行环境。希望本文能为您在实际操作中提供有价值的参考。
87 26
|
2天前
|
NoSQL 关系型数据库 MySQL
Linux安装jdk、mysql、redis
Linux安装jdk、mysql、redis
44 7
|
1月前
|
缓存 NoSQL 关系型数据库
Redis和Mysql如何保证数据⼀致?
在项目中,为了解决Redis与Mysql的数据一致性问题,我们采用了多种策略:对于低一致性要求的数据,不做特别处理;时效性数据通过设置缓存过期时间来减少不一致风险;高一致性但时效性要求不高的数据,利用MQ异步同步确保最终一致性;而对一致性和时效性都有高要求的数据,则采用分布式事务(如Seata TCC模式)来保障。
66 14
|
1月前
|
存储 NoSQL 关系型数据库
MySQL和Redis的区别
**MySQL和Redis的区别** MySQL和Redis都是流行的数据存储解决方案,但它们在设计、用途和特性上有显著区别。理解这些区别有助于选择合适的数据库来满足不同的应用需求。本文将详细介绍MySQL和Redis的区别,包括它们的架构、使用场景、性能和其他关键特性。 ### 一、基本概述 **MySQL**: MySQL是一个关系型数据库管理系统(RDBMS),使用结构化查询语言(SQL)进行数据管理。它支持事务、复杂查询和多种存储引擎,广泛应用于各种Web应用、企业系统和数据分析项目。 **Redis**: Redis是一个基于内存的键值数据库,通常被称为NoSQL数
83 4
|
1月前
|
NoSQL 安全 关系型数据库
2024Mysql And Redis基础与进阶操作系列(6)作者——LJS[含MySQL 多表之一对一/多;多对多;多表联合查询等详解步骤及常见报错问题所对应的解决方法]
MySQL 多表之一对一/多;多对多;多表联合之交叉连接;内连接;左、右、外、满、连接;子查询及关键字;自连接查询等详解步骤及常见报错问题所对应的解决方法
|
1月前
|
SQL NoSQL 关系型数据库
2024Mysql And Redis基础与进阶操作系列(5)作者——LJS[含MySQL DQL基本查询:select;简单、排序、分组、聚合、分组、分页等详解步骤及常见报错问题所对应的解决方法]
MySQL DQL基本查询:select;简单、排序、分组、聚合、分组、分页、INSERT INTO SELECT / FROM查询结合精例等详解步骤及常见报错问题所对应的解决方法
|
2月前
|
NoSQL 关系型数据库 MySQL
MySQL与Redis协同作战:百万级数据统计优化实践
【10月更文挑战第21天】 在处理大规模数据集时,传统的单体数据库解决方案往往力不从心。MySQL和Redis的组合提供了一种高效的解决方案,通过将数据库操作与高速缓存相结合,可以显著提升数据处理的性能。本文将分享一次实际的优化案例,探讨如何利用MySQL和Redis共同实现百万级数据统计的优化。
114 9
|
1月前
|
SQL NoSQL 关系型数据库