拓冰建站拓冰建站
首页 / 资讯中心 / 正文

重复数查找、登录序列化与位运算实战:一个登录模块的完整拆解

想做“重复数查找、序列化登录与位运算测试”这个题目的朋友我猜你大概是遇到了两种情况要么是准备面试题复习看到这三个词凑在一起有点懵要么是接手了一套老代码里面既要做登录状态存储又要在注册时查重还用了不少位运算做状态标记想找个地方把这几块东西彻底捋清楚。我自己的经历更偏向后者。有一阵子我在维护一个古董级用户系统登录态用Session存里面塞了个用户对象用户表里找重复手机号靠一条模糊查询硬闯权限判断则是一连串if (flag 1 1)这种写法。代码能跑但谁也不敢动因为没人说得清这些位标记、序列化对象在极端情况下到底会发生什么。后来我花了两个晚上把这三个知识点分别写成小测试用例再拼成一个完整场景跑了一遍才真正搞明白它们之间的配合关系。这篇文章就是那次折腾的完整记录包含可复制的最小代码示例、我自己掉进去过的坑以及一个把三者串起来的登录模块案例。适合还在跟面试题较劲的初级开发也适合刚接手老系统的朋友。1. 重复数查找的三种思路从暴力哈希到位图压缩首先把重复数查找这个问题的边界搞清楚。最常见的需求是给定一个数组让你判断里面有没有重复元素或者找出第一个重复元素再严苛一点——给定长度为 n1 的数组元素范围是 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就开一个 n1 长的 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...这样的乱码。这不是 bugJDK 序列化的二进制格式就是这样只是没法直接排查数据。更适合登录态的场景是 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 - userJsonCookie 里只留一个随机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 而不是掩码 13就会变成把value ~3和value ~(13)混为一谈。用绝对位置而不是掩码来写接口能少踩一半的坑。4. 实战案例一个登录模块同时用到查重、状态位与序列化前面三个部分讲的是知识点这一节把它们拼成一个真实可跑的最小系统。需求就三条新用户注册时手机号不能重复。登录成功后用一个 int 记录用户状态bit0 是否登录、bit1 是否管理员、bit2 是否已激活。登录态存进 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(host127.0.0.1, port6379, db0) # 登录成功后构造状态位 def login(username, is_adminFalse): # bit0: 登录状态bit1: 管理员bit2: 已激活 status 0 status | (1 0) # 已登录 if is_admin: status | (1 1) if is_activated(username): status | (1 2) session_id fsession_{username}_{int(time.time())} session_data { username: username, login_status: status, login_at: int(time.time()) } # JSON 序列化后存储 r.setex(fsession:{session_id}, 1800, json.dumps(session_data)) return session_id def is_admin_by_session(session_id): data r.get(fsession:{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是 intJSON 序列化后存的仍然是 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_PROPERTIESfalse否则老代码发来的数据里多个字段整个反序列化直接炸掉。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 里如果一组权限标志位包含 bit31status就是个负数直接打印出来会吓人一跳但只要(status 31) 1还是 1逻辑就没问题。为了避免团队里其他人被这种负整数吓到我建议状态标志位只用低 16 位高 16 位留给未来的扩展并且约定“不以 int 的符号判断逻辑只用和提取具体位”。5.4 序列化登录的另一个隐藏点不要在日志里打印完整对象这个点可能有人会觉得和序列化无关但运维事故往往就出在这。登录成功时开发者为了好排查习惯把登录用户对象直接打日志如果是 JSON 序列化后的字符串里面带电话号码、邮箱、密码哈希甚至 token就会被日志平台采集走。我的处理方式是统一封装一个PartialUserSnapshot只包含userId、username、status三个字段再序列化成 JSON 打日志。这样既方便排查又不会把敏感字段糊在日志里。同样在 Redis 里用 JSON 序列化存储登录态时也建议只存用户表的核心字段不要顺手把用户的密码加盐哈希也存进去。会话和账号信息分开出问题时单独清 session 不影响账号体系。6. 写在最后的经验这三个主题放在同一篇里最初看起来散但当你真的并行处理一个登录模块时会发现它们是互相咬合的注册查重利用的是哈希、位图那套效率思想登录态存储绕不开序列化而状态位、权限标记则是位运算最自然的生产场景。项目里不一定每个模块都能同时用上这三样但你在候选人身上或队友身上看到的那种“处理状态位不慌、选序列化方案能说出理由、查重前先问数据量级”的笃定都是从这些基础功夫里长出来的。我给自己的要求是位运算的每个操作都写测试用例序列化方案先自问“这个数据流经哪些系统、谁能改它、要不要防止篡改”查重算法则永远先亮数据规模和范围边界。做到这三点你会发现很多线上问题根本不会发生。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门