使用Lua Script简化Redis的访问

  之前跟公司的总监聊过一段,他的一席话还是挺中肯的:其实我们大多数的工程师,所要攻克的内容也不是什么高深的算法,难以攻克的难题,而只是完成特定功能的普通服务、普通工具,我们要做的就是把服务做稳定,性能上满足公司发展的需求,不要因为我们技术的原因被客户投诉,被老板指责就好;即使是因为合作方的原因导致的事故,也要有理有据,不要因为技术人员老实就专做其他部门的背锅侠;如果在合作方出现问题的时候,我们通过各种途径去减少甚至避免别人的故障带给我们系统和业务的影响,那就比完成工作更上了一个台阶,是把事情做好的境界了。在无法避免合作方事故的时候,我们系统受影响的投诉率趋于不断减少的收敛范围,这才是领导最喜欢看到的结果。
  其实,支付行业中的打款服务,也算是公司所有服务中业务逻辑最简单清晰的部分,但因为需要同外网各个结算通道方交互,所以也较容易受到对端不可控因素的影响。路由服务算算是整个系统中十分重要的部分,其不仅关系到费率成本,也关系到速率、成功率、稳定性等各项指标,并最终影响结算服务的质量。而且,业务逻辑是相对死板的,但是路由服务算是可玩性最强的环节,所以最近个人一直在做打款系统路由服务的优化,主要是性能的提升,以及根据服务运行指标进行自动化的流控机制。
  通过监控系统,发现一条路由请求的处理响应时间是12ms-15ms。因为最初设计实现的时候各项统计数据是完全放到Redis中的,这样就可以实现路由服务的无状态性,方便了后续服务更新和分布式部署,但是放到Redis中的缺点就是每次路由都需要进行数据的获取和更新,而且中间环节必须串行化执行,所以上面的响应指标意味着最高只能支持70~80TPS,业务量再增长一段时间打款服务就会成为整个系统的瓶颈所在,优化工作还是很有必要的。当前的一个着眼点就是通过Redis的访问优化获得路由性能的提升。
  首先是对Hash容器类型中的数据,实现组织好键参数,通过HMGET方式进行聚合获取,对返回的ARRAY结果进行解析可以减少多次访问Redis的TTL,因为Redis原生支持这种指令,所以此处不表;另外的一点优化是通过Redis Script的方式实现的,这也是本文接下来需要描述的内容。

一、Redis中Lua的使用

1.1 EVAL使用方式

  Redis从2.6.0版本开始通过built-in Lua interpreter支持了运行脚本的功能,主要是通过EVAL和EVALSHA命令,也附带其他的一些辅助性命令。
  通过EVAL执行脚本的时候,其内容是直接需要执行的Lua语句,紧邻其后的是一个整数,表明需要传递给脚本的参数(argument)中key name的个数,他们可以通过KEY[1]、KEY[2]……这种形式访问;剩余的所有参数被认为和Redis key无关,他们可以通过ARGV[1]、ARGV[2]……的方式访问,其形如:

1
> EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 key1 key2 first second

  脚本中的逻辑操作即是普通的Lua编程,当脚本需要访问Redis数据的时候,可以通过redis.call()和redis.pcall()这两个Lua函数来实现。他们俩的使用方式相同,区别在于出现错误的时候,前者会引发一个Lua error强制EVAL返回一个错误给命令的调用者,而后者会捕获这个错误,然后通过返回Lua table组织的错误信息,调用者看来就是redis.pcall返回的错误信息更加的人性化、好理解(一个Lua table带有err字段)。上面看到将arguments分割成KEYS和ARGS看似有些多此一举,甚至有些时候可以直接将KEYS和ARGS嵌入到脚本内容当中去,其实这种分割KEYS和ARGS是一种使用上的约定,尤其在Redis Cluster上集群需要根据KEYS来将命令转发到实际的机器上时候会比较有用。
  在Redis中通过Lua访问table有两个需要注意的地方:table的索引是从1开始的;table中的元素中间不能插入nil,否则nil后续的所有元素会被丢弃truncated。
  Redis内部会使用同一个interpreter去执行所有的命令,他们会被当作同Redis内置的普通命令一样被执行,同时Redis是一个单进程单线程的服务,所以可以轻易保证脚本是原子串行化的执行:Redis在依次执行脚本中每条语句的时候,是不会响应任何其他的Redis请求的,所以一些逻辑简单但是需要同步保护的语句,完全可以丢到Lua脚本中去原子执行就可以了,不需要任何额外的应用程序或者Redis的同步操作。不过也正是因为这个特性,脚本的内容不宜过长过复杂,否则会阻塞其他Redis的正常请求。
  在使用中,还有一点需要注意的就是横跨Lua、Redis、Hiredis三者的数据类型的转换关系,列表如下:

Lua Redis Hiredis
number integer REDIS_REPLY_INTEGER r->integer
bulk string REDIS_REPLY_STRING r->str, r->len
multi bulk table REDIS_REPLY_ARRAY r->elements, r->element
status table[ok] REDIS_REPLY_STATUS r->str, r->len
error table[err] REDIS_REPLY_ERROR r->str, r->len
false nil REDIS_REPLY_NIL

1.2 EVALSHA使用方式

  在Redis中除了EVAL还有一个EVALSHA命令。因为EVAL命令每次执行的时候都需要将脚本的内容一遍一遍的发送给Redis,虽然Redis内部机制保证不会一次又一次的重新加载编译这个脚本,它会自动使用内部缓存机制保存编译后的结果,但是会额外的占用网络的带宽。
  通过EVALSHA的方式,可以只发送脚本的SHA1摘要值,就可以获取和原先EVAL一样的执行效果,而好处是减少了网络通信的开销。Redis收到EVALSHA传递过来的SHA1时候,如果在内部缓存中找到了对应脚本的编译结果,则直接执行之,否则报NOSCRIPT错并告知使用EVAL的方式执行脚本。同时,使用EVALSHA的时候将KEYS和ARGS从脚本内容中独立出来的好处也是显而易见的,这样脚本的内容就是一个常量了,可以很好的被Redis的内部缓存。
  通过SCRIPT LOAD可以加载一个脚本,然后返回脚本对应的SHA1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int RedisConn::load_script(const std::string& script, std::string& sha) {
int ret_code = 0;
try {
do {
redisReply_ptr r = exec("SCRIPT LOAD %s ", script.c_str());
if (!r || r->type == REDIS_REPLY_ERROR) {
log_error("get redis resp error!");
if (r) {
std::string msg = std::string( r->str, r->len );
log_error("Error Info: %s", msg.c_str());
}
ret_code = -2; break;
}
if (r->type != REDIS_REPLY_STRING || !r->len) {
log_error("parse redis resp info error: %d, %d", r->type, r->len);
ret_code = -3; break;
}
sha = std::string( r->str, r->len );
log_alert("Success load with new sha: %s", sha.c_str());
} while (0);
} catch(...) {
log_error("Redis error!!!");
ret_code = -10;
}
return ret_code;
}

  通过SCRIPT EXISTS可以检查指定的SHA1是否可用,其返回1表示对一个的脚本已经被缓存,0表示对应的脚本不存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int RedisConn::check_sha(const std::string& sha, bool& sha_exist) {
int ret_code = 0;
try {
do {
redisReply_ptr r = exec("SCRIPT EXISTS %s", sha.c_str());
log_trace("SCRIPT EXISTS %s", sha.c_str());
if (!r || r->type == REDIS_REPLY_ERROR) {
log_error("get redis resp error!");
ret_code = -2; break;
}
if (r->type == REDIS_REPLY_ARRAY && r->elements >0 && r->element[0]->integer == 1) {
sha_exist = true;
log_alert("script %s exist!", sha.c_str());
} else if (r->type == REDIS_REPLY_ARRAY && r->elements >0 && r->element[0]->integer == 0) {
sha_exist = false;
log_error("script %s not exist!", sha.c_str());
} else {
log_error("Unknow response: %d", r->type);
ret_code = -3; break;
}
} while (0);
} catch(...) {
log_error("Redis error!!!");
ret_code = -10;
}
return ret_code;
}

  在Redis内部,EVAL执行的所有脚本都会被永久的缓存,其实在Redis看来这些脚本就新建的命令一样,和Redis内建的其他命令相同看待,通常来说EVAL的脚本数目都不会太多,所以这也不会导致内存方面的压力,只有在执行SCRIPT FLUSH或者重启Redis实例的时候,这些缓存才会被全部清除。所以我们可以假定一个执行的上下文中SHA1总是有效的(比如在一个pipeline、MULTI/EXEC中),只需要在开始的位置采用SCRIPT EXISTS确定脚本可用,接下来的脚本就无需处理NOSCRIPT异常,这样可以很大的简化编程对异常情况的处理。
  还有一点需要说明的是,发现CentOS官方库中的redis并不支持SCRIPTS这一族的命令,虽然按照其版本来应该是支持的,所以要想使用上述命令,需要自行编译安装Redis服务端。

二、C++中通过hiredis实现Redis Script优化

  在开发调试的时候,我们可以通过redis-cli –eval xxx的命令行方式传递并运行脚本,但是生产中是需要将其集成到服务当中的。C/C++的开发者通常使用hiredis库访问Redis服务,所以上面的命令完全可以转化成对应的hiredis调用。
  下面的这个例子中,将脚本的SHA1保存为static变量,然后运行的时候检测这个SHA1是否已经初始化有值了:如果没有初始化就执行SCRIPT LOAD加载之,并将结果保存在这个变量中;否则就执行EVALSHA运行之。如果运行成功(几乎是绝对情况)则万事大吉,否则将脚本的SHA1清空(等待下次重新加载),然后再用后备的普通Redis方式执行相应的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
int calc_item_miniute(const std::string& class_key, const std::string& item_key,
const std::string& stime, int64_t& count, int64_t& average) {
// KEYS, ARGV
static std::string lua_script =
" local items = redis.call(\"LRANGE\", KEYS[1], 0, -1) "
" if #items == 0 then "
" return {0, 0} end "
" local total_amount = 0 "
" for item in pairs(items) do "
" total_amount = total_amount + tonumber(item) "
" end "
" return {#items, (total_amount / #items)} ";
static std::string lua_sha = "";
redis_conn_ptr conn;
request_scoped_redis_conn(conn);
if (!conn) {
log_error("cannot get redis conn!");
return -1;
}
std::stringstream ss;
ss << REDIS_PREFIX << "_" << class_key << "_" << item_key << "_" << stime;
std::string check_key = ss.str(); // 千万不要 ss.str().c_str()这么用
try {
if (unlikely( lua_sha.empty() )) {
if (conn->load_script(lua_script, lua_sha) != 0)
log_error("Load lua_script failed!");
}
do {
if(likely( !lua_sha.empty() )) {
redisReply_ptr r = conn->exec("EVALSHA %s 1 %s", lua_sha.c_str(), check_key.c_str());
log_trace("EVALSHA %s 1 %s", lua_sha.c_str(), check_key.c_str());
if (!r || r->type == REDIS_REPLY_ERROR) {
log_error("get redis resp error!");
// 比如redis重启了,或者 script flush清除缓存等情况会导致sha失效
// 虽然此处可能会有竞争条件,但是这种极端情况暂时忽略不计
bool sha_exist = false;
if (conn->check_sha(lua_sha, sha_exist) == 0 && !sha_exist) {
lua_sha = "";
log_error("sha %s not exist, reset it for next time automatic reload!", lua_sha.c_str());
}
ret_code = -1; break;
}
if ( r->type != REDIS_REPLY_ARRAY || r->elements != 2 ) {
log_error("get redis resp check error: %d %d", r->type, static_cast<int>(r->elements));
ret_code = -1; break;
}
count = r->element[0]->integer;
average = r->element[1]->integer;
log_trace("for(sha) %s, len:%ld, avarge:%ld", check_key.c_str(), count, average);
return 0;
}
} while(0);
// fall through legacy calc method
return calc_item_miniute_legacy();
} catch(...) {
yk_api::log_error("Redis exception error!!!");
ret_code = -1;
}
return ret_code;
}
int calc_item_miniute_legacy(const redis_conn_ptr& conn, const std::string& real_key,
int64_t& count, int64_t& average) {
try {
redisReply_ptr r = conn->exec("LRANGE %s 0 -1", real_key.c_str());
log_trace("LRANGE %s 0 -1", check_key.c_str());
if (!r || r->type == REDIS_REPLY_ERROR) {
log_error("get redis resp error!");
return = -1;
}
if (r->elements == 0) {
log_trace("for(legacy) %s info empty, set default 0, 0", check_key.c_str());
count = 0; average = 0;
return 0;
}
int64_t sum = 0; count = r->elements;
for(size_t i = 0; i < r->elements; i++) {
std::string str_count(r->element[i]->str, r->element[i]->len);
sum += ::atoll(str_count.c_str());
}
average = sum / r->elements;
log_trace("for(legacy) %s, len:%ld, avarge:%ld", check_key.c_str(), count, average);
} catch(...) {
yk_api::log_error("Redis exception error!!!");
return -1;
}
return 0;
}

  总体而言,上面的操作觉得还是复杂了点,是不是自己写代码比较的多疑?其实脚本测试好了就应该当做没有问题,而且直接发脚本也是可以接受的,毕竟内网环境,而且我们日志流量要比数据流量大很多的。

  优化后的效果还是有的,监控系统显示每次请求的响应时间平均降到了大约3ms左右,也就是说性能提高了接近4~5倍,虽然大部分优化可能收益于聚合操作(哈哈,说明原来的代码就写的比较烂),但是Lua Script的优化还算是体验尝试到了。虽然优化是一个不断提升的过程,但以此看来即使公司业务每年翻番,保守估计当前系统撑个四五年也是没有问题的!

本文完!

参考