服务器端控制业务处理频率

服务器软件开发过程中, 会遇到某个接口频繁被同一用户请求的情况, 比如用户频繁点击发送验证码, 客户端的代码不严谨或者底层漏洞造成请求被连续多次执行, 控制某个 Ip 或者用户对某个资源的调用频率, 等等情况吧, 总之会造成数据的脏写, 抑或某些成本较高的资源被无意义重复调用等.

假设用户使用手机号注册发送验证码, 校验后初始化用户数据的需求, 我们假设 60s 内不允许用户重复发送验证码, 已经发送的验证码在 5 分钟内有效, 用户数据 UID 为自增 id.

初步设想, 我们这么搞就行了

我们定义 Redis 中数据结构如下:

  • 存储验证码发送时间 SET s:[$mobile]:times 1 EX 60
  • 存储验证码值 SET s:[$mobile]:code RANDSTR EX 300
  • 存储用户自增 UID 当前值 SET s:uid:index 0 NX
  • 存储用户数据 HMSET h:[$mobile]:userinfo uid 1 username "ONO"

简单描述一下获取验证码的步骤:

  1. 使用 Redis 的 String 类型 key 对同一用户进行标记
  2. 给该 key 一个过期时间表示一段时间内的调用
  3. 该 key 的值可以为调用该接口的次数
  4. 当该 key 存在且次数大于某个值得时候执行相应的拒绝逻辑
发送验证码
1
2
3
4
5
6
7
8
9
if (EXISTS s:[$mobile]:times) {
INCR s:[$mobile]:times
// do log warning: no reason resend, check client logic.
} else {
SET s:[$mobile]:times 1 EX 60
SET s:[$mobile]:code RANDSTR EX 300
$code = GET s:[$mobile]:code
// do send code via sms
}

下面是校验验证码的步骤:

  1. 将验证码传过来后, 与数据库中获取的验证码进行比较
  2. 比较不成功说明验证码输入错误, 或者验证码过期
  3. 验证成功, 那么从自增 ID 处自增一个 uid, 将其写入用户的 Hash 信息结构
校验验证码,初始化用户
1
2
3
4
5
6
7
if (GET s:[$mobile]:code === $code) {
// verify success, create user
$uid = INCR s:uid:index
HMSET h:[$mobile]:userinfo uid \$uid username "ONO"
} else {
// code expired, resend once please
}

此种情形当调用频率不是很高(50ms/次以上)的时候是非常方便的, 并且可以有效的防止短信这种较高成本资源频繁无意义调用.

思考一下有什么问题?

但是假设该接口不是人为手动点击, 而是使用高并发程序进行发包, 那么请求间隔就会很高(1e-3ms/次以上), 甚至并发执行.

此时就应该考虑并发过程中时间线的问题, 同样参考上面代码:

  1. 第一个请求到达判断 if 的时候, 向 redis 数据库发送请求EXISTS s:[$mobile]:times
  2. 此时第二个请求同样到达 if 请求, 发送EXISTS s:[$mobile]:times到 redis
  3. 此时 redis 没有数据, 为两个请求均返回了 false, 回到代码逻辑向下执行, 两个请求都走 else 逻辑
  4. 第一个请求成功执行SET s:[$mobile]:times 1 EX 60, 而第二个请求同样成功执行SET s:[$mobile]:times 1 EX 60
  5. 第一个请求将 RANDSTR1 成功写入s:[$mobile]:code, 并且从 redis 中又获取到了该验证码, 向客户端发送验证码 RANDSTR1
  6. 第二个请求随后将 RANDSTR2 成功写入s:[$mobile]:code, 造成脏写, 随后从中获取到了该验证码 RANDSTR2, 发送给客户的用户
  7. 此时用户拥有两个验证码, 并且第一个验证码 RANDSTR1 无效.

修正代码逻辑, 解决重复发送验证码的漏洞

修改数据结构:

存储验证码发送时间 SET s:[$mobile]:times 1 EX 60 NX

Note: NX 的重要性, 只有该键不存在时才能成功写入

改动代码逻辑:

发送验证码
1
2
3
4
5
6
7
8
9
10
11
if (EXISTS s:[$mobile]:times) {
INCR s:[$mobile]:times
// do log warning: no reason resend, check client logic.
} else {
$ok = SET s:[$mobile]:times 1 EX 60 NX
if ($ok === "OK") {
SET s:[$mobile]:code RANDSTR EX 300
$code = GET s:[$mobile]:code
// do send code via sms
}
}

根据以上的判断, 解决并发情况下重复脏写验证码, 而且发送多条验证码给用户的问题.

思考一下是不是校验的时候有一样的问题?

Bingo! 是不是生成验证码的接口问题, 在校验验证码的接口逻辑中同样存在!

生成验证码是发送了错误验证码, 校验验证码生成用户数据则多次初始化了数据, 并且数据的 UID 多次被更新, 只有最后一次的 UID 有效.

采用同样的手段修改一下:

增加数据结构手机号与 UID 的对应关系: SET s:[$mobile]:uid 1 NX

Note: NX 的作用非比寻常, 在不存在该键的时候写入成功, 存在则返回失败.

校验验证码,初始化用户
1
2
3
4
5
6
7
8
9
10
11
if (GET s:[$mobile]:code === $code) {
// verify success, create user
$uid = INCR s:uid:index
// 动动脑: 这里为什么引入了$ok, 而不直接使用 EXISTS h:[$mobile]:userinfo 呢?
$ok = SET s:[$mobile]:uid $uid NX
if ($ok) {
HMSET h:[$mobile]:userinfo uid \$uid username "ONO"
}
} else {
// code expired, resend once please
}

一切都很完美了么?

No, No, No! 上面的方案只是解决了用户 UID 不被覆盖赃写的问题, 虽然已接近完美, 但是仍有瑕疵~

因为s:uid:index被无意义的自增了许多次. 当前s:uid:index值已经不代表用户量总数了.

有什么解决方案吗? 当然!

支持原子特性的 Lua 隆重出场!

校验验证码,初始化用户的Lua脚本
1
2
3
4
5
6
7
8
9
// ARGV[1] $mobile  ARGV[2] $code
code = redis.call("GET", "s:[" .. ARGV[1] .. "]:code")
exists = redis.call("EXISTS", "h:[" .. ARGV[1] .. "]:userinfo")
if (code == ARGV[2] && !exists)
then
uid = redis.call("INCR", "s:uid:index")
redis.call("HMSET", "h:[" .. ARGV[1] .. "]:userinfo", "uid", uid, "username", "ONO")
end
}
调用Lua脚本
1
eval str_lua_code 0 $mobile $code

上面的写法, 由于 Redis 的 Lua 会进行 Key 的执行前检测, 效率没有下面的高:

依据官文正确使用 KEYS

校验验证码,初始化用户的Lua脚本
1
2
3
4
5
6
7
8
9
// KEYS[1] h:[$mobile]:code KEYS[2] h:[$mobile]:userinfo ARGV[1] $code ARGV[2] $username
code = redis.call("GET", KEYS[1])
exists = redis.call("EXISTS", KEYS[2])
if (code == ARGV[1] && !exists)
then
uid = redis.call("INCR", "s:uid:index")
redis.call("HMSET", KEYS[2], "uid", uid, "username", ARGV[2])
end
}
调用Lua脚本
1
eval str_lua_code 2 h:[$mobile]:code h:[$mobile]:userinfo \$code "ONO"

最后留一个思考题

为什么在 Lua 脚本的执行过程中, 没有使用前面的手机号与 UID 的对应关系 - s:[$mobile]:uid, 而是直接使用了 EXISTS 来判断? 为什么前面不直接使用 EXISTS 来判断?

本文问题来自一个运维朋友, 整体思路在问题讨论之中渐渐形成, 遂整理之, 疏漏之处欢迎指教.

Donate - Support to make this site better.
捐助 - 支持我让我做得更好.