文章目录
- 1.需求描述
- 2.需求分析
- 3.字符集
- 4.方法一:随机数 唯一性判断(不可逆)
- 5.方法二:Hash 唯一性判断(不可逆)
- 6.方法三:进制法(可逆)
- 7.方法四:进制法 扩散、混淆(可逆)
- 8.小结
- 参考文献
1.需求描述
有一个业务需求,需要根据用户 ID(数值型 >=10000000)生成一个唯一的长 6 个字符的邀请码,用于邀请新用户注册。
2.需求分析
从业务需求和一般产品邀请码的使用体验上来看,邀请码有以下几个特点:
- 不可重复:不用用户 ID 生成的邀请码是不同的;
- 唯一确定:一个用户 ID 只能生成一个邀请码;
- 是否可逆:是否需要通过邀请码反推对应的用户 ID 是什么。
本文将以 Golang 为例,给出根据用户 ID 生成唯一且不重复的邀请码的常见方法与实现示例。
3.字符集
首先需要确定组成邀请码的字符集,一般采用数字和英文大小写字母共计 62 个字符。如果长度时 6 的邀请码,那么空间大小 62^6 = 56,800,235,584,这是一个非常大的空间,足够用户量为亿级别的业务使用。
代码语言:javascript复制// AlphanumericSet 字母数字集
var AlphanumericSet = []rune{
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
}
当然,为什么提升用户体验,可以把 O、o、0、I、1 这五个形似易混淆的字符去掉,本文就不去了。
4.方法一:随机数 唯一性判断(不可逆)
使用用户 ID 作为种子初始化随机数发生器,随机生成字符集下标,取出对应的字符拼接成邀请码。
注意,这里会存在生日问题,随着已生成的邀请码数量的上升,发生碰撞的概率将会大大增加。
如果生成 100W 个邀请码,假设前 100W 一个都不重复,那么下一个重复的概率是((1/62)^6 * 100W)≈1/5.6W
,冲突率已经到了在万分之一的概率,远大于直觉(1/62)^6
。
// GetInvCodeByUID 获取指定长度的邀请码
func GetInvCodeByUID(uid uint64, l int) string {
r := rand.New(rand.NewSource(int64(uid)))
var code []rune
for i := 0; i < l; i {
idx := r.Intn(len(AlphanumericSet))
code = append(code, AlphanumericSet[idx])
}
return string(code)
}
fmt.Println(GetInvCodeByUID(100000000, 6)) // uGkK9K
fmt.Println(GetInvCodeByUID(100000001, 6)) // UPFlHa
fmt.Println(GetInvCodeByUID(100000002, 6)) // keKZ01
缺点:
- 唯一性判断引入第三方组件,增加依赖,降低了可靠性;
- 在 >=99.99% 的情况下,唯一性判断都是通过的,浪费存储资源。
5.方法二:Hash 唯一性判断(不可逆)
对用户 ID 做 Hash(如 MD5)运算,获取散列值后取散列值的多个字节映射到字符集,然后组成邀请码。因为可能发生碰撞,所以需要做唯一性判断,比如借助 DB(Redis)来判断。
代码语言:javascript复制// GetInvCodeByUID 获取指定长度的邀请码
func GetInvCodeByUID(uid string, l int) string {
// 因为 md5 值为 16 字节
if l > 16 {
return ""
}
sum := md5.Sum([]byte(uid))
var code []rune
for i := 0; i < l; i {
idx := sum[i] % byte(len(AlphanumericSet))
code = append(code, AlphanumericSet[idx])
}
return string(code)
}
// 示例
fmt.Println(GetInvCodeByUID("100000000", 6)) // btCcwX
fmt.Println(GetInvCodeByUID("100000001", 6)) // sxQ1mq
fmt.Println(GetInvCodeByUID("100000002", 6)) // swA3pk
缺点:
- 唯一性判断引入第三方组件,增加依赖,降低了可靠性;
- 在 >=99.99% 的情况下,唯一性判断都是通过的,浪费存储资源。
我们可以写一个单测来看下长度为 6 的邀请码,在字符空间为 62 ,一千万用户量下的碰撞率。
代码语言:javascript复制func TestGetInvCodeByUID(t *testing.T) {
sUID, eUID := 10000000, 20000000
var md5ConCnt int // md5 前 6 字节冲突次数
var codeConCnt int // 邀请码冲突次数
mHash := make(map[uint64]struct{})
mCode := make(map[string]struct{})
// 统计 1KW 个用户ID生成邀请码发生碰撞的次数
t.Run("getConflictNumTestCase", func(t *testing.T) {
for uid := sUID; uid < eUID; uid {
sum := md5.Sum([]byte(strconv.Itoa(uid)))
md5Value := uint64(sum[5]) | uint64(sum[4])<<8 | uint64(sum[3])<<16 | uint64(sum[2])<<24 |
uint64(sum[1])<<32 | uint64(sum[0])<<40
if _, ok := mHash[md5Value]; ok {
md5ConCnt
codeConCnt
continue
}
mHash[md5Value] = struct{}{}
code := GetInvCodeByUID(strconv.Itoa(uid), 6)
if _, ok := mCode[code]; ok {
codeConCnt
continue
}
mCode[code] = struct{}{}
}
if md5ConCnt != 0 || codeConCnt != 0 {
t.Errorf("md5ConCnt=%v, codeConCnt=%v conRate=%v", md5ConCnt, codeConCnt, float64(codeConCnt)/float64(eUID-sUID))
}
})
}
执行单测命令go test -run TestGetInvCodeByUID$
--- FAIL: TestGetInvCodeByUID (13.01s)
--- FAIL: TestGetInvCodeByUID/getConflictNumTestCase (13.01s)
main_test.go:35: md5ConCnt=0, codeConCnt=900 conRate=9e-05
FAIL
exit status 1
FAIL test 13.331s
可见前 6 个字节的散列值没有发生冲突,但是冲突率还是比较高的,在万分之一的级别。这种方式产生碰撞的原因是:虽然每个字节是不同值,但是对字符集大小取模后可能会相同,所以就有可能出现碰撞。随着用户量的增加,这里的碰撞概率会越来越高。
降低冲突率的办法是增加邀请码的空间,有两个办法:
- 增加生成邀请码的字符空间;
- 增加邀请码的长度。
6.方法三:进制法(可逆)
用户 ID 是唯一的,生成一个唯一的邀请码也是理所当然的。
因为我们的用户ID是一个数值,可以将其看作是一个 62 进制的数,每一位的值范围是 0~61,类似于 10 进制数的每一位的范围是 0~9,取 62 进制数位的每一位作为字符集的下标,高位用 0 补全,这样我们便可以采用除法取整与取模来实现。
代码语言:javascript复制// GetInvCodeByUIDUnique 获取指定长度的邀请码
func GetInvCodeByUIDUnique(uid uint64, l int) string {
var code []rune
for i := 0; i < l; i {
idx := uid % uint64(len(AlphanumericSet))
code = append(code, AlphanumericSet[idx])
uid = uid / uint64(len(AlphanumericSet)) // 相当于右移一位(62进制)
}
return string(code)
}
// 示例
fmt.Println(GetInvCodeByUIDUnique(100000000, 6)) // ezAL60
fmt.Println(GetInvCodeByUIDUnique(100000001, 6)) // fzAL60
fmt.Println(GetInvCodeByUIDUnique(100000002, 6)) // gzAL60
缺点:
- 连续用户ID生成的邀请码也是连续的,用户易输错;
- 连续用户ID生成的邀请码也是连续的,规律性强,可以反推用户ID。
如果业务场景对这两个缺点可以接受的话,那么这个方法就足够用了。
7.方法四:进制法 扩散、混淆(可逆)
扩散 (Diffusion) 和混淆 (Confusion) 是 C. E. Shannon 提出的设计密码体制的两种基本方法,其目的是为了抵抗坏人对密码的统计分析。在分组密码的设计中,充分利用扩散和混淆,可以有效地抵抗坏人从密文的统计特性推测明文或密钥。扩散和混淆是现代分组密码的设计基础。
所谓扩散就是让明文中的每一位影响密文中的许多位,或者说让密文中的每一位受明文中的许多位的影响。这样可以隐蔽明文的统计特性。当然,理想的情况是让明文中的每一位影响密文中的所有位,或者说让密文中的每一位受明文中所有位的影响。
所谓混淆就是将密文与密钥之间的统计关系变得尽可能复杂,使得对手即使获取了关于密文的一些统计特性,也无法推测密钥。使用复杂的非线性代替变换可以达到比较好的混淆效果,而简单的线性代替变换得到的混淆效果则不理想。
使用扩散和混淆的方式可以对进制法进行改进。
如何扩散呢?
可以将个位和其它每一位作和后取余,即可把个位的变化传导到每一位。为了使结果看起来更随机,可以给每一位分配不同系数。
代码语言:javascript复制// GetInvCodeByUID 获取指定长度的邀请码
func GetInvCodeByUID(uid uint64, l int) string {
var code []rune
slIdx := make([]byte, l)
for i := 0; i < l; i {
slIdx[i] = byte(uid % uint64(len(AlphanumericSet))) // 获取 62 进制的每一位值
idx := (slIdx[i] byte(i)*slIdx[0]) % byte(len(AlphanumericSet)) // 其他位与个位加和再取余(让个位的变化影响到所有位)
code = append(code, AlphanumericSet[idx])
uid = uid / uint64(len(AlphanumericSet)) // 相当于右移一位(62进制)
}
return string(code)
}
// 示例
fmt.Println(GetInvCodeByUIDUniqueNew(100000000, 6)) // eN2r08
fmt.Println(GetInvCodeByUIDUniqueNew(100000001, 6)) // fO4u4d
fmt.Println(GetInvCodeByUIDUniqueNew(100000002, 6)) // gP6x8i
从示例结果可以看出,相邻的用户ID对应的邀请码虽然不是连续的,但是每一位还是有很强的规律,左起第一位间隔一,第二位间隔二,第三位间隔三,以此类推。
如何隐藏这些规律呢?
我们可以对用户ID进行变换,比如放大或者加盐。
放大可以对用户ID乘以一个与 62 互质的数,比如 3。这是因为根据循环群的性质:若 m 和 p 互质,则 m 可以作为整数同余加法群 [0, p) 的生成元,通过累加取模运算生成整个群,即 ( id * m ) % p 的结果包含 [0, p) 的所有整数,这保证了放大后结果的分布和原数据的分布同样均匀。
举个例子: 如果 p = 3,那么 [0, p) = {0, 1, 2},2 与 3 互质,那么我们可以通过 2 这个生成元生成整个群。
代码语言:javascript复制(0*2) % 3 = 0 % 3 = 0
(1*2) % 3 = 2 % 3 = 2
(2*2) % 3 = (2 2) % 3 = 1
(3*2) % 3 = (2 2 2) % 3 = 0
(4*2) % 3 = (2 2 2 2) % 3 = 2
(5*2) % 3 = (2 2 2 2 2) % 3 = 1
...
右侧的余数便组成了一个 3 阶循环群 {0, 1, 2}。3 阶指元素个数,循环指不管生成元 2 累加多少次,对 3 取余后结果都是在 {0, 1, 2} 中,出现循环的情况。
对 ID 放大后,我们也可以加个盐,可以是一个固定值,也可以是每个用户ID对应一个值,我这里取一个固定值 123456789。盐不要太小,太小缺乏隐蔽性;也不能太大,太大会占用过多用户 ID 的取值空间。比如位数可以和最大用户ID的位数保持一致。
放大和加盐后的效果:
代码语言:javascript复制const (
PRIME1 = 3
SALT = 123456789
)
uid = uid*PRIME1 SALT
// 示例
fmt.Println(GetInvCodeByUID(100000000, 6)) // dFchi3
fmt.Println(GetInvCodeByUID(100000001, 6)) // gIiqui
fmt.Println(GetInvCodeByUID(100000002, 6)) // jLozGx
可见,邀请码的规律性肉眼已经看不出来了。
然后是混淆,如何混淆呢?
混淆我用了P-box的方式,其实就是将数字洗牌。比如把 1234567 洗成 5237641。这样处理之后可以隐藏明文和密文之间的关系。洗牌的方式也很简单,选择一个和 CODE_LENGTH(本文中为 6)互质的数 PRIME2(可以选择 5),和数组角标相乘取余即可(原理同 PRIME1)。
最终的代码如下:
代码语言:javascript复制const (
PRIME1 = 3 // 与字符集长度 62 互质
PRIME2 = 5 // 与邀请码长度 6 互质
SALT = 123456789 // 随意取一个数值
)
// GetInvCodeByUIDUniqueNew 获取指定长度的邀请码
func GetInvCodeByUIDUniqueNew(uid uint64, l int) string {
// 放大 加盐
uid = uid*PRIME1 SALT
var code []rune
slIdx := make([]byte, l)
// 扩散
for i := 0; i < l; i {
slIdx[i] = byte(uid % uint64(len(AlphanumericSet))) // 获取 62 进制的每一位值
slIdx[i] = (slIdx[i] byte(i)*slIdx[0]) % byte(len(AlphanumericSet)) // 其他位与个位加和再取余(让个位的变化影响到所有位)
uid = uid / uint64(len(AlphanumericSet)) // 相当于右移一位(62进制)
}
// 混淆
for i := 0; i < l; i {
idx := (byte(i) * PRIME2) % byte(l)
code = append(code, AlphanumericSet[slIdx[idx]])
}
return string(code)
}
// 示例
fmt.Println(GetInvCodeByUID(100000000, 6)) // d3ihcF
fmt.Println(GetInvCodeByUID(100000001, 6)) // giuqiI
fmt.Println(GetInvCodeByUID(100000002, 6)) // jxGzoL
8.小结
本文介绍了常见的通过用户 ID 生成唯一邀请码的几种方法,大家可以根据业务场景选择使用。
当然,本文介绍的方法可能并不满组某些业务场景的需求,比如用户ID并不是数值型,那么就需要我们根据具体场景用合适的方法解决问题。没有最好的方法,只要能解决问题就是好方法。
参考文献
趣谈唯一邀请码生成方法 简单的密码学生成唯一邀请码 记录使用 Golang math/rand 随机数遇到的坑 维基百科.混淆与扩散 CSDN.以模6加法群(Z6, )认识循环群及其特点 维基百科.阶 (群论)