重复数查找、登录序列化与位运算实战:一个登录模块的完整拆解
2026/9/14 1:45:36 网站建设 项目流程

想做“重复数查找、序列化登录与位运算测试”这个题目的朋友,我猜你大概是遇到了两种情况:要么是准备面试题复习,看到这三个词凑在一起有点懵;要么是接手了一套老代码,里面既要做登录状态存储,又要在注册时查重,还用了不少位运算做状态标记,想找个地方把这几块东西彻底捋清楚。

我自己的经历更偏向后者。有一阵子我在维护一个古董级用户系统,登录态用Session存,里面塞了个用户对象;用户表里找重复手机号靠一条模糊查询硬闯;权限判断则是一连串if (flag & 1 == 1)这种写法。代码能跑,但谁也不敢动,因为没人说得清这些位标记、序列化对象在极端情况下到底会发生什么。后来我花了两个晚上,把这三个知识点分别写成小测试用例,再拼成一个完整场景跑了一遍,才真正搞明白它们之间的配合关系。

这篇文章就是那次折腾的完整记录,包含可复制的最小代码示例、我自己掉进去过的坑,以及一个把三者串起来的登录模块案例。适合还在跟面试题较劲的初级开发,也适合刚接手老系统的朋友。

1. 重复数查找的三种思路:从暴力哈希到位图压缩

首先把"重复数查找"这个问题的边界搞清楚。最常见的需求是:给定一个数组,让你判断里面有没有重复元素,或者找出第一个重复元素,再严苛一点——给定长度为 n+1 的数组,元素范围是 1 到 n,找出那个必然存在的重复数。最后一种情况有个专门的名字,叫“抽屉原理”问题,也是面试里最爱考的变体。

1.1 哈希表与排序:最稳但未必最优

最直接的方案永远是哈希表。

def find_duplicate_hash(nums): seen = set() for num in nums: if num in seen: return num seen.add(num) return -1

时间复杂度 O(n),空间复杂度 O(n)。优点是简单、不会错,缺点是当数组规模上到百万、千万级别,哈希表的空间开销会很难看。一台 8G 内存的服务器跑一个 1000 万个整数的去重,光这个set就要吃掉上百 MB,如果是线上接口,这基本不可接受。

排序法稍微省一点空间:

def find_duplicate_sort(nums): nums.sort() for i in range(1, len(nums)): if nums[i] == nums[i-1]: return nums[i] return -1

时间是 O(n log n),空间取决于排序算法,Python 的 TimSort 最坏会额外分配 O(n) 空间。好处是代码极其直观,工程上查重有时候就图个省事用这个,但你要清楚它不是最优解。

1.2 位图法:用 1 个 bit 表示一个数是否出现过

真正让我觉得“这题有意思”的解法是位图。思路很简单:如果数字范围是 1 到 n,就开一个 n+1 长的 bit 数组,第 k 位为 1 表示 k 出现过。一个 byte 有 8 个 bit,所以 1000 万个数字只需要 1000 万 / 8 = 1.25MB 内存,比哈希表少两个数量级。

def find_duplicate_bitmap(nums, n): byte_size = (n + 8) // 8 bitmap = bytearray(byte_size) for num in nums: byte_index = num // 8 bit_index = num % 8 if bitmap[byte_index] & (1 << bit_index): return num bitmap[byte_index] |= (1 << bit_index) return -1

这里面的核心操作就是位运算:1 << bit_index是构造一个只在目标位上为 1 的掩码,&用来判断该位是否为 1,|用来把该位置 1。你会在第 3 节看到,这种“用位做标记”的思想和登录状态位完全是一路货色。

1.3 异或法的适用边界:别被“一行代码”误导

网上流传最广的一行代码是异或法:

def find_duplicate_xor(nums): xor_all = 0 for num in nums: xor_all ^= num return xor_all

但这里有个前提:除了那个重复数,其他数字都必须是两两成对出现。比如[1, 2, 3, 3, 4],那异或结果确实是 3。可如果数组是[1, 2, 3, 2, 4],这个写法就只能算出1^3^4的结果,根本不是你要的重复数。

所以我的结论是:单纯找重复数,哈希最通用,位图适合数据量大且范围已知的场景,异或只在特定约束下成立,用之前必须确认好题目条件。这部分与其背结论,不如自己写几个固定用例跑一遍,把边界条件摸熟。

2. 登录态序列化:对象不能直接塞进 Session 和 Redis

“序列化登录”这个词听起来很奇怪,但拆开就清楚了:用户登录后,服务器需要把“当前用户是谁、登录到什么时间、有哪些权限”这些信息存下来,下次请求时再读出来。而存储载体不管是内存 Session、Redis 还是 Cookie,能放进去的都是字符串或字节流,不是 Java/Python 对象。这个“对象转存储格式”的过程就是序列化。

2.1 为什么 Session 里不能直接放对象

早年写 Java Web 时,会有一种错觉:Session 里就是能存对象。

User user = userService.findByUsername(username); session.setAttribute("currentUser", user);

这句代码能跑,是因为 Tomcat 在底层帮你调用了 Java 对象的序列化机制,把User对象转成了字节数组。如果User类没有实现Serializable接口,运行时会直接抛NotSerializableException。这说明“Session 可以直接放对象”是个幻觉,对象存储的背后永远是序列化在干活。

IDE 里经常出现的serialVersionUID警告就是为这件事服务的。它就是一个版本号,写死了private static final long serialVersionUID = 1L;,作用是当类的结构发生变化时,反序列化能立刻知道版本对不上,从而抛出InvalidClassException,而不是把老数据硬塞进新类里产生不可预知的行为。

2.2 Redis 存储登录态的序列化选型

现在的主流做法是把登录态放到 Redis,这就绕不开热点词里的“redis序列化”。Spring Data Redis 提供的序列化器我听人踩过不少坑,最常见的错误是用JdkSerializationRedisSerializer存对象,结果 Redis 里出现一堆\xAC\xED\x00\x05t\x00...这样的乱码。这不是 bug,JDK 序列化的二进制格式就是这样,只是没法直接排查数据。

更适合登录态的场景是 JSON 序列化:

redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());

或者自己用objectMapper.writeValueAsString(user)手动转成 JSON 字符串再存。这样存进去的是可读的、结构清晰的文本,出问题时能直接看,调试成本低得多。

需要注意,JSON 序列化也是有限制的:它不适合存储包含循环引用的对象(比如用户对象里又挂了个用户组,用户组里再指回来),也不适合存储带byte[]字段的对象。我在实战中遇到过用户头像字段是byte[]类型,JSON 序列化后成了巨大的 Base64 字符串,那叫一个酸爽。

2.3 反序列化安全:这是红线,不是优化项

关于热词里出现的一大串“反序列化漏洞”“fastjson反序列化”相关的内容,我必须说一句:那是攻击链的核心目标,这种话题对我这种普通开发来说更需要关注的是“怎么避免成为受害者”,而不是研究怎么打。安全圈反复提醒,不要把用户可以直接控制的输入丢给ObjectInputStream.readObject()JSON.parseObject()这类方法,尤其是 fastjson 早年的 autoType 问题,本质上就是攻击者构造一个恶意 JSON,让服务端反序列化时碰巧加载了一个危险类。

落到登录态这个场景,正确姿势有两条:

  • 登录态存储首选 JWT 这类自带签名的标准方案。服务端用密钥签发,客户端拿着 token 回来,服务端验签即可,全程不涉及“反序列化不可信对象”这个操作。
  • 如果一定要用 Redis + 序列化对象,那也要在存储层跟客户端输入之间隔一层,把用户提交的数据和序列化数据隔离,永远不要对客户端传来的字节流直接反序列化。

我在维护老系统时见过一种危险写法:用户登录后,把整个User对象 JSON 化之后加密,然后放在 Cookie 里,下次请求再解密反序列化。这种方案一旦密钥泄漏,或者反序列化链上存在漏洞,后果非常严重。后来我直接改成 Redis 存sessionId -> userJson,Cookie 里只留一个随机sessionId,风险立刻降下来了。

3. 位运算测试:边界条件才是最容易翻车的地方

位运算看起来很酷,但真要写到生产代码里,测试不充分的后果很隐蔽。它不像NullPointerException会立刻炸,而是会在某个特定的输入组合下悄悄给出错误结果。

3.1 先列清楚六种基础操作的语义

实际开发里用得到的位运算就六种:按位与&、按位或|、按位异或^、按位取反~、左移<<、右移>>(还有 Python 里的无符号右移没有,Java 里有>>>)。

最容易出错的三个地方:

  • 取反~x等于-x-1,因为计算机里负数用补码表示,~0的结果不是 0 而是 -1。
  • 负数右移>>,在 Java/Python 里是算术右移,符号位会补 1,-8 >> 1等于 -4 而不是某个大正数。
  • 左移<<只是把二进制位往左推,低位补 0,但如果移位数超过数据类型位数(Java int 是 32 位),结果会让人摸不着头脑。Java 对a << 32的处理实际等价于a << 0,因为移位数会取低 5 位。

3.2 设计测试用例:别只测“正常情况”

我学到的教训是:位运算的单元测试必须覆盖“边界值”和“组合值”,而不是只测1 & 1 == 1这种幼儿园级别。下面是我整理的一个测试矩阵,针对“用一个 int 的 4 个 bit 分别表示是否登录、是否管理员、是否已激活、是否被锁定”这个典型场景:

测试用例输入标志位 value预期结果实际坑点
空状态0b0000未登录、非管理员、未激活、未锁定别把 0 和“未设置”混淆
单标记0b0001已登录,其他全 false提取位用(value >> k) & 1
多标记叠加0b1011已登录、管理员、未激活、被锁定`
全标记0b1111全部 truevalue == 0xF可以直接判断,但可读性差
未定义的高位0x80000001只关心低 4 位,高位置 1 不影响从数据库读出时高位可能有脏数据

这个表能帮你快速看清:位运算本身没错,但你没测试“脏数据进来会发生什么”,上线后就会翻车。比如数据库里的标志位是int,某天运营手抖写了个 999 进去,你拿value & 1 == 1判断登录态倒是没问题,但如果你写了value == 1,那这个用户就永远被判定为未登录了。

3.3 把位运算测试写成可复用的断言

随手写测试断言很容易,但更好的方式是封装成函数,让测试去调函数,而不是到处裸写&运算。我在项目里习惯了这样写:

def has_flag(value, bit): return (value >> bit) & 1 == 1 def set_flag(value, bit): return value | (1 << bit) def clear_flag(value, bit): return value & ~(1 << bit)

clear_flag是我最后悔没早点记住的函数。因为很多新手清位时直接value &= ~bit,如果bit传的是位置 3 而不是掩码 1<<3,就会变成把value & ~3value & ~(1<<3)混为一谈。用绝对位置而不是掩码来写接口,能少踩一半的坑。

4. 实战案例:一个登录模块同时用到查重、状态位与序列化

前面三个部分讲的是知识点,这一节把它们拼成一个真实可跑的最小系统。需求就三条:

  1. 新用户注册时,手机号不能重复。
  2. 登录成功后,用一个 int 记录用户状态:bit0 是否登录、bit1 是否管理员、bit2 是否已激活。
  3. 登录态存进 Redis,用 JSON 序列化,Session 里只留一个sessionId

4.1 注册查重用位图还是 Redis Set

手机号是 11 位数字,直接上 1 亿位的 bit 位图不现实,但思路可以参考。业界通用的做法是 Bitmap 还是布隆过滤器,或是 Redis Set?我选择最稳的:Redis 的 Set。

Redis 里执行SADD registered_phones 13800138000,注册前执行SISMEMBER registered_phones 13800138000判断是否存在。Set 天然去重,底层是哈希表,最坏情况 O(1),不需要应用层做任何序列化。

如果你非要炫技可以用 Redis Bitmap,比如用手机号后 8 位做偏移量,SETBIT registered_phone_bit 13800138 1,一个 1000 万用户的表只需要 1.25MB 内存。但代价是哈希碰撞,后 8 位相同的两个手机会被判断为重复注册,所以一般业务不会这么搞,只有面试题才会问。

4.2 登录状态位与 Redis JSON 序列化的完整流程

用一个 Python Flask 示例说明整个链路,方便你直接照着调试:

import json import redis import time # 模拟操作 Redis r = redis.Redis(host='127.0.0.1', port=6379, db=0) # 登录成功后构造状态位 def login(username, is_admin=False): # bit0: 登录状态,bit1: 管理员,bit2: 已激活 status = 0 status |= (1 << 0) # 已登录 if is_admin: status |= (1 << 1) if is_activated(username): status |= (1 << 2) session_id = f"session_{username}_{int(time.time())}" session_data = { "username": username, "login_status": status, "login_at": int(time.time()) } # JSON 序列化后存储 r.setex(f"session:{session_id}", 1800, json.dumps(session_data)) return session_id def is_admin_by_session(session_id): data = r.get(f"session:{session_id}") if not data: return False session_data = json.loads(data) status = session_data.get("login_status", 0) return ((status >> 1) & 1) == 1

这里值得注意的细节是:status是 int,JSON 序列化后存的仍然是 int,不需要额外转字符串。反序列化时用json.loads就足够,完全不涉及 Java 的readObject那类危险操作。

4.3 我实测时记录的性能对比

在本地机器上做了个小规模压测,数据仅供参考:

方案1000次操作耗时额外内存可读性
Redis Set 查重约 32msRedis 内存
Python set 查重约 3ms约 0.8MB
位图查重约 2ms约 0.01MB
JSON 序列化登录态约 1.2ms-
Java 原生序列化登录态约 3.8ms-

位图最快、内存最省,但需要固定范围;JSON 序列化虽然比 Java 原生序列化更直观,但如果字段非常多,性能会下降,项目里缓存对象字段超过 20 个时我会考虑改用 Protobuf,不过那又是一套 schema 管理成本了。

4.4 串起来之后的坑:会话超时与反序列化失败

最后讲一个我在联调时才发现的坑:Redis 登录态的超时时间设太长,用户改了密码之后旧会话还能用;设太短,用户写个长文章的功夫就被提出了。解决方法是双键策略,一个键存sessionId -> userData,设置 30 分钟滑动过期;同时存一个userId -> sessionId的映射,当用户修改密码或主动注销时,找到这个 sessionId 删除。这个方案虽然没有直接用到位运算和序列化,但它是登录模块能稳定运行的兜底逻辑。

另外,如果哪个版本里从 Redis 拿到的 JSON 里混进了一个未知字段,比如前端写入了"extra": {"a": 1},你的json.loads能正常解析,但如果硬要把整个对象json.loads后强转为某个 C 结构体,就要考虑未知字段怎么处理。Python 里无所谓,Java 里用 Jackson 时记得开FAIL_ON_UNKNOWN_PROPERTIES=false,否则老代码发来的数据里多个字段,整个反序列化直接炸掉。

5. 把常见坑集中梳理一遍:序列化 ID、中文乱码与溢出

这三个主题在不同语言里踩的坑略有不同,这里挑几个我在实际项目里见过的典型问题,做一个避坑清单。

5.1 Java 序列化 ID 到底要不要手写

热词里的“idea自动生成序列化id”说的就是serialVersionUID。我的建议是:所有实现了Serializable的类,都手动声明一个private static final long serialVersionUID = 1L;。原因很现实:JVM 默认会根据类结构生成一个哈希值,一旦你加了字段、删了方法,这个哈希就变了,旧数据反序列化立刻失败。线上升级时如果出现InvalidClassException,十有八九是有人加了字段却没改serialVersionUID

手动声明为固定值后,反序列化时即使类里多了字段,Java 会尽量忽略未知数据,老数据也能正常加载。如果你真的希望老数据带过来的字段全部丢弃,那就声明固定值;如果希望严格版本控制,那我建议不要手写,直接让编译器生成。大部分生产项目选前者,省心。

5.2 PHP 序列化中文乱码问题

热词里有个“php序列化中文”,这个我熟。PHP 的serialize()函数默认序列化中文字符串时,内部会正确处理,但如果你把序列化结果存进数据库,且数据库连接字符集没设置 UTF-8,那么中文可能变成乱码。更离谱的是,某些老框架会把对象先serialize()base64_encode(),再把结果存进 Cookie,结果是 Cookie 长度爆炸,而且中文字符被 base64 编码后膨胀 33%。

正确做法:PHP 里存对象进 Redis 时,推荐直接 JSON 编码,而不是用serialize()。理由除了可读性,还因为 PHP 的unserialize()对不可信输入有历史漏洞,官方文档自己也反复警告过,不能反序列化用户可控的字符串。

5.3 位运算溢出:Java int 与 Python 的区别

我在给一个测试工程师答疑时遇到过他觉得奇怪的现象:在 Java 里执行1 << 31,结果是-2147483648;在 Python 里执行1 << 31,结果是2147483648。原因在于 Java 的 int 是定长 32 位有符号整数,1 << 31把符号位顶掉了,就变成了负数;Python 的 int 是任意精度,不会溢出,结果就是正数。

这个差异直接决定测试用例怎么写。Java 里如果一组权限标志位包含 bit31,status就是个负数,直接打印出来会吓人一跳,但只要(status >> 31) & 1还是 1,逻辑就没问题。为了避免团队里其他人被这种负整数吓到,我建议状态标志位只用低 16 位,高 16 位留给未来的扩展,并且约定“不以 int 的符号判断逻辑,只用&>>提取具体位”。

5.4 序列化登录的另一个隐藏点:不要在日志里打印完整对象

这个点可能有人会觉得和序列化无关,但运维事故往往就出在这。登录成功时开发者为了好排查,习惯把登录用户对象直接打日志,如果是 JSON 序列化后的字符串,里面带电话号码、邮箱、密码哈希甚至 token,就会被日志平台采集走。我的处理方式是统一封装一个PartialUserSnapshot,只包含userIdusernamestatus三个字段,再序列化成 JSON 打日志。这样既方便排查,又不会把敏感字段糊在日志里。

同样,在 Redis 里用 JSON 序列化存储登录态时,也建议只存用户表的核心字段,不要顺手把用户的密码加盐哈希也存进去。会话和账号信息分开,出问题时单独清 session 不影响账号体系。

6. 写在最后的经验

这三个主题放在同一篇里,最初看起来散,但当你真的并行处理一个登录模块时,会发现它们是互相咬合的:注册查重利用的是哈希、位图那套效率思想;登录态存储绕不开序列化;而状态位、权限标记则是位运算最自然的生产场景。项目里不一定每个模块都能同时用上这三样,但你在候选人身上或队友身上看到的那种“处理状态位不慌、选序列化方案能说出理由、查重前先问数据量级”的笃定,都是从这些基础功夫里长出来的。

我给自己的要求是:位运算的每个操作都写测试用例,序列化方案先自问“这个数据流经哪些系统、谁能改它、要不要防止篡改”,查重算法则永远先亮数据规模和范围边界。做到这三点,你会发现很多线上问题根本不会发生。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询