模拟高并发场景,并解决实际问题

目录

下面开始——高并发解决方案:

一、缓存——数据最终一致性

1.加锁

分布式锁的实现方式

①数据库悲观锁

②数据库乐观锁 √

③redis锁实现 √

        ③—①redis的setnx实现

        ③—②redisson实现

④关于加唯一索引的想法

⑤synchronized关键字,和lock锁(只针对单体,分布式系统不行)

2.异步写入缓存

3.延迟双删

二、消息队列


前言:

①本帖先选用JMeter作为测试并发的工具,讲解JMeter的并发配置,模拟请求成功

②开始抢票场景的模拟,编写数据库表、业务代码、模拟并发成功

③开始解决抢票过程中遇到的并发问题,并使其并发只有一个用户能够抢到票

④开始多种方案尝试

JMeter使用方法见下方,写的很详细:使用JMeter进行并发测试接口_susu1083018911的博客-CSDN博客_jmeter并发测试1.下载JMeterApache JMeter - Download Apache JMeter我是windows系统,选择了zip下载。2.解压下载好的zip3.打开bin文件夹找到jmeter.properties配置文件,修改(大概在39行的位置)language=zh_CN4.在bin目录下,找到jmeter.bat,双击启动5.新建测试计划,选中“测试计划”,鼠标右击,选择添加->线程(用户)->线程组6.光标选中线程组,..https://blog.csdn.net/susu1083018911/article/details/123529036

如图配置:

如上图检测到的结果能看到,并发的五个请求都成功请求到了。配置完毕。

下面我们开始模拟场景:

这里以抢票为例吧,一张票只能被一个人抢到。先设计表结构,编写业务。

票种和剩余票数关联关系表:

抢票记录表:

 编写业务:

package com.hyj.services;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.hyj.domain.tb.TicketNum;
import com.hyj.domain.tb.TicketPurchaseLog;
import com.hyj.interfaces.ITicketNumService;
import com.hyj.mapper.TicketNumMapper;
import com.hyj.mapper.TicketPurchaseLogMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Date;

@Service
@Slf4j
public class TicketNumServiceImpl implements ITicketNumService {
    @Autowired
    private TicketNumMapper numMapper;
    @Autowired
    private TicketPurchaseLogMapper purchaseLogMapper;

    @Override
    public Boolean buyTicket(Integer userId) {
        //假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
        //抢票前,需判断此票种是否还能抢票。
        //在这里,模拟都抢ticked_id=1的票
        try {
            QueryWrapper queryWrapper = Wrappers.query();
            queryWrapper.eq("ticked_id", 1);

            TicketNum numInfo = numMapper.selectById(1);

            if (numInfo == null || numInfo.getNum() < 1) {
                log.warn("此票已被抢");
                return false;
            }

            //减票数
            numInfo.setNum(numInfo.getNum() - 1);
            numMapper.updateById(numInfo);
            //插入日志
            TicketPurchaseLog model = new TicketPurchaseLog();
            model.setTickedId(1);
            model.setUserId(userId);
            model.setCreateTime(new Date());
            purchaseLogMapper.insert(model);
        } catch (Exception e) {
            log.error("抢票信息异常:{}", e.getStackTrace());
            return false;
        }
        return true;
    }


}

接口调试执行成功,插入一条数据并更新库存,从6更改为5。

 清空表后,下面开始并发测试:

妈滴……只插入了一条数据,好像并没有并发问题????(当时是业务设计不完整,是单表操作。后面更改为两表操作后,不睡也会有问题) 有并发问题的话,应该插入5条。呵呵哒,傻了,是因为执行太快,并没有并发问题,所以再次假设,红框内执行较慢,让它睡5秒

 再次并发测试,5个请求都返回true,且插入5条数据,成功模拟并发场景。

插入五条抢票记录,而库存却只减了一个。

这是错误日志:

 所以,得出啥结论造吗……只要够快,就没有问题。。。所以呢,消息队列就可以引入了(如果业务场景允许)


下面开始——高并发解决方案

①系统拆分

②缓存

③消息队列

④分库分表

⑤读写分离

⑥solrCloud

这里我们说②③④

一、缓存——数据最终一致性

高并发场景,利用缓存内存储的数据实现效率的提高,减轻数据库压力(以空间换时间),但只要

有两份数据存在,数据一致性问题就不可避免。

有几下几种解决方式:

1.加锁

当更新数据库的时候,同步更新缓存。

优点:数据一致性强,不会出现缓存雪崩的问题。

缺点:代码耦合度高,影响正常业务,增加一次网络开销。

适用环境:适用于数据一致性要求高的场景,比如银行业务,证券交易

上图我们就是用第一种方式实现的业务,耦合度很高,但是没有一致性问题。加锁的确会解决数据

不一致的问题,但是也有缺点:串行执行,效率极低。

分布式锁的实现方式

①数据库:乐观锁、悲观锁

②基于redis的实现

③基于zookeeper

分布式锁的那几种实现方式都搞上:悲观锁、乐观锁、缓存分布式锁、关键字锁之类的。能用乐观

锁就不要用悲观锁,悲观锁要等待,容易阻塞,体验很差劲。

下面开始实操:

场景:只剩一张车票,禁止卖超

①数据库悲观锁

它悲观地认为,所有操作都冲突

/**
     * 悲观锁方式解决并发
     * @param userId
     * @return
     */
    @Override
    @Transactional
    public Boolean bgBuyTicket(Integer userId) {
        //假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
        //抢票前,需判断此票种是否还能抢票。
        //在这里,模拟都抢ticked_id=1的票
        try {
            //悲观锁锁住数据
            // SELECT id,num,version,update_time from ticket_num where id = 1 for update
            TicketNum numInfo = numMapper.bgSql();
            if (numInfo == null || numInfo.getNum() < 1) {
                log.warn("此票已被抢");
                return false;
            }

//            Thread.sleep(5000);
            //减票数
            numInfo.setNum(numInfo.getNum() - 1);
            numMapper.updateById(numInfo);
            //插入日志
            TicketPurchaseLog model = new TicketPurchaseLog();
            model.setTickedId(1);
            model.setUserId(userId);
            model.setCreateTime(new Date());
            purchaseLogMapper.insert(model);
        } catch (Exception e) {
            log.error("抢票信息异常:{}", e.getStackTrace());
            return false;
        }
        return true;
    }

结果:

下图为并发10次,只有6张票的情况:所以解决方案正确

这种是悲观锁的方式,解决高并发问题,注意一定要加@transactional事务注解,不加的话,会同

时插入5条,控制不住并发。but有but,这种方式能不用就不用。贼不好,要一直等待,所以又产

生了一个新的问题:死锁。华丽丽登场,这里我们先不说了,篇幅有限(假装我会但我不说)

注意:单纯加注释而不锁数据for update。并没有卵用。

上图是其中的一种解决方式,本质是mysql的悲观锁+事务。最后commit

②数据库乐观锁

乐观得认为,数据修改产生冲突的概率并不大,适合读多写少。

乐观锁的原理是:两个字段控制唯一一条数据。当在查询数据的时候并不知道成功与否,修改数据

的时候才知道

/**
     * 乐观锁方式解决并发
     *
     * @param userId
     * @return
     */
    @Override
    @Transactional
    public Boolean lgBuyTicket(Integer userId) {
        //假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
        //抢票前,需判断此票种是否还能抢票。
        //在这里,模拟都抢ticked_id=1的票
        try {
            //查出数据
            QueryWrapper queryWrapper = Wrappers.query();
            queryWrapper.eq("ticked_id", 1);
            TicketNum numInfo = numMapper.selectById(1);
            if (numInfo == null || numInfo.getNum() < 1) {
                log.warn("此票已被抢");
                return false;
            }
            //乐观锁,减票数
            // UPDATE ticket_num set num = num-1,version = #{oldVersion}+1,update_time = now() where id = 1 and version = #{oldVersion}
            Integer result = numMapper.lgSql(numInfo.getVersion());
            if (result > 0) {
                //插入日志
                TicketPurchaseLog model = new TicketPurchaseLog();
                model.setTickedId(1);
                model.setUserId(userId);
                model.setCreateTime(new Date());
                purchaseLogMapper.insert(model);
            }
        } catch (Exception e) {
            log.error("抢票信息异常:{}", e.getStackTrace());
            return false;
        }
        return true;
    }

这里模拟了只剩两张票,结果:

经尝试,不加事务注解对并发没什么影响,但是!还是加上吧,同时要注意拿到结果后才能继续下

一步

下图为并发10次,只有6张票的情况:所以解决方案正确

③redis锁实现 √

/**
     * 缓存方式,解决并发问题
     *
     * @param userId
     * @return
     */
    @Override
    @Transactional
    public Boolean finalBuyTicket(Integer userId) {
        //假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
        //抢票前,需判断此票种是否还能抢票。
        //在这里,模拟都抢ticked_id=1的票
        try {
            Integer num = 0;
            if (redisUtil.exists("ticked_id_1")) {
                num = (Integer) redisUtil.get("ticked_id_1");
            } else {
                QueryWrapper queryWrapper = Wrappers.query();
                queryWrapper.eq("ticked_id", 1);
                TicketNum numInfo = numMapper.selectById(1);
                num = numInfo.getNum();
                redisUtil.set("ticked_id_1", num);
            }

            if (num < 1) {
                log.warn("此票已被抢,userId={}的没抢到", userId);
                return false;
            }
            log.info("目前一共{}张票,userId={}抢到一张", num, userId);
            //减票数
            redisUtil.set("ticked_id_1", num - 1);
            TicketNum numModel = new TicketNum();
            numModel.setId(1);
            numModel.setNum(num - 1);
            numModel.setUpdateTime(new Date());
            numMapper.updateById(numModel);
            //插入日志
            TicketPurchaseLog model = new TicketPurchaseLog();
            model.setTickedId(1);
            model.setUserId(userId);
            model.setCreateTime(new Date());
            purchaseLogMapper.insert(model);
        } catch (Exception e) {
            log.error("抢票信息异常:{}", e.getStackTrace());
            return false;
        }
        return true;
    }

不考虑并发的,缓存业务写法如上图。

成功的并发住了,出现问题了:

实际上,只是简单的读取并放入就会并发住:

 

系统初始化的时候,将票的库存加载到Redis 缓存中保存

下面用分布式锁解决问题,

        ③—①redis的setnx实现

如果key不存在,这种情况下等同SET命令。 当key存在时,什么也不做。

目前业务场景不支持用setnx,这里不说了

        ③—②redisson实现

解决方式是:在业务模块之前,redis加锁;在业务模块之后,redis解锁

这里引入redission,redisson 是 Redis 官方的分布式锁组件。很成熟了,拿来就可以用,支持单

点模式、主从模式、哨兵模式、集群模式,也不用自己写锁之类的。

Redisson实现了分布式和可扩展的java数据结构,支持的数据结构有:List, Set, Map, Queue,

SortedSet, ConcureentMap, Lock, AtomicLong, CountDownLatch。并且是线程安全的,底层使用

Netty4实现网络通信。和jedis相比,功能比较简单,不支持排序,事务,管道,分区等redis特性,

可以认为是jedis的补充,不能替换jedis。

首先,加入pom依赖

<!--redisson -->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.13.0</version>
        </dependency>

加入依赖后,注解@Autowried引用类RedissonClient,这个类提供了方法的加锁及解锁。

编写:

/**
     * redisson分布式锁方式,解决并发问题
     *
     * @param userId
     * @return
     */
    @Override
    @Transactional
    public Boolean redissonBuyTicket(Integer userId) {
        //假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
        //抢票前,需判断此票种是否还能抢票。
        //在这里,模拟都抢ticked_id=1的票

        Boolean result = true;
        //获取分布式锁
        RLock lock = redissonClient.getLock("ticked_id_1");
        lock.lock();

        try {
            QueryWrapper queryWrapper = Wrappers.query();
            queryWrapper.eq("ticked_id", 1);
            TicketNum numInfo = numMapper.selectById(1);
            Integer num = numInfo.getNum();
            if (num < 1) {
                log.warn("此票已被抢,userId={}的没抢到", userId);
                return false;
            }

            log.info("目前一共{}张票,userId={}抢到一张", num, userId);
//            Thread.sleep(5000);
            //业务逻辑卸载try...catch中 ,finally最后一定要释放锁
            //减票数
            TicketNum numModel = new TicketNum();
            numModel.setId(1);
            numModel.setNum(num - 1);
            numModel.setUpdateTime(new Date());
            numMapper.updateById(numModel);
            //插入日志
            TicketPurchaseLog model = new TicketPurchaseLog();
            model.setTickedId(1);
            model.setUserId(userId);
            model.setCreateTime(new Date());
            purchaseLogMapper.insert(model);
        } catch (Exception e) {
            log.error("抢票信息异常:{}", e.getStackTrace());
            result = false;
        } finally {
            lock.unlock();
        }
        return result;
    }

这里注意,try catch finally之后一定要解锁。

运行结果:报错

 注意,这里锁的key和库存key不要用一个!!!不要用redis已经存在的key。。。

再次运行,结果如下:

 成功控制住并发!再次解决。分布式锁推荐此种方式

分布式锁问题解决完了,下面继续说,引入缓存解决高并发,在中间会遇到各种问题,比如:

缓存失效、缓存击穿、缓存雪崩等问题,这里的问题,参见我的xmind图,就不细说了。

但是要注意,极高并发数量的情况下,必须引入缓存或者消息队列,使其计算迅速,否则直接打到

数据库上,运行缓慢,即使加了事务注解,也会有异常。

亲测过100次并发条件下,数据都会不正确……更别提数据量上升了,即使加了事务注解也没用,

所以一定要加缓存或者消息队列,使其运行速度。

下面附图一张引入缓存解决高并发,满足上述业务的最终方案代码图

/**
     * 缓存方式,解决并发问题
     *
     * @param userId
     * @return
     */
    @Override
    @Transactional
    public Boolean finalBuyTicket(Integer userId) {
        //假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
        //抢票前,需判断此票种是否还能抢票。
        //在这里,模拟都抢ticked_id=1的票

        Boolean result = true;
        //获取分布式锁
        RLock lock = redissonClient.getLock("ticked");
        lock.lock();
        try {
            Integer num = 0;
            if (redisUtil.exists("ticked_id_1")) {
                num = (Integer) redisUtil.get("ticked_id_1");
            } else {
                QueryWrapper queryWrapper = Wrappers.query();
                queryWrapper.eq("ticked_id", 1);
                TicketNum numInfo = numMapper.selectById(1);
                num = numInfo.getNum();
                redisUtil.set("ticked_id_1", num);
            }

            if (num < 1) {
                log.warn("此票已被抢,userId={}的没抢到", userId);
                return false;
            }
            log.info("目前一共{}张票,userId={}抢到一张", num, userId);
            //减票数
            redisUtil.set("ticked_id_1", num - 1);
            TicketNum numModel = new TicketNum();
            numModel.setId(1);
            numModel.setNum(num - 1);
            numModel.setUpdateTime(new Date());
            numMapper.updateById(numModel);
            //插入日志
            TicketPurchaseLog model = new TicketPurchaseLog();
            model.setTickedId(1);
            model.setUserId(userId);
            model.setCreateTime(new Date());
            purchaseLogMapper.insert(model);
        } catch (Exception e) {
            log.error("抢票信息异常:{}", e.getStackTrace());
            result = false;
        } finally {
            lock.unlock();
        }
        return result;
    }

引入redis缓存,成功解决效率问题,用redis锁解决并发问题,使其控制数据在同一进程中。

redis的默认最大并发数是1万,所以,这里的并发数据以1万为上限。实际上当并发数量超过一万

的时候,Jemeter的http请求会报红。

④关于加唯一索引的想法

建议只是想想,太简单粗暴了,能没弊端吗(一不小心又死锁了)……能在上游拦截就拦截,加索

引也可以,上游一定要处理后再加。跟悲观锁一个道理

⑤synchronized关键字,和lock锁(只针对单体,分布式系统不行)

/**
     * 关键字锁,解决并发问题
     *
     * @param userId
     * @return
     */
    @Override
    @Transactional
    public synchronized Boolean lockBuyTicket(Integer userId) {
        //假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
        //抢票前,需判断此票种是否还能抢票。
        //在这里,模拟都抢ticked_id=1的票
        try {
            QueryWrapper queryWrapper = Wrappers.query();
            queryWrapper.eq("ticked_id", 1);

            TicketNum numInfo = numMapper.selectById(1);

            if (numInfo == null || numInfo.getNum() < 1) {
                log.warn("此票已被抢,userId={}的没抢到", userId);
                return false;
            }
            log.info("目前一共{}张票,userId={}抢到一张", numInfo.getNum(),userId);

            //Thread.sleep(5000);
            //减票数
            numInfo.setNum(numInfo.getNum() - 1);
            numMapper.updateById(numInfo);
            //插入日志
            TicketPurchaseLog model = new TicketPurchaseLog();
            model.setTickedId(1);
            model.setUserId(userId);
            model.setCreateTime(new Date());
            purchaseLogMapper.insert(model);
        } catch (Exception e) {
            log.error("抢票信息异常:{}", e.getStackTrace());
            return false;
        }
        return true;
    }

成功解决问题。依旧不推荐,原理同悲观锁相同,都需要等待释放,很慢,吞吐量不行。而且,这

里还有比数据库悲观锁更不好的一点,关键字只能控制单体应用,并不能控制分布式服务

2.异步写入缓存

当更新数据库的同时,异步去更新缓存,比如更新数据库后把一条消息发送到mq中去实现。

优点:与业务解耦,不影响正常业务

缺点:可能某个时间缓存一直不存在,导致数据都从数据库中获取

具体消息队列的引入,在第二部分会说

3.延迟双删

先删除缓存,更新完数据后,再sleep一段时间,然后再次删除缓存。


缓存问题解决完了,下面开始用其他方式解决高并发问题:

二、消息队列

异步,削峰,解耦。用起来用起来

用消息队列,解决缓存最终一致性问题,解决效率问题,更方便业务解耦。这里我选择rabbitmq。

主业务:

/**
     * 异步消息队列,解决并发问题
     * 解除锁方式
     *
     * @param userId
     * @return
     */
    @Override
    @Transactional
    public Boolean mqBuyTicket(Integer userId) {
        //假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
        //抢票前,需判断此票种是否还能抢票。
        //在这里,模拟都抢ticked_id=1的票

        try {
            // 假设,余票库存放入缓存的步骤,在新建票种的时候,已经放入缓存,这里直接取
            // 首先,查一下票,为了能及时的返回给用户信息
            // 剩下的业务放入消息队列处理

            Integer num = 0;
            if (redisUtil.exists("ticked_id_1")) {
                num = (Integer) redisUtil.get("ticked_id_1");
            }
            if (num < 1) {
                log.warn("此票已被抢,userId={}的没抢到", userId);
                return false;
            }
            log.info("抢票成功:userId={}", userId);
            //放入消息队列
            iFeignPushService.pushMsg(userId.toString());
        } catch (Exception e) {
            log.error("抢票异常:{}", e.getStackTrace());
            return false;
        }
        return true;
    }

上述描述的【放入消息队列】代码如下:

package com.hyj.interfaces;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@FeignClient(name = "hahaha", path = "/feign", url = "localhost:6001")
public interface IFeignPushService {

    @GetMapping("/pushMsg")
    void pushMsg(@RequestParam("msg") String msg);
}

消费端代码如下:

/**
     * 消费端,消费业务逻辑
     *
     * @param userId
     * @return
     */
    @Override
    public Boolean reduceStocksConsumer(Integer userId) {
        log.info("消费逻辑userId={}", userId);
        try {
            //减票数
            Integer num = (Integer) redisUtil.get("ticked_id_1");
            if (num < 1) {
                log.warn("此票已被抢,userId={}的没抢到", userId);
                return false;
            }
            redisUtil.set("ticked_id_1", num - 1);
            TicketNum numModel = new TicketNum();
            numModel.setId(1);
            numModel.setNum(num - 1);
            numModel.setUpdateTime(new Date());
            numMapper.updateById(numModel);
            //插入日志
            TicketPurchaseLog model = new TicketPurchaseLog();
            model.setTickedId(1);
            model.setUserId(userId);
            model.setCreateTime(new Date());
            purchaseLogMapper.insert(model);
        } catch (Exception e) {
            log.error("消费端,消费业务逻辑异常:{}", e.getMessage());
            return false;
        }
        return true;
    }

此段代码是consumer微服务,监听生产消息,消费后,feign指过来的。具体实现如下:

这段是consumer的监听代码:

package com.hyj.service;

import com.hyj.interfaces.IFeignService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;


@Service
@Slf4j
public class MsgService {

    @Autowired
    private IFeignService iFeignService;

    @RabbitListener(queues = "directQueue")
    public void GetMsg(String msg) {
        try {
            log.info("************消费开始************:{}", msg);
            iFeignService.reduceStocksConsumer(msg);
            log.info("************消费结束**********");
        } catch (Exception e) {
            log.error("消费异常:{}", e.getMessage());
        }
    }
}

上述调用的接口,如下:

package com.hyj.interfaces;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

/**
 * 调用其他服务的方法
 */
@FeignClient(name = "hahaha",path = "/feign",url = "localhost:9999")
public interface IFeignService {

    /**
     * 库存减少,消费端处理
     * @return
     */
    @GetMapping("/reduceStocksConsumer")
    Boolean reduceStocksConsumer(@RequestParam("msg") String msg);
}

总而言之,上述用消息队列异步,成功解决了系统并发问题。

然后有了新的问题,消息丢失、重复消费、消息堆积等问题

  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值