测试环境:netcore3.1 redis-6.2.4
一:安装Redis
尽管在不是系统性介绍Radis的地方介绍安装radis并不是一件明智的事情,但本着能跑起来就算成功的原则,这里简单介绍一下。
1.1 首先去https://redis.io/下载对应的tar包,默认我们要在linux系统下安装,这里使用CentOS7做演示
1.2 安装必备的c语言编译环境
yum install centos-release-scl scl-utils-build && yum install -y devtoolset-8-toolchain && scl enable devtoolset-8 bash
centos8只需要装一下gcc即可
测试gcc版本
1.3 将下载好的redis-6.2.4.tar.gz放在/opt目录下
使用tar -zxvf redis-6.2.4.tar.gz
解压文件,解压完成后cd进入redis-6.2.4目录
tar -zxvf redis-6.2.4.tar.gz && cd redis-6.2.4
在redis-6.2.4目录下执行make命令(编译)
(补充:这里如果没有安装c语言环境,make会报错—Jemalloc/jemalloc.h:没有那个文件,解决办法是运行make distclean
,然后再重新make)
等到如上方图示一样出现了 It’s a good idea to run ‘make test’ 字样,证明编译成功,于是我们直接跳过测试,使用make install
来完成最后的安装
安装目录:/usr/local/bin
查看默认安装目录:
redis-benchmark:性能测试工具,可以在自己本子运行,看看自己本子性能如何
redis-check-aof:修复有问题的AOF文件,rdb和aof后面讲
redis-check-dump:修复有问题的dump.rdb文件
redis-sentinel:Redis集群使用
redis-server:Redis服务器启动命令
redis-cli:客户端,操作入口
1.4 后台启动radis,首先备份redis.conf文件,拷贝一份到其他目录
mkdir /myredis && cp /opt/redis-6.2.4/redis.conf /myredis/redis.conf
修改redis.conf(257行)文件将里面的daemonize no 改成 yes,让服务在后台启动
如果希望可以被远程访问,注释掉bind 127.0.0.1 的配置
如果想设置密码,搜索requirepass,设置格式为:
requirepass 123 指定密码123
然后使用redis-server /myredis/redis.conf
启动服务
redis-server /myredis/redis.conf
用客户端访问redis-cli
使用Ping验证
如果设置了密码,登录进入后输入 auth [你的密码]
回车完成验证。
如果有需求,不要忘记端口放过
firewall-cmd --zone=public --add-port=6379/tcp --permanent #开启指定端口
systemctl restart firewalld.service #重启防火墙
二:测试在.net中连接redis
1.1 准备一个.net项目,我们先要在NuGet中下载对应的包 StackExchange.Redis
1.2 自建一个Controller,路由随意,我们准备往名为 "sk:001:qt"
的key中添加一个数值50,用来作为我们的库存,其中001是商品的编号,代码如下:
[ApiController]
[Route("Val")]
public class ValuesController : Controller
{
//添加商品库存
[Route("add")]
public string Add(string prodid) {
if (prodid == null)
{
return "产品id不能为空";
}
//redis连接
ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("192.168.132.136:6379,password=123123");
//获取redis数据库
IDatabase db = redis.GetDatabase();
//拼接库存Key
string stockKey = "sk:" + prodid + ":qt";
//调用StringSet方法来设置kv
db.StringSet(stockKey, 50);
return "设置 " + stockKey + " 商品50件库存成功.";
}
}
访问接口:https://localhost:5001/Val/add?prodid=001
去radis-cli
中查看是不是有对应的数据
如此便说明连接测试成功。
三:做简单的秒杀案例
思路:
首先需要一个stockKey,也就是我们前面所提到的商品库存key,是一个String类型,用户通过指明该库存key来进行有选择的消费;接着需要一个userKey,是一个Set类型,维护着所有已经参与秒杀的成员列表,因为我们的秒杀是只允许用户参与一次,因此,只要用户出现在了这个userKey的集合中,就可以判定其已经参与过秒杀,从而不放行后面的操作。
步骤1:uid和pid非空判断,uid是用户编号,pid(prodid)是商品编号
步骤2:拼接key
步骤3:获取库存,如果库存为Null,说明秒杀还没开始
步骤4:判断用户是否重复秒杀
步骤5:判断商品数量,数量小于1结束秒杀
步骤6:执行秒杀
步骤7:把成功用户放清单里
代码:
[ApiController]
[Route("Val")]
public class ValuesController : Controller
{
//执行秒杀测试
[Route("kill")]
public String Index(String uid,String prodid )
{
//redis连接
ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("192.168.132.133:6379,password=123123");
//获取redis数据库
IDatabase db = redis.GetDatabase();
//1 uid和pid非空判断
if (uid == null || prodid == null)
{
return "id不能为空";
}
//2 拼接key
//2.1 库存Key
String stockKey = "sk:" + prodid + ":qt";
//2.2 秒杀成功用户key
String userKey = "sk:" + prodid + ":user";
//3 获取库存,如果库存为Null,秒杀还没开始
string stock = db.StringGet(stockKey);
if (stock == null)
{
redis.Close();
return "秒杀还没开始";
}
//4 判断用户是否重复秒杀
if (db.SetContains(userKey, uid)) {
redis.Close();
return "您已经秒杀过商品,请勿重复操作。";
}
//5 判断商品数量,数量小于1结束秒杀
if (int.Parse(stock) < 1) {
redis.Close();
return "秒杀活动已经结束";
}
//6 秒杀过程
//6.1 库存减一
db.StringDecrement(stockKey);
//7.2 把成功用户放清单里
db.SetAdd(userKey, uid);
//关闭连接
redis.Close();
return "用户:" + userKey + " 秒杀成功!! 剩余库存为:"+db.StringGet(stockKey);
}
}
直接访问测试:https://localhost:5001/Val/kill?uid=1&prodid=001
第二次直接测试:https://localhost:5001/Val/kill?uid=1&prodid=001
查看radis-cli:
至于上述使用到的StringDecrement
等等方法是怎么来的,可以查看IDatabase
的源码找到,这里无非就是在原始radis的操作上做一层封装,名称里大部分也都带着Set,String,Hash等字眼,仔细找找就可以发现自己需要的方法。
这里简单的对上面使用过的方法进行一个总结
方法 | 作用 |
---|---|
StringGet(key) | 获取指定字符串key下的value |
SetContains(key,value) | 判断key为key的Set集合中包不包含该value |
StringDecrement(key) | 对指定key下的value进行递减,默认步长为1 |
SetAdd(key,vlaue) | 将制定的value添加到对应key下的集合中 |
这样一来,一个简陋的秒杀逻辑就完成了。
四:并发测试
但是这样真的安全吗?
这里我们稍微改动一下代码方便用来测试,显示添加了一个工具类用来随机生成用户id。
RandomNumUtil
public class RandomNumUtil
{
//随机生成六位数的验证码
public static String getCheckNumber()
{
Random rd = new Random();
int num = rd.Next(100000, 1000000);
return num.ToString();
}
}
然后是修改kill方法:
[Route("kill")]
public String Index()
{
//生成一个随机的用户id
string uid = RandomNumUtil.getCheckNumber();
string prodid = "001";//商品固定001
//redis连接
ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("124.71.96.193:6379,password=Project@2020");
//获取redis数据库
IDatabase db = redis.GetDatabase();
//1 uid和pid非空判断
if (uid == null || prodid == null)
{
return "id不能为空";
}
//2 拼接key
//2.1 库存Key
String stockKey = "sk:" + prodid + ":qt";
//2.2 秒杀成功用户key
String userKey = "sk:" + prodid + ":user";
//3 获取库存,如果库存为Null,秒杀还没开始
string stock = db.StringGet(stockKey);
if (stock == null)
{
redis.Close();
return "秒杀还没开始";
}
//4 判断用户是否重复秒杀
if (db.SetContains(userKey, uid)) {
redis.Close();
return "您已经秒杀过商品,请勿重复操作。";
}
//5 判断商品数量,数量小于1结束秒杀
if (int.Parse(stock) < 1) {
redis.Close();
return "秒杀活动已经结束";
}
//6 秒杀过程
//6.1 库存减一
db.StringDecrement(stockKey);
//7.2 把成功用户放清单里
db.SetAdd(userKey, uid);
//关闭连接
redis.Close();
return " 秒杀成功!! ";
}
我们使用Jmeter压测一发,先温柔的来上50个线程
填写对应的测试信息
Run一波,等上个六七秒钟Stop
后台直接爆了异常,因为我们一直裸连太消耗性能了。
接着去看看Radis战况如何,发现商品直接被卖成负数了。
出现这种情况的原因,是因为我们的扣减库存的操作根本就不是原子级的,在高并发的情况下,线程间的可见性不是很好,这就导致线程彼此都对同一个操作进行了提交,从而卖成了负数。
五:结合Lua脚本解决库存问题
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。因此,这里我们就可以通过Lua来调一些底层的钩子,从而完成一些原子级的操作。
在此之前,我们先对radis连接行为进行一个简单的封装,然后让它自己注入进去,只不过这里我封装的非常简陋,按理说是应该将所有用到的方法都封装一下,只不过为测试,且希望直接掉它的Close方法,因此只是对ConnectionMultiplexer进行了一个封装。
RadisConn
//获取Redis连接工具
public class RadisConn
{
//定义连接器
static ConnectionMultiplexer redis = null;
//获取连接数据库
public ConnectionMultiplexer getConn() {
try
{
redis = ConnectionMultiplexer.Connect("192.168.132.123:6379,password=123123");
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
return redis;
}
}
Startup.cs
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
//支持mvc服务
services.AddControllersWithViews();
//DI依赖注入
services.AddSingleton<RadisConn>();
}
}
ValuesController
[ApiController]
[Route("Val")]
public class ValuesController : Controller
{
//radis数据库连接
private RadisConn radisTemplate;
public ValuesController(RadisConn radisTemplate)
{ //依赖注入
this.radisTemplate = radisTemplate;
}
}
5.1 首先是在NuGet中下载NLua依赖包
5.2 ValueController中改进方法
[Route("do")]
public string doKill() {
string uid = RandomNumUtil.getCheckNumber(); //生成一个随机的用户id
string prodid = "001";//商品固定001
//redis连接
ConnectionMultiplexer redis = radisTemplate.getConn();
IDatabase db = redis.GetDatabase();
//定义Lua脚本
string secKillScript = "local userid=KEYS[1];\r\n" +
"local prodid=KEYS[2];\r\n" +
"local qtkey='sk:'..prodid..\":qt\";\r\n" +
"local usersKey='sk:'..prodid..\":user\";\r\n" +
"local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" +
"if tonumber(userExists)==1 then \r\n" +
" return 2;\r\n" +
"end\r\n" +
"local num= redis.call(\"get\" ,qtkey);\r\n" +
"if tonumber(num)<=0 then \r\n" +
" return 0;\r\n" +
"else \r\n" +
" redis.call(\"decr\",qtkey);\r\n" +
" redis.call(\"sadd\",usersKey,userid);\r\n" +
"end\r\n" +
"return 1";
RedisKey[] s = new RedisKey[2];
s[0] = uid;
s[1] = prodid;
//使用ScriptEvaluate执行脚本
Object result = db.ScriptEvaluate(secKillScript,s);
string result1 = result.ToString();
//关闭现有连接
redis.Close();
if ("0".Equals(result1))
{
return "已抢空!!";
}
else if ("1".Equals(result1))
{
return "抢购成功!!!!";
}
else if ("2".Equals(result1))
{
return "该用户已抢过!!";
}
else
{
return "抢购异常!!";
}
}
如此一来,借由lua提供的原子级操作,在使用Jmeter压测的时候,就不会出现库存为负数的情况了。
完整脚本内容如下:
local userid=KEYS[1];
local prodid=KEYS[2];
local qtkey="sk:"..prodid..":qt";
local usersKey="sk:"..prodid.":user';
local userExists=redis.call("sismember",usersKey,userid);
if tonumber(userExists)==1 then
return 2;
end
local num= redis.call("get" ,qtkey);
if tonumber(num)<=0 then
return 0;
else
redis.call("decr",qtkey);
redis.call("sadd",usersKey,userid);
end
return 1;