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

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 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月前
|
缓存 NoSQL 关系型数据库
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
|
3月前
|
消息中间件 缓存 弹性计算
纯PHP+MySQL手搓高性能论坛系统!代码精简,拒绝臃肿
本内容分享了一套经实战验证的社交系统架构设计,支撑从1到100万用户的发展,并历经6次流量洪峰考验。架构涵盖客户端层(App、小程序、公众号)、接入层(API网关、负载均衡、CDN)、业务服务层(用户、内容、关系、消息等服务)、数据层(MySQL、Redis、MongoDB等)及运维监控层(日志、监控、告警)。核心设计包括数据库分库分表、多级缓存体系、消息队列削峰填谷、CQRS模式与热点数据动态缓存。同时提供应对流量洪峰的弹性伸缩方案及降级熔断机制,并通过Prometheus实现全链路监控。开源建议结构清晰,适合大型社交平台构建与优化。
165 11
|
25天前
|
关系型数据库 MySQL 分布式数据库
Super MySQL|揭秘PolarDB全异步执行架构,高并发场景性能利器
阿里云瑶池旗下的云原生数据库PolarDB MySQL版设计了基于协程的全异步执行架构,实现鉴权、事务提交、锁等待等核心逻辑的异步化执行,这是业界首个真正意义上实现全异步执行架构的MySQL数据库产品,显著提升了PolarDB MySQL的高并发处理能力,其中通用写入性能提升超过70%,长尾延迟降低60%以上。
|
24天前
|
缓存 NoSQL 算法
高并发秒杀系统实战(Redis+Lua分布式锁防超卖与库存扣减优化)
秒杀系统面临瞬时高并发、资源竞争和数据一致性挑战。传统方案如数据库锁或应用层锁存在性能瓶颈或分布式问题,而基于Redis的分布式锁与Lua脚本原子操作成为高效解决方案。通过Redis的`SETNX`实现分布式锁,结合Lua脚本完成库存扣减,确保操作原子性并大幅提升性能(QPS从120提升至8,200)。此外,分段库存策略、多级限流及服务降级机制进一步优化系统稳定性。最佳实践包括分层防控、黄金扣减法则与容灾设计,强调根据业务特性灵活组合技术手段以应对高并发场景。
376 7
|
2月前
|
开发框架 Java 关系型数据库
在Linux系统中安装JDK、Tomcat、MySQL以及部署J2EE后端接口
校验时,浏览器输入:http://[your_server_IP]:8080/myapp。如果你看到你的应用的欢迎页面,恭喜你,一切都已就绪。
281 17
|
2月前
|
运维 关系型数据库 MySQL
使用RDS MySQL 极速构建实时全文检索系统,完成任务可领取300社区积分兑换各种商城好礼!
实时全文检索系统是企业竞争力的关键工具,但自建面临诸多挑战。本方案利用阿里云RDS MySQL版与Elasticsearch简化构建,优化数据索引与查询性能,助力企业高效数字化转型。
|
3月前
|
关系型数据库 MySQL Linux
CentOS 7系统下详细安装MySQL 5.7的步骤:包括密码配置、字符集配置、远程连接配置
以上就是在CentOS 7系统下安装MySQL 5.7的详细步骤。希望这个指南能帮助你顺利完成安装。
912 26
|
3月前
|
Ubuntu 关系型数据库 MySQL
在Ubuntu系统的Docker上安装MySQL的方法
以上的步骤就是在Ubuntu系统的Docker上安装MySQL的详细方法,希望对你有所帮助!
361 12
|
4月前
|
消息中间件 NoSQL 关系型数据库
去哪面试:1Wtps高并发,MySQL 热点行 问题, 怎么解决?
去哪面试:1Wtps高并发,MySQL 热点行 问题, 怎么解决?
去哪面试:1Wtps高并发,MySQL 热点行 问题, 怎么解决?
|
4月前
|
缓存 NoSQL 关系型数据库
Redis和Mysql如何保证数据⼀致?
1. 先更新Mysql,再更新Redis,如果更新Redis失败,可能仍然不⼀致 2. 先删除Redis缓存数据,再更新Mysql,再次查询的时候在将数据添加到缓存中 这种⽅案能解决1 ⽅案的问题,但是在⾼并发下性能较低,⽽且仍然会出现数据不⼀致的问题,⽐如线程1删除了 Redis缓存数据,正在更新Mysql,此时另外⼀个查询再查询,那么就会把Mysql中⽼数据⼜查到 Redis中 1. 使用MQ异步同步, 保证数据的最终一致性 我们项目中会根据业务情况 , 使用不同的方案来解决Redis和Mysql的一致性问题 : 1. 对于一些一致性要求不高的场景 , 不做处理例如 : 用户行为数据 ,

热门文章

最新文章

推荐镜像

更多