Redis解决高并发问题

article/2025/10/7 2:09:38

1 模拟商品抢购和并发的效果

这里模拟一个商品抢购的过程所带来的问题,以及解决问题的思路。

这里模拟的商品抢购过程是一个商品正常购买的过程,其中包含了两个主要的步骤:商品库存减少和商品购买记录的添加。

下面搭建项目环境。

1.1 数据库结构(MySQL)

DROP DATABASE IF EXISTS rush_to_purchase_db;
CREATE DATABASE rush_to_purchase_db;
USE rush_to_purchase_db;/* 产品信息表 */
CREATE TABLE t_product(id INT(12) NOT NULL AUTO_INCREMENT COMMENT '商品编号',NAME VARCHAR(60) NOT NULL COMMENT '商品名称',stock INT(10) NOT NULL COMMENT '库存',price DECIMAL(16,2) NOT NULL COMMENT '单价',VERSION INT(10) NOT NULL DEFAULT 0 COMMENT '版本号',note VARCHAR(256) NULL COMMENT '备注',PRIMARY KEY(id)
);
/* 购买信息表 */
CREATE TABLE t_purchase_record(id INT(12) NOT NULL AUTO_INCREMENT COMMENT '记录编号',userId INT(12) NOT NULL  COMMENT '用户编号',productId INT(12) NOT NULL  COMMENT '商品编号',price DECIMAL(16,2) NOT NULL COMMENT '价格',quantity INT(12) NOT NULL  COMMENT '数量',purchaseTime TIMESTAMP NOT NULL DEFAULT NOW() COMMENT '购买时间',note VARCHAR(512) NOT NULL COMMENT '备注',PRIMARY KEY(id)
);
INSERT INTO t_product VALUES(1, 'Yogas2020笔记本电脑', 50, 4000, DEFAULT, 'Yogas2020笔记本电脑,14.3寸,轻便之选');

1.2 创建SpringBoot的SSM项目,实现基本购物功能

(1)Model

public class Product implements Serializable {private int id;private String name;private int stock;private double price;private int version;private String note;//省略getter、setter
}
public class PurchaseRecord implements Serializable {private int id;private int userId;private int productId;private double price;private int quantity;private double totalPrice;private Timestamp purchaseTime;private String note;//省略getter、setter   
}

(2)Mapper

public interface ProductMapper {@Select("SELECT id,name,stock,price,VERSION,note FROM t_product where id=#{id}")Product selectById(long id);@Update("update t_product set stock=stock- #{quantity} where id=#{id}")void descreaseStock(long id, long quantity);
}
public interface PurchaseRecordMapper {@Options(keyProperty = "id", useGeneratedKeys = true)@Insert("INSERT INTO t_purchase_record(userId,productId,price,quantity,purchaseTime,note) VALUES(#{userId},#{productId},#{price},#{quantity},#{purchaseTime},#{note})")void insert(PurchaseRecord record);
}

(3)Service

@Service
public class PurchaseServiceImpl implements PurchaseService {@Autowiredprivate PurchaseRecordMapper purchaseRecordMapper;@Autowiredprivate ProductMapper productMapper;@Transactionalpublic boolean purchase(int userId, int productId, int quantity) {//根据产品id判断库存是否够Product product= productMapper.selectById(productId);//如果库存不够,购买失败if(quantity>product.getStock()) {return false;}//如果库存足够--减库存productMapper.descreaseStock(productId,quantity);//增加购买记录addPurchaseRecord(userId,product,quantity);return true;}//添加购买记录private void addPurchaseRecord(int userId, Product product, int quantity){PurchaseRecord record=new PurchaseRecord();record.setPrice(product.getPrice());record.setPurchaseTime(new Timestamp(System.currentTimeMillis()));record.setProductId(product.getId());record.setUserId(userId);record.setNote("购买时间:"+System.currentTimeMillis());purchaseRecordMapper.insert(record);}
}

(4)Controller

@RestController
public class PurchaseController {@Autowiredprivate PurchaseService purchaseService;@PostMapping("/api/purchase")public String purchase(int userId,int productId,int quantity){boolean flag=purchaseService.purchase(userId,productId,quantity);return flag?"抢购成功":"抢购失败";}
}

(4)index.html:使用jQuery Ajax模拟抢购过程

<script src="jquery.js"></script>
<script>$(function(){//抢购按钮模拟500人抢购50台笔记本$("#rush2buy").click(function(){for(var i=1; i<=500; i++){var params = {userId:1, productId:1, quantity: 1};$.post("api/purchase", params, function(result){console.log(new Date().getTime());});}});})
</script>

数据库发生超发现象:

在这里插入图片描述

注意:

如果是低并发量测试一般时没问题的,如果购买不成功有正确的提示,如果是高并发量就会出现超发现象,即库存小于0的问题。即库存原本只有50,但是500个人去抢的时候,最后库存变成了-3,相当于卖出了53台.

2 方案1:线程同步

上述的超发现象,归根到底在于数据库时被多个线程同时访问的,在没有加锁的情况下,上述代码并不是线程安全的。

最简单的办法是为业务方法添加线程同步“synchroized”关键字,确保同一个时间只有一个线程进入操作。

    //添加synchronized实现线程同步@Transactionalpublic synchronized boolean purchase(int userId, int productId, int quantity) {//根据产品id判断库存是否够Product product= productMapper.selectById(productId);//如果库存不够,购买失败if(quantity>product.getStock()) {return false;}//如果库存足够--减库存productMapper.descreaseStock(productId,quantity);//增加购买记录addPurchaseRecord(userId,product,quantity);return true;}

线程同步把抢购业务方法变成了单线程执行,能保证不会发生超发现象,但随着并发量增加性能下降较大。
如果还是发生超发现象 ,是电脑性能比较强 ,我的代码逻辑上还存在缺陷,高并发造成瞬间数据穿透。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fCnSBkBx-1666238246946)(模拟并发下的Redis使用.assets/image-20211222112035751.png)]

3 方案2:数据库“悲观锁”

高并发情况下出现的问题,主要原因在于共享资源(stock)被多个线程并行修改。

如果一个数据库事务读取到产品库存后,就直接把该行数据锁定,不允许其它事务读写,直到事务完成商品库存的减少在释放锁,就不会出现并超发现象了。MySQL就提供数据库锁的解决方案,这种锁称为悲观锁。具体操作如下:

修改上述Mapper中的查询语句,在每次查询商品库存的时候加上更新锁。

public interface ProductMapper {@Select("SELECT id,name AS productName,stock,price,VERSION,note FROM t_product where id=#{id} for update")Product selectById(long id); ......
}

注意上述语句中“SELECT id,product_name AS productName,stock,price,VERSION,note FROM t_product where id=#{id} for update” 中的**“for update”称为更新锁**,在数据库事务执行过程中,它会锁定查询出来的数据,其他事务不能再对其进行读写,直到该事务完成才会只放锁,这样能避免数据不一致了。

经过上述修改,并发执行后就不会超发了。
如果还是发生超发现象 ,是电脑性能比较强 ,我的代码逻辑上还存在缺陷,高并发造成瞬间数据穿透。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uuyvHEbK-1666238246947)(模拟并发下的Redis使用.assets/image-20211222112815823.png)]

但由于加锁,会导致实际代码的执行时间有所增加。

4 方案3:“乐观锁”

(1)乐观锁的概念

悲观锁虽然可以解决高并发下的超发现象,却并非高效方案,另一些开发者会采用乐观锁方案。乐观锁并非数据库加锁和阻塞的解决方案,乐观锁把读取到的旧数据保存下来,等到要对数据进行修改的时候,会先把旧数据与当前数据库数据进行比较,如果旧数据与当前数据一致,我们就认为数据库没有被并发修改过,否则就认为数据已经被其它并发请求修改,当前的事务回滚,不再修改任何数据。在实际操作中,乐观锁通常需要在数据表中增加“数据版本号”这样一个字段,以标识当前数据和旧数据是否一致,每次修改数据后“数据版本号”要增加。

(2)乐观锁的使用

修改减少库存的Mapper方法,每次减少库存的时候同时修改数据的版本号version

public interface ProductMapper {//不使用悲观锁@Select("SELECT id,name AS productName,stock,price,VERSION,note FROM t_product where id=#{id}")Product selectById(long id);//使用乐观锁(添加版本号条件和版本号增加)@Update("update t_product set stock=stock- #{quantity}, version=version+1 where id=#{id} and version=#{version}")void descreaseStock(long id, long quantity, long version);
}

修改业务方法,每次修改库存时检查是否修改到,如果没改到数据“result==0”则表示数据版本号已经变更,有其他并发请求改过库存,放弃当前操作。

@Service
public class PurchaseServiceImpl implements PurchaseService {......//乐观锁方案@Transactionalpublic boolean purchase(int userId, int productId, int quantity) {//根据产品id判断库存是否够Product product=productMapper.selectById(productId);//如果库存不够,购买失败if(quantity>product.getStock()) {return false;}//如果库存足够--减库存int result=productMapper.descreaseStock(productId,quantity,product.getVersion());// 影响行数0,没修改成,代表版本号已经改变,已经并发,放弃本次修改System.out.println(result);if(result==0) {return false;}//增加购买记录addPurchaseRecord(userId,product,quantity);return true;}
}

乐观锁可以很好的提高执行效率,也可以确保不会出现超发的数据不一致问题。但是,乐观锁也有自己的问题,请求失败率变得很高,以致数据库可能还有剩余的商品。

例如,我们把模拟的抢购人数从500将为100,则可能看到库存还有剩余商品。

for(var i=1; i<=100; i++){	//将为100var params = {userId:1, productId:1, quantity: 1};$.post("api/purchase", params, function(result){console.log(new Date().getTime());});
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VMYzMJRX-1666238246948)(模拟并发下的Redis使用.assets/image-20211222120347556.png)]

因此,乐观锁虽然能避免并发,却并不适合抢购的业务场景,当然,我们也可以增加失败重试的机制去增加成功率。

5 方案4:使用Redis提高并发性

实际中,引入Redis这类NoSQL是提高并发性的更好选择。

Redis这类的NoSQL数据库以Hash的方式把数据存放在内存中,在理想环境中每秒读写次数可以高达10万次,数据吞吐效率远高于SQL数据库,因此常常用来解决大规模并发的访问效率问题。

Redis 的“INCR”命令可以将key中存储的数字值加1。如果key不存在,那么Key的值会先被初始化为0,然后再执行INCR操作。Redis 中的该操作是原子性的,不会被高并发打断,可以确保数据的一致性。

5.1 使用Redis计数器的处理思路:

(1)抢购开始前,Redis缓存抢购商品的HashMap:从数据库中读取参加抢购的商品(ID)和对应的库存(stock)保存在Redis中;

(2)Redis中为每件抢购商品单独保存一个计数器:key中保存商品id信息,value中保存商品的销量(sales);

(3)处理每个购买请求时,先从Redis中读取商品库存(stock)和之前的销量(sales);若“库存<之前销量+本次购买量” 则 返回购买失败;否则 使用原子计数器增加销量,并继续执行后续的数据库操作;

5.2 具体实现:

(1)为Spring Boot 项目引入 Redis 依赖

        <!-- redis --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>

(2)修改 application.yml 配置Redis

spring:#redis配置连接redis:database: 0host: localhostport: 6379

(4)在开始抢购前缓存商品和库存集合

这里为了方便测试,直接用SpringBootTest来模拟把商品预先缓存到Redis的操作。

@RunWith(SpringRunner.class)
@SpringBootTest
public class AddStocks2RedisTests {@Autowired@Qualifier("stringRedisTemplate")private RedisTemplate redisTemplate;@Autowiredprivate ProductService productService;@Testpublic void testAddStocks2Redis() {productService.findAll().forEach(x->{redisTemplate.opsForHash().put("product-stocks", x.getId()+"", x.getStock()+"");});redisTemplate.expire("product-stocks", 3600, TimeUnit.SECONDS);}
}

(4)重写PurchaseServiceImpl中的purchase方法,处理购买前先检查Redis中的库存和销量

    @Autowired@Qualifier("stringRedisTemplate")private RedisTemplate<String,String> redisTemplate;//使用Redis判断库存量是否够发,过滤掉超发请求,然后再进行SQL操作@Transactionalpublic boolean purchase(int userId, int productId, int quantity) {/* 使用Redis对比并发时的销量和库存量是否一致,排除超发请求 *///读取商品库存long stock = Long.parseLong(redisTemplate.opsForHash().get("product-stocks", productId + "").toString());//读取商品销量String value = redisTemplate.opsForValue().get("product-sales-" + productId);int sales = 0;if (value != null) {sales = Integer.valueOf(value);} else {    //若还没有对应产品的销量,向Redis初始化该产品销量为0redisTemplate.opsForValue().set("product-sales-" + productId, "0", 3600, TimeUnit.SECONDS);}if (stock < (sales + quantity)) {   //对比库存量和销量,库存不足销售时返回falsereturn false;}redisTemplate.opsForValue().increment("product-sales-" + productId, quantity);  //增加Redis中的销量/* 以下为MySQL数据库操作,不超发的请求才执行,可以保留悲观锁操作,以防Redis中还可能有漏网的并发  */Product product = productMapper.selectById(productId);if (product.getStock() < quantity) {return false;}//减少库存productMapper.descreaseStock(productId, quantity);//增加购买记录addPurchaseRecord(userId,product,quantity);return true;}

这个方法利用了Redis的高速访问特性,有效的提高了并发超发的检查效率。

下为MySQL数据库操作,不超发的请求才执行,可以保留悲观锁操作,以防Redis中还可能有漏网的并发 */
Product product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
return false;
}
//减少库存
productMapper.descreaseStock(productId, quantity);
//增加购买记录
addPurchaseRecord(userId,product,quantity);
return true;
}


这个方法利用了Redis的高速访问特性,有效的提高了并发超发的检查效率。在实际应用中,我们还可以把购买的整个过程使用Redis操作记录下来,在空闲的时候再把结果同步回SQL数据库,这样就真的能解决并发的效率问题了。

http://chatgpt.dhexx.cn/article/VTw8eYsh.shtml

相关文章

一文教你如何处理高并发

目录 前言 一、为什么要解决高并发问题 二、性能评估 计算峰值流量方法 本章结论 三、性能测试 测试目的 找到系统最高承受压力的临界点 找出系统中的短板 测试工具 简单测试 1.数据抓包 2.加压测试 3.硬件跟踪 4.JVM跟踪 5.其它组件测试 6.总括 全链路测试&…

高并发场景设计与解决方案

所有的平台或系统建设和维护中&#xff0c;高并发场景都存在&#xff0c;解决方案也是各种样式&#xff0c;本次将从初中、高二个场景给出设计方案。 本文内容&#xff1a;高并发场景定义&#xff0c;高并发初中级场景与解决方案&#xff0c;高并发高级场景与解决方案 第一部分…

数据库关系代数运算

转载&#xff1a;https://wenku.baidu.com/view/f301bf48e45c3b3567ec8b75.html

数据库关系模型与关系运算---2022.2.13

关于外模式&#xff0c;模式&#xff0c;内模式的理解 可以看到用不同的语句进行表示&#xff1a; 关系的性质 概念模式/内模式映射是物理独立性的关键&#xff1b; 外模式/概念模式映射就是逻辑独立性的关键 候选键 (最小组成的超键) 关系中的一个属性组&#xff0c;其值…

关系运算

关系代数是一种抽象的查询语言&#xff0c;它用对关系的运算来表达查询。关系运算的运算对象是关系&#xff0c;运算结果亦是关系&#xff0c;关系代数的运算符包括两类&#xff1a;传统的集合运算和专门的关系运算两类。 传统的集合运算是从关系的水平方向&#xff0c;即行的角…

数据库之间的关系

数据库的设计 1.多表之间的关系 1.一对一&#xff1a;如 人和身份证 &#xff0c;一个人只能一张身份证&#xff0c;一个身份证只能对应一个人 2.一对多&#xff1a;如 部门和员工 一个部门有多个员工&#xff0c;一个员工只能对应一个部门 3.多对多&#xff1a…

数据库(笔记)——关系代数以及相关运算

关系代数 关系代数及其运算符集合运算符关系运算符 总结 关系代数及其运算符 关系代数是一种抽象的查询语言&#xff0c;通过关系的运算来表达查询 关系代数常使用的运算符由如下几类 集合运算符&#xff1a;∪&#xff08;并&#xff09;、∩&#xff08;交&#xff09;、-&…

数据库关系代数详解

文章目录 数据库关系代数1. 传统的关系运算2. 专门的关系运算2.1 关系运算中的基础概念2.2 元组的连接2.3 象集(除法运算重要工具) 3 数学上的运算3.1 并运算3.2 差运算3.3 交运算3.4 笛卡尔积&#xff08;万能运算&#xff09; 4. 关系运算4.1 表格简介4.2 选择&#xff08;Se…

数据库专门的关系运算

本文章用表 选择运算&#xff08;从行的角度运算&#xff09; 选择又称为限制&#xff0c;选择运算符的含义&#xff1a; 在关系R中选择满足给定条件的诸元组 投影&#xff08;从列的角度运算&#xff09; 投影运算符的含义&#xff1a;从表中选出若干属性列组成新的关系 注…

数据库关系代数运算之连接

联接有三种&#xff1a;θ联接和自然联接&#xff08;这里是算术比较符&#xff09;&#xff0c;外联接。 &#xff08;1&#xff09; θ联接 (从R和S的笛卡儿乘积中选取满足条件“iθj”的元组 •&#xff08;2&#xff09;自然联接&#xff08;naturaljoin&#xff09; 两个…

数据库关系代数中除运算讲解和SQL语句的实现

【数据库原理】关系代数篇——除法讲解 陈宇超 编辑总结: 除法运算的一般形式示意图 如何计算RS呢&#xff0c;首先我们引进”象集”的概念&#xff0c;具体意义看下面的陈述即可理解 关系R和关系S拥有共同的属性B、C , RS得到的属性值就是关系R包含而关系S不包含的属性&am…

关系代数基本运算 数据库

操作目录 关系代数的八种基本运算并交差笛卡尔积选择投影连接除总结 关系代数的八种基本运算 并 并&#xff0c;就是将两个或多个表并连起来&#xff0c;需要注意的就是在并的过程中&#xff0c;我们并不是直接一笼统地并起来&#xff0c;而且还要对相同的元祖进行合并&#x…

数据库系统概论----关系运算之除运算

这一周都在复习《数据库系统概论》这门课&#xff0c;看到关系运算的这一节时&#xff0c;对于除运算不是很理解。 通过百度&#xff0c;我觉得也没有得到比较容易理解的讲解。 这里呢&#xff0c;我就分享一下我的理解吧&#xff0c;如有差错的地方&#xff0c;还希望看到这…

数据库-----关系运算

关系数据库概述 相关术语 ◎在现实世界中&#xff0c;描述一个事物常常要抽取其若干特征来表示&#xff0c;这些特征称为属性&#xff0c;如用学号、性别、班级等来描述学生。每个属性的取值范围对应一个值的集合&#xff0c;称为属性的域&#xff0c;如性别的域是{男&#x…

数据库基础--关系代数中的除法运算

除法运算的定义&#xff1a; 这个概念的描述的非常抽象&#xff0c;刚开始学习的同学完全不知所云。这里通过一个实例来说明除法运算的求解过程 设有关系R、S 如图所示&#xff0c;求RS 的结果 求解步骤过程&#xff1a; 第一步&#xff1a;找出关系R和关系S中相同的属…

数据库的运算

数据库的运算可分为集合运算和关系运算。 一、集合运算 • 从关系的水平方向迚行&#xff1b; • 包括&#xff0c;幵、交、差、笛卡尔积运算。 • 幵运算&#xff08;R U S&#xff09;&#xff1a;可实现数据的揑入。 • 差运算&#xff08;R–S&#xff09;&#xff1a;主…

关系数据库:专门关系运算

专门关系运算有&#xff1a;选择&#xff0c;投影&#xff0c;连接&#xff0c;除运算。 1.选择从关系中找出满足给定条件的所有元组称为选择&#xff0c;其中条件是用逻辑表达式给出的&#xff0c;逻辑表达式为真时元组被选取。 选择运算记为δF&#xff08;R&#xff09;&am…

详解【数据库】关系代数基本运算

文章目录 五中基本的关系代数操作并&#xff08;Union&#xff09;差&#xff08;Difference&#xff09;广义笛卡尔积&#xff08;Extended Cartesian Product&#xff09;投影&#xff08;Projection&#xff09;选择&#xff08;Selection&#xff09; 连接等值连接自然连接…

数据库关系运算——除运算

书上给“除运算”的定义是&#xff1a; 设关系R除以关系S的结果为关系T&#xff0c;则T包含所有在R但不在S中的属性及其值&#xff0c;且T的元组与S的元组的所有组合都在R中。 我对此不是很理解。 直到看到这样的解读&#xff0c;方才恍然大悟&#xff1a;

【数据库】关系代数基本运算

前言 关系代数是以关系为运算对象的一组高级运算的集合。由于关系定义为属性个数相同的元组的集合&#xff0c;因此集合代数的操作就可以引入到关系代数中。关系代数中的操作可以分为两类&#xff1a;传统的关系操作&#xff0c;并、差、交、笛卡尔积&#xff08;乘&#xff09…