服务器软件开发过程中, 会遇到某个接口频繁被同一用户请求的情况, 比如用户频繁点击发送验证码, 客户端的代码不严谨或者底层漏洞造成请求被连续多次执行, 控制某个 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"
简单描述一下获取验证码的步骤:
- 使用 Redis 的 String 类型 key 对同一用户进行标记
- 给该 key 一个过期时间表示一段时间内的调用
- 该 key 的值可以为调用该接口的次数
- 当该 key 存在且次数大于某个值得时候执行相应的拒绝逻辑
1 | if (EXISTS s:[$mobile]:times) { |
下面是校验验证码的步骤:
- 将验证码传过来后, 与数据库中获取的验证码进行比较
- 比较不成功说明验证码输入错误, 或者验证码过期
- 验证成功, 那么从自增 ID 处自增一个 uid, 将其写入用户的 Hash 信息结构
1 | if (GET s:[$mobile]:code === $code) { |
此种情形当调用频率不是很高(50ms/次以上)的时候是非常方便的, 并且可以有效的防止短信这种较高成本资源频繁无意义调用.
思考一下有什么问题?
但是假设该接口不是人为手动点击, 而是使用高并发程序进行发包, 那么请求间隔就会很高(1e-3ms/次以上), 甚至并发执行.
此时就应该考虑并发过程中时间线的问题, 同样参考上面代码:
- 第一个请求到达判断 if 的时候, 向 redis 数据库发送请求
EXISTS s:[$mobile]:times
- 此时第二个请求同样到达 if 请求, 发送
EXISTS s:[$mobile]:times
到 redis - 此时 redis 没有数据, 为两个请求均返回了 false, 回到代码逻辑向下执行, 两个请求都走 else 逻辑
- 第一个请求成功执行
SET s:[$mobile]:times 1 EX 60
, 而第二个请求同样成功执行SET s:[$mobile]:times 1 EX 60
- 第一个请求将 RANDSTR1 成功写入
s:[$mobile]:code
, 并且从 redis 中又获取到了该验证码, 向客户端发送验证码 RANDSTR1 - 第二个请求随后将 RANDSTR2 成功写入
s:[$mobile]:code
, 造成脏写, 随后从中获取到了该验证码 RANDSTR2, 发送给客户的用户 - 此时用户拥有两个验证码, 并且第一个验证码 RANDSTR1 无效.
修正代码逻辑, 解决重复发送验证码的漏洞
修改数据结构:
存储验证码发送时间 SET s:[$mobile]:times 1 EX 60 NX
Note: NX 的重要性, 只有该键不存在时才能成功写入
改动代码逻辑:
1 | if (EXISTS s:[$mobile]:times) { |
根据以上的判断, 解决并发情况下重复脏写验证码, 而且发送多条验证码给用户的问题.
思考一下是不是校验的时候有一样的问题?
Bingo! 是不是生成验证码的接口问题, 在校验验证码的接口逻辑中同样存在!
生成验证码是发送了错误验证码, 校验验证码生成用户数据则多次初始化了数据, 并且数据的 UID 多次被更新, 只有最后一次的 UID 有效.
采用同样的手段修改一下:
增加数据结构手机号与 UID 的对应关系: SET s:[$mobile]:uid 1 NX
Note: NX 的作用非比寻常, 在不存在该键的时候写入成功, 存在则返回失败.
1 | if (GET s:[$mobile]:code === $code) { |
一切都很完美了么?
No, No, No! 上面的方案只是解决了用户 UID 不被覆盖赃写的问题, 虽然已接近完美, 但是仍有瑕疵~
因为s:uid:index
被无意义的自增了许多次. 当前s:uid:index
值已经不代表用户量总数了.
有什么解决方案吗? 当然!
支持原子特性的 Lua 隆重出场!
1 | // ARGV[1] $mobile ARGV[2] $code |
1 | eval str_lua_code 0 $mobile $code |
上面的写法, 由于 Redis 的 Lua 会进行 Key 的执行前检测, 效率没有下面的高:
依据官文正确使用 KEYS
1 | // KEYS[1] h:[$mobile]:code KEYS[2] h:[$mobile]:userinfo ARGV[1] $code ARGV[2] $username |
1 | eval str_lua_code 2 h:[$mobile]:code h:[$mobile]:userinfo \$code "ONO" |
最后留一个思考题
为什么在 Lua 脚本的执行过程中, 没有使用前面的手机号与 UID 的对应关系 - s:[$mobile]:uid
, 而是直接使用了 EXISTS 来判断? 为什么前面不直接使用 EXISTS 来判断?
本文问题来自一个运维朋友, 整体思路在问题讨论之中渐渐形成, 遂整理之, 疏漏之处欢迎指教.