在这里插入图片描述

1.需求描述

有一个业务需求,需要根据用户 ID(数值型 >=10000000)生成一个唯一的长 6 个字符的邀请码,用于邀请新用户注册。

2.需求分析

从邀请码的使用体验上来看,邀请码一般有以下几个特点:

  • 不可重复:不用用户 ID 生成的邀请码是不同的。
  • 唯一确定:一个用户 ID 只能生成一个邀请码。
  • 是否可逆:是否需要通过邀请码反推对应的用户 ID 是什么,不同业务场景有不同的要求。

本文将以 Golang 为例,给出根据用户 ID 生成唯一且不重复的邀请码的常见方法与实现示例。

3.字符集

首先需要确定组成邀请码的字符集,一般采用数字和英文大小写字母共计 62 个字符。如果长度时 6 的邀请码,那么空间大小 62^6 = 56,800,235,584,这是一个非常大的空间,足够用户量为亿级别的业务使用。

// 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)来判断。

// 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 ,一千万用户量下的碰撞率。

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 补全,这样我们便可以采用除法取整与取模来实现。

// 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.方法四:进制法+扩散、混淆(可逆)

7.1 扩散和混淆的作用

扩散 (Diffusion) 和混淆 (Confusion) 是 C. E. Shannon 提出的设计密码体制的两种基本方法,是现代分组密码的设计基础。在分组密码的设计中,充分利用扩散和混淆,可以有效抵抗坏人从密文的统计特性推测出明文或密钥。

根据香农在《密码学的数学理论》论文中的描述,扩散主要用来使明文和密文的关系变得尽可能复杂,明文中任何一点小变动都会使密文有很大的差异。

所谓混淆就是将密文与密钥之间的统计关系变得尽可能复杂,使得对手即使获取了关于密文的一些统计特性,也无法推测出密钥。使用复杂的非线性变换可以达到比较好的混淆效果,而简单的线性变换得到的混淆效果不理想。

使用扩散和混淆的方式可以对进制法进行改进。

7.2 进制法+扩散

如何扩散呢?

可以将个位和其位作和后取余,即可把个位的变化传导到每一位。为了使结果看起来更随机,可以给每一位分配不同系数。

// GetInvCodeByUID 获取指定长度的邀请码。
func GetInvCodeByUID(uid uint64, l int) string {
	var code []rune
	idxs := make([]byte, l)
	for i := 0; i < l; i++ {
		idxs[i] = byte(uid % uint64(len(AlphanumericSet)))               // 获取 62 进制的每一位值
		idx := (idxs[i] + byte(i)*idxs[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 这个生成元生成整个群。

(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 的位数保持一致。

放大和加盐后的效果:

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

可见,邀请码的规律性肉眼已经看不出来了。

其实做到这一步,已经满足绝大部分业务场景的要求。下面再加上混淆处理,仅供了解。

7.3 进制法+扩散+混淆

然后是混淆,如何混淆呢?

混淆我用了 P-box 的方式,其实就是将数字洗牌。比如把 1234567 洗成 5237641。这样处理之后可以隐藏明文和密文之间的关系。洗牌的方式也很简单,选择一个和 CODE_LENGTH(本文中为 6)互质的数 PRIME2(可以选择 5),和数组角标相乘取余即可(原理同 PRIME1)。

最终的代码如下:

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 随机数遇到的坑
以模6加法群(Z6,+)认识循环群及其特点 - CSDN

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐