小记一个菜鸟程序员设计的邀请码功能中的一个bug引发的思考

偶然查库检查用户邀请码使用情况的时候,发现居然有重复的邀请码。。。

2018/05/25,这是个很正常的一天,除了京东白条出账催债这个很恼人的事情,大体也就没什么大事缠身的样子。

同事在检查用户邀请码使用情况的时候,突然叫道我,说了句很严重很致命的话:“怎么邀请码有重复的?”

咩话?!点解?!我记得邀请码设计是讨论之后敲定的方案,怎么会有重复的邀请码出现???

问题来了,当然还得确认确认是个什么毛病才行。。。

在本文进行下去之前,让我来说明一下当时的设计是个什么样的方案。

首先在邀请码的需求上,我们采用的是6位字符串,邀请码生成组合中有4位是字母,另外2位是数字。而在字母组合上,我们去除了邀请码中可能输入错误的字母OL,并将剩余的 24 个字母打乱组合成四个基础字母组。

随后遍历四组基础字母和数字,生成无重复的邀请码生成组合。

文字说明比较生硬,但经过上述处理后,产生的邀请码生成组合没有重复,可以看下面给出的范例:(注意观察,结合后面的内容思考这里埋的坑)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[
  "huax54",
  "huax56",
  "huax57",
  "huax58",
  ...
  "huac54",
  "huac56",
  "huac57",
  "huac58",
  ...
]

这只是第一步,因为全部组合的可能性只有 1118 个,完全不可能当作邀请码来使用(根本不够嘛),所以我们更激进的,直接用每个组合进行遍历生成一批邀请码。

明眼人应该还没看到这里就能想到上面的设计方案存在什么问题了,我先走个过程,来验证一下代码中是否存在问题。

在判断重复的时候,我们采用 Redis Set 的特性来排除重复。Redis Set 这一数据结构内部不允许出现重复,并且是无序的,所以能满足下面两点需求:

  1. 添加到 Set 内的数据不会有重复
  2. 随机取出邀请码并移出 Set

在实现的时候,我将上面产生的邀请码生成组合落库,通过状态字段过滤已用组合,实际产生的邀请码存入 Redis Set 中,每当 Set 的集合个数少于 10000 条,就生成新的邀请码丢入 Set。(埋坑)

在取邀请码的时候,用到 spop 取出,表示这个邀请码已用过,不再可用。(埋了个大坑!)

那么看到这里,我就来说一下上面的实现存在了什么问题:

首先,组合中有相互存在的字母,你有h我也有h,你有5我也有5,并没有保证生成组合两两间的完全去重。

其次,生成的条件只有一个,只要满足 6 位字符就是个合理的邀请码,这样一来就有可能出现多组都是hahhhh,或者555e55之类的邀请码。

上面提到的三个埋坑点,其实很大的原因是Redis Set内部元素一定唯一这一特性,使得我在开发过程中过度依赖了 Redis 特性,忽视了邀请码 pop 后的场景会产生的问题,以及没有注意到邀请码生成组合本身存在的缺陷。

埋坑点 1 虽然不是影响最严重的因素,但由于设置的阈值过低,很有可能已经使用过的邀请码早就被 pop 出 Set 了,没有元素重复判断的参照。

埋坑点 2 则是随机取出邀请码,这里用到了 spop,spop 做的事情是,返回一个随机的元素,并且将这个元素从 Set 中移除。由于元素移除后,Set 中不存在这个元素,从而会导致相同的元素能够成功进入 Set 中。这样的情景其实只是保证了 Redis Set 内的唯一性,但没有保证整个邀请码系统的邀请码唯一性。

然而上面的埋坑点都不是根源,要知道这些邀请码都是由数据库中的邀请码生成组合产生的,追根揭底还是要检查邀请码组合的构成。

最终我发现,如果邀请码生成组合内如果两两间存在相同的字符,在现有的生成方法中必定会出现像上面提到的hahhhh的情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 邀请码生成写入Redis的为代码
for (char c1 : chars) {
  for (char c2 : chars) {
    for (char c3 : chars) {
      for (char c4 : chars) {
        for (char c5 : chars) {
          for (char c6 : chars) {
            if (c1 == c2 && c1 == c3 && c1 == c4 && c1 == c5 && c1 == c6) {
              continue;
            }
            redis.sadd(INVITE_CODE_SET, "" + c1 + c2 + c3 + c4 + c5 + c6);
          }
        }
      }
    }
  }
}

再来看邀请码生成组合的生成方法

 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
for (int i = 0; i < letters.length - 3; i++) {
  for (int j = 1; j < letters.length - 2; j++) {
    for (int k = 2; k < letters.length - 1; k++) {
      for (int l = 3; l < letters.length; l++) {
        for (int x = 0; x < numbers.length - 1; x++) {
          for (int y = 1; y < numbers.length; y++) {
            if (letters[i] == letters[j] || letters[i] == letters[k]
                || letters[i] == letters[l]
                || letters[j] == letters[k] || letters[j] == letters[l]
                || letters[k] == letters[l]) {
              continue;
            }
            if (numbers[x] == numbers[y]) {
              continue;
            }
            InviteCodeGenerateGroup group = new InviteCodeGenerateGroup();
            group.setCharacters("" + letters[i] + letters[j] + letters[k] + letters[l]);
            group.setNumbers("" + numbers[x] + numbers[y]);
            group.setStatus(NORMAL);
            db.insert(group);
          }
        }
      }
    }
  }
}

这样的生成方式存在的缺陷已经很明显了,通过遍历数组的方式,仅在最里层遍历做去重,并不能做到有一个组合是hatc12,其他组合不能有这个组合中的任何字母的情况。