一、Redis Lua脚本
Lua脚本介绍
Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中从而为应用程序提供灵活的扩展和定制功能。
Lua目前大规模的嵌入到C语言的开发环境中
- 网易游戏 游戏引擎使用Lua
- 腾讯游戏 游戏引擎使用Lua
Lua脚本在中间件中的应用
- Nginx反向代理 脚本Lua(流控、黑名单)
- Redis分布式缓存 脚本Lua
Lua脚本语法
-- 第一个Lua程序
print("Hello World !")
-- Lua的数据类型
-- nil 空类型、布尔类型 [false|true]、number 双精度浮点型
-- string 字符串类型[单双引号]、table 数组类型
-- userdata 自定义类型、function 函数类型、thread 线程类型
a = false --布尔型
b = {} --表 数组型 table型
b["laoshi"] = "XX老师"
print(b["laoshi"])
-- Lua运算符 + - * / % -负数
-- 关系运算 = = 等于 a = = b [true|false]
-- 逻辑运算 and 且、or 或、not 非
a = 1
b = 2
c = a + b
-- Lua 变量类型
a,b,c=1,2,3 -- a b c 是全局变量
local d = 4 -- 局部变量
Redis的Lua脚本与性能调优
Redis作为高速内存存储系统,提供了丰富的数据结构控制指令,但是Redis没有提供复杂流程的控制能力。(string、list、set、hash、zset)新增(Geospatial、HyperLogLog、Bitmaps)
Redis内置了Lua解释器,使用Lua脚本进行服务端编程,扩展Redis功能、优化性能。
Lua脚本的例子 A用户转账B用户100元
/**
* java伪代码
*/
//两个步骤使用了两次的网络请求
java.redis.decrby(A,100) //扣A用户100元
java.redis.incrby(B,100) //给B用户加100元
//使用Redis Lua脚本优化
//只使用了一次网络请求
String lua = "decrby A 100 incrby B 100"
java.redis.lua.execute(lua)
//客户端一:A转账B用户100元
//客户端二:A转账C用户100元
//两个步骤使用了两次的网络请求
//高并发情况下会出现竟态条件,无法保证执行的顺序性(原子性)
java.redis.decrby(A,100) //扣A用户100元
java.redis.incrby(B,100) //给B用户加100元
java.redis.decrby(A,100) //扣A用户100元
java.redis.incrby(C,100) //给C用户加100元
//同时执行并不会造成多大的影响,但如果
java.redis.incrby(B,100) //给B用户加100元
java.redis.decrby(A,100) //扣A用户100元
java.redis.incrby(C,100) //给C用户加100元
java.redis.decrby(A,100) //扣A用户100元
//B、C同时加完钱开始扣A的金额,但A此时只有100块,此时会出现C账户里的钱是不对的。
Lua脚本类似于关系数据库中的函数和存储过程。
Redis嵌入Lua脚本的优点:
- 减少网络开销:Lua脚本只需要一次网络传输。
- 原子操作:Redis将整个脚本作为一个原子执行。
- 可复用:脚本会保存在Redis服务器中,其他的客户端也可使用。
Redis运行Lua脚本的第一种方式
EVAL运行Lua脚本
-- lua脚本
-- set teacher shark --Redis 指令
"redis.call('set','teacher','shark')"
-- number 代表 key 参数的数量
-- KEY[?] 代表KEY类型变量 系统默认
-- EVAL <lua脚本> number <key ...> <argv ...>
EVAL "redis.call('set','teacher','shark')" 0
EVAL "redis.call('set',KEYS[1],'shark1')" 1 teacher
EVAL "redis.call('set',KEYS[1],ARGV[1])" 1 teacher shark2
注意是KEYS不是KEY,加完后是没有单引号的(踩过坑)。
#错误指令
> EVAL "return 1"
ERR wrong number of arguments for 'eval' command
#正确指令(必须按照规范加number)
> EVAL "return 1" 0
1
Redis中运行多行Lua脚本
#复合多行指令
set teacher shark
get teacher
#多行指令Lua脚本
#使用空格来分割多个命令
#return 是返回值
"redis.call('set','teacher','shark') return redis.call('get','teacher')"
EVAL "redis.call('set','teacher','shark') return redis.call('get','teacher')" 0
EVAL "redis.call('set',KEYS[1],ARGV[1]) return redis.call('get',KEYS[1])" 1 teacher shark
小例子:
# EVAL <lua> 2 x1 x2 x3 x4
$ EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 x1 x2 x3 x4
1) "x1"
2) "x2"
3) "x3"
4) "x4"
Redis中Lua脚本的原子性
# set teacher shark
# get teacher
$ EVAL "redis.call('set','teacher','shark') return redis.call('get','teacher')" 0
Redis中Lua脚本的原子性指的是整体执行,不可拆分【不存在执行部分命令的情况】。
类比下面两个原子性的场景
- JDK锁包含的原子操作,比如CAS原子操作
- ACID事务一组SQL执行单元,要么整体成功,要么整体失败
Redis中的Lua脚本的原子性原理是什么?
Redis使用单个Lua解释器去运行所有脚本,并且,Redis也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或Redis命令被执行。这和使用MULTL/EXEC包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意,写一个跑的很快和顺溜的脚本并不难,因为脚本的运行开销(overhead)非常少,但是当你不得不使用一些跑的比较慢的脚本时,请小心,因为当这些蜗牛脚本在慢吞吞地运行的时候,其他客户端会因为服务器正在忙而无法执行命令。
Redis中Lua脚本的性能缺点
- Redis中的Lua脚本单线程串行排队执行
- 避免使用Redis运行低效的Lua脚本导致的系统卡顿
Redis中的事务原子性原理
Redis中事务的执行过程
MULTI命令用于开启一个事务,它总是返回ok。MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。另一方面,通过调用DISCARD客户端可以清空事务队列,并放弃执行事务。
#开启事务
$ MULTI
OK
#命令入队
$ INCR foo
QUEUED
$ INCR bar
QUEUED
#执行事务
$ EXEC
1) (integer) 1
1) (integer) 1
Redis中的事务可以一次执行多个命令,并且带有以下两个重要的保证:
- 事务是一个单独的隔离操作:事务中所有的命令都会序列化、按顺序地执行、事务在执行的过程中,不会被其他客户短发送来的命令请求锁打断。
- 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。
Redis中事务的本质:
Redis中的事务是客户端执行多个命令原子性的保障。
Redis事务和传统关系型数据库事务的区别:
- 原子性:Redis的Lua脚本可以原子性的运行,但是遇到异常不回滚,不保证整体成功
- 一致性:Redis的Lua脚本运行时可以保证一致性,但是遇到异常不回滚,不保证整体一致性。
- 隔离性:Redis的Lua脚本使用单线程排队串行化执行,自带隔离级别。
- 持久化:Redis的Lua脚本操作的是内存,但是Redis的持久化机制容易导致数据丢失。
Redis事务并非传统关系型数据库事务。
Redis中Lua脚本的事务性
Redis脚本和事务
从定义上来说,Redis中的脚本本身就是一种事务,所以任何事务可以完成的事,在脚本里面也能完成,并且一般来说,使用脚本要来得横简单,并且速度更快。
因为脚本功能是Redis2.6才引入,而事务功能则更早之前就存在了,所以Redis才会同时存在两种处理事务的方法。
不过我们并不打算在短时间内就移除事务的功能,因为事务提供了一种即使不使用脚本,也可以避免竞争条件的方法,而且事务本身的实现并不复杂。
不过在的将来,可能所有用户都会只使用脚本来实现事务也说不定,如果真的发生的话,那么我们将废弃并最终移除事务功能。
结论 Redis中Lua脚本天然实现了Redis的事务,Redis中的Lua脚本也可以成为事务脚本。
Lua脚本的事务性等价案例
$ EVAL "redis.call('incr','foo') redis.call('incr','br')" 0
$ MULTI
$ INCR foo
$ INCR br
$ EXEC
Redis中Lua脚本的缺陷
Redis中事务原子性的缺陷【Lua脚本的事务原子性缺陷】
Redis的Lua脚本缺陷实验
# Redis Lua脚本运行时异常不会回滚
# Redis Lua脚本要求你的程序必须是百分百正确,否则后果很严重。
redis.call('lpush','foo','a')
redis.call('set',996,996)
# 错误指令
redis.call('get','foo')
redis.call('set',997,997)
return 1
# 错误指令导致的Lua脚本只执行一半
$ EVAL "redis.call('lpush','foo','a') redis.call('set',996,996) redis.call('get','foo') redis.call('set',997,997) return 1" 0
- 如果使用RDB持久化、Redis被管理员杀死或硬件故障可能导致事务的命令只写了一部分。
- Redis主从的情况下的切换有可能导致事务的命令只持久化了部分数据【命令原子执行了,但是数据丢失了】
- Redis中的事务再出现运行时异常,事务不会撤销和回滚,有可能导致事务的命令只写了部分。【Redis中Lua脚本必须保证能正确执行,只能执行正向事务】
二、SpringBoot Redis Lua实战
SpringBoot集成Redis
源码地址:https://github.com/caozhenyuan/springboot-redis-lua-demo.git
第一步:创建一个springboot项目
image-20230223154216286.png使用常规的Redis方案:Spring Data Redis(Access+Driver)
image-20230223161105994.png第二步:配置redis的连接地址,配置application.yml文件
server:
port: 7001
servlet:
context-path: /
spring:
redis:
# redis地址
host: 127.0.0.1
# redis端口
port: 6379
# redis索引
database: 0
# redis密码
password:
# redis连接超时时间
timeout: 10s
lettuce:
pool:
# redis连接池中的最小空闲连接
min-idle: 0
# redis连接池中的最大空闲连接
max-idle: 8
# redis连接池的最大数据库连接数
max-active: 8
# redis连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
第三步:测试
package com.czy.springbootredislua.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/hello")
public class HelloWorldController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/teacher")
public String teacher(){
stringRedisTemplate.opsForValue().set("teacher","shark");
return "hello teacher";
}
}
测试地址:http://localhost:7001/hello/teacher
结果:
image-20230223163747449.pngSpringBoot集成Lua脚本实战
第一步:新建Lua脚本,放置到resource目录下起名Demo.lua
redis.call('set',KEYS[1],ARGV[1]) return redis.call('get',KEYS[1])
第二步:配置SpringBoot项目Lua脚本Bean
@Configuration
public class LuaConfig {
@Bean
public DefaultRedisScript<String> redisScript() {
DefaultRedisScript<String> defaultRedisScript = new DefaultRedisScript<>();
//设置返回类型
defaultRedisScript.setResultType(String.class);
//defaultRedisScript.setScriptText();
defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("Demo.lua")));
return defaultRedisScript;
}
}
第三步:创建controller
package com.czy.springbootredislua.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
@RestController
@RequestMapping("/redisLua")
public class RedisLuaController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private DefaultRedisScript redisScript;
/**
* eval script number <key> <args>
* 整合中没有number会根据key的集合获取,多个参数用逗号隔开
*
* @return result
*/
@GetMapping("/doLua")
public String doLua() {
return (String) stringRedisTemplate.execute(
redisScript, Collections.singletonList("uuid"), "hello world"
);
}
}
测试地址:http://127.0.0.1:7001/redisLua/doLua
三、微信支付宝接口限流项目实战
微信支付宝API限流场景
获取腾讯云API调用凭证:
image-20230224131415588.png支付宝现金红包:
https://opensupport.alipay.com/support/knowledge/32233/201602456372
image-20230224131611800.png10tps:1秒钟只可以转10笔操作
接口API限流是非常常用的操作。
灵活的配置限流参数、高并发的执行限流。
Redis的Lua脚本能很好的执行API限流场景。
Redis的Lua脚本限流实战
-- 官方文档推荐,但是有缺陷
-- 错误情况1 KEY = pay-1 发生碰撞 其他的客户端写入一个相同的永久不过期的KEY 接口被永久拉黑
-- 错误情况2 Redis集群主从切换导致数据的丢失 产生了一个相同的永久不过期的KEY 接口被永久拉黑
-- 单笔转账到支付宝账户限制为 10TPS
-- 设置某个KEY=pay-1
-- 每次转账请求给KEY=pay-1计数加1,设置过期时间为1秒钟
-- 如果KEY=pay-1计数大于10,那么触发限流操作
-- KEYS[1] = pay-1 --谁访问的接口KEY
-- ARGV[1] = 1 --1秒钟
-- ARGV[2] = 10 --10TPS
-- return false 接口限流
-- return true 正常流量
-- 局部变量 visitTime pay-1
-- 局部变量 visitTime pay-1
local visitTime = redis.call('INCR', KEYS[1])
if visitTime == 1 then
redis.call('expire', KEYS[1], tonumber(ARGV[1]))
end
if visitTime > tonumber(ARGV[2]) then
-- 增加保护机制
local key_ttl = redis.call('ttl', KEYS[1])
if tonumber(key_ttl) == -1 then
redis.call('del', KEYS[1])
end
return false
else
return true
end
SpringBoot的Lua脚本限流实战
第一步:在resources目录下新建Pay-right-1.lua文件
-- 局部变量 visitTime pay-1
local visitTime = redis.call('INCR', KEYS[1])
if visitTime == 1 then
-- 如果是第一次访问 设置这个key的超时时间
redis.call('expire', KEYS[1], tonumber(ARGV[1]))
end
-- 如果接口访问超过最大值
if visitTime > tonumber(ARGV[2]) then
-- 增加保护机制,防止有客户端把key变为永久
local key_ttl = redis.call('ttl', KEYS[1])
if tonumber(key_ttl) == -1 then
redis.call('del', KEYS[1])
end
return false -- 返回false 接口被限流
else
return true -- 返回true 接口放行
end
第二步:新建DefaultRedisScript单独的Bean
@Configuration
public class LuaConfig {
/**
* 前面测试的配置
*/
@Bean
public DefaultRedisScript<String> redisScript() {
DefaultRedisScript<String> defaultRedisScript = new DefaultRedisScript<>();
//设置返回类型
defaultRedisScript.setResultType(String.class);
//defaultRedisScript.setScriptText();
defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("Demo.lua")));
return defaultRedisScript;
}
/**
* 限流的Lua脚本的配置
*/
@Bean
public DefaultRedisScript<Boolean> redisScriptPay() {
DefaultRedisScript<Boolean> defaultRedisScript = new DefaultRedisScript<>();
//设置返回类型
defaultRedisScript.setResultType(Boolean.class);
//defaultRedisScript.setScriptText();
defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("Pay-right-1.lua")));
return defaultRedisScript;
}
}
第三步:创建 支付宝API流控测试类
@RestController
@RequestMapping("/payApi")
public class PayApiController {
@Autowired
@Qualifier("redisScriptPay")
private DefaultRedisScript<Boolean> redisScriptPay;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* eval script number <key> <args>
*/
@RequestMapping("/pay")
public Boolean pay() {
//key ="pay-1" 1 时间 5000 限流最大值
Boolean result = stringRedisTemplate.execute(redisScriptPay, Collections.singletonList("pay-1"), "1", "4000");
if (Boolean.FALSE.equals(result)) {
System.out.println(new Date() + " " + false);
}
return result;
}
}
测试地址:http://127.0.0.1:7001/payApi/pay
如果想压测则使用JMeter
Redis的Lua脚本实战2
-- 支付宝API限流 10TPS
local pay_api_key = KEYS[1] -- pay-1
local ttl = tonumber(ARGV[1]) -- 过期时间1秒钟
local max_value = tonumber(ARGV[2]) --最大访问量10
local current_value = redis.call('get', pay_api_key)
-- 值不存在代表第一次访问,则set值然后放行
if not current_value then
redis.call('set', pay_api_key, 1, 'NX', 'EX', ttl)
return true;
else
-- 如果有值,则拿到值加1
local current_value_add = tonumber(current_value) + 1
-- 获取当前key的过期时间单位毫秒
local current_value_number_pttl = redis.call("pttl", pay_api_key)
-- 如果当前key没有过期
if current_value_number_pttl > 0 then
-- 则把新值set到key中,并把过期的毫秒值也set回去,类似于修改
redis.call('set', pay_api_key, current_value_add, 'XX', 'PX', current_value_number_pttl)
else
-- key不存在或者过期时间为 0 , -1 ,-2则删除
redis.call('del', pay_api_key)
end
-- 判断值是否大于最大访问量
if tonumber(current_value_add) >= max_value then
return false
else
return true
end
end
微信支付宝接口限流项目讨论
Redis的Lua脚本开发的时候
- 临时性键值的永久性异常【出现的概率小,一旦出现就是大Bug】
场景介绍
- INCR操作临时KEY保护机制
- SET操作临时KEY保护机制
结论 操作临时KEY都需要考虑保护机制
四、黑客接口防刷项目实战
黑客接口防刷场景描述
接口保护机制
- 要求同一个IP地址10秒内访问不超过20次
- 如果超过访问频率,加入黑名单3天时间
技术方案
使用Redis的Lua脚本解决黑客接口防刷场景
Redis的Lua脚本防刷实战
-- 要求同一个IP10秒内不能访问超过20次 超过则就加入黑名单三天时间
-- 计数功能
-- return false 黑名单
-- return ture 不在黑名单
local key_ip = KEYS[1] -- api-ip 访问计数KEY
local key_black_name = KEYS[2] --api-black_ip 黑名单的KEY
local argv_ttl = ARGV[1] --10S
local argv_max_count = ARGV[2] --20 最大访问次数
local argv_black_ttl = ARGV[3] --3天 24*3600*3=259200
local default_black_value = 1 --黑名单的值
--判断是不是在黑名单里
local black_value = redis.call('GET', key_black_name)
-- 不在黑名单
if not black_value then
--局部变量 visitTime pay-1
local visitTime = redis.call('INCR', key_ip)
if visitTime == 1 then
-- 如果是第一次访问 设置这个key的超时时间
redis.call('EXPIRE', key_ip, tonumber(argv_ttl))
end
--如果接口访问超过最大值
if visitTime > tonumber(argv_max_count) then
--设置黑名单
redis.call('SET', key_black_name, default_black_value, 'EX', tonumber(argv_black_ttl))
-- 此处为保护机制
local ttl = redis.call('TTL', KEYS[1])
if tonumber(ttl) == -1 then
redis.call('DEL', KEYS[1])
end
return false --返回false IP被拉黑
else
return true
end
else
return false
end
SpringBoot Redis Lua防刷实战
第一步:创建Api-brush-protection.lua脚本在Resources目录下面
-- 要求同一个IP10秒内不能访问超过20次 超过则就加入黑名单三天时间
-- 计数功能
-- return false 黑名单
-- return ture 不在黑名单
local key_ip = KEYS[1] -- api-ip 访问计数KEY
local key_black_name = KEYS[2] --api-black_ip 黑名单的KEY
local argv_ttl = ARGV[1] --10S
local argv_max_count = ARGV[2] --20 最大访问次数
local argv_black_ttl = ARGV[3] --3天 24*3600*3=259200
local default_black_value = 1 --黑名单的值
--判断是不是在黑名单里
local black_value = redis.call('GET', key_black_name)
-- 不在黑名单
if not black_value then
--局部变量 visitTime pay-1
local visitTime = redis.call('INCR', key_ip)
if visitTime == 1 then
-- 如果是第一次访问 设置这个key的超时时间
redis.call('EXPIRE', key_ip, tonumber(argv_ttl))
end
--如果接口访问超过最大值
if visitTime > tonumber(argv_max_count) then
--设置黑名单
redis.call('SET', key_black_name, default_black_value, 'EX', tonumber(argv_black_ttl))
-- 此处为保护机制
local ttl = redis.call('TTL', KEYS[1])
if tonumber(ttl) == -1 then
redis.call('DEL', KEYS[1])
end
return false --返回false IP被拉黑
else
return true
end
else
return false
end
第二步:在LuaConfig中添加Bean
/**
* 黑客接口防刷Lua配置
*/
@Bean
public DefaultRedisScript<Boolean> apiBrushProtection() {
DefaultRedisScript<Boolean> defaultRedisScript = new DefaultRedisScript<>();
//设置返回类型
defaultRedisScript.setResultType(Boolean.class);
//defaultRedisScript.setScriptText();
defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("Api-brush-protection.lua")));
return defaultRedisScript;
}
第三步:创建接口
@RestController
@RequestMapping("/apiBrushProtection")
public class ApiBrushProtectionController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
@Qualifier("apiBrushProtection")
private DefaultRedisScript<Boolean> apiBrushProtection;
@RequestMapping("/api")
public Boolean lua() {
Boolean result = stringRedisTemplate.execute(apiBrushProtection, Arrays.asList("api-127.0.0.1", "api-black-127.0.0.1"),
"10", "20", "259200");
if (Boolean.FALSE.equals(result)) {
System.out.println(false);
}
return result;
}
}
黑客接口防刷场景优化讨论
高频场景:
- 优惠券场景
- 秒杀场景
接口保护机制
- 要求同一个IP地址10秒内访问不超过20次
- 如果超过访问频率,加入黑名单3天时间
使用IP地址不是很好的选择,如果一个公司有几万人,对外部服务而言就有可能就几个IP,如果这个几万人同时去抢你的优惠券或者秒杀活动,这几个IP会被瞬间封掉。
优化方案:
- 多人共用同一个IP地址可能会误伤
- 很多黑客可以伪造IP地址
访问使用唯一键来区别,比如登录性调用接口使用UserId来区分。如果还是要使用IP来操作,要考虑上述场景和IP的白名单。
网友评论