前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用户ID生成唯一邀请码的几种方法

用户ID生成唯一邀请码的几种方法

作者头像
恋喵大鲤鱼
发布2021-12-06 11:17:19
7.7K0
发布2021-12-06 11:17:19
举报
文章被收录于专栏:C/C++基础
在这里插入图片描述
在这里插入图片描述

文章目录

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

代码语言:javascript
复制
// 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$

代码语言:javascript
复制
--- 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,+)认识循环群及其特点 维基百科.阶 (群论)

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021/09/07 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 文章目录
  • 1.需求描述
  • 2.需求分析
  • 3.字符集
  • 4.方法一:随机数+唯一性判断(不可逆)
  • 5.方法二:Hash+唯一性判断(不可逆)
  • 6.方法三:进制法(可逆)
  • 7.方法四:进制法+扩散、混淆(可逆)
  • 8.小结
  • 参考文献
相关产品与服务
云数据库 Redis
腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档