1. 模块定位与整体设计思路
做“知光项目”的时候,我负责的是用户资料模块,这个模块乍一看没什么技术含量,不就是存个昵称、头像、性别、生日嘛。但真正动手做之后才发现,里面坑不少,尤其是当用户量上来、业务方需求开始五花八门之后,这个模块的设计质量直接决定后续半年你加需求时是轻松改配置还是天天加班写兼容代码。
先交代一下背景。知光项目是一款面向C端用户的社交类应用,用户体量规划在百万级,初期上线功能包括基础资料、个性化签名、地域信息、成就徽章等展示型内容。我接到的核心任务是搭建一套用户资料模块,支撑前端展示、后台可配置化管理、运营可干预,同时为推荐系统和搜索模块提供标准化的用户属性数据。说白了,这个模块是很多业务模块的数据底座,不只是给个人中心页用的。
第一个要理清楚的问题是:用户资料模块和账号体系是什么关系。账号体系管的是注册、登录、密码、手机号、绑定关系这些安全敏感信息,而用户资料模块管的是用户自身属性的展示类信息,比如昵称、头像、性别、生日、简介、所在地、职业、兴趣标签、实名认证状态等等。这俩必须分开,不能混在一个表里。原因很简单:账号数据的访问频率相对低、安全级别高、字段稳定;资料数据访问频率极高、字段频繁变动、不同业务对字段的读写需求差异大。混在一起会让账号体系的安全策略变得很被动,比如运营想改一个用户昵称,结果得走账号权限审批流程,效率极低。
那资料模块具体要承担哪些职责?我在设计时初步梳理了几个核心方向:首先是提供用户资料的全量读写接口,支撑个人中心的查看和编辑;其次是向其他业务模块输出标准化的用户资料数据,比如推荐系统要读年龄区间、搜索模块要读昵称和标签;再就是支持后台运营的定向修改,比如活动运营要帮用户加一个临时标签,或批量修改某个地域分组的用户头像框;最后是资料完整度的统计,用于引导用户完善资料,也是产品做用户画像的一个基础。
这个模块的边界我卡得很死:不碰鉴权逻辑,不碰消息通知,不碰好友关系,只专注在“用户属性数据本身”的存储、读取、校验和分发上。边界清晰是后面所有工作顺畅的前提,很多项目出问题都是因为模块职责蔓延,今天加一个需求明天再加一个,最后变成一个什么都管的大泥球。
设计上我分成了四层:数据存储层负责物理表结构和缓存结构;服务层负责核心CRUD逻辑和字段校验;适配层负责对接不同业务方的个性化数据需求,比如给推荐系统输出的是一个精简版本,给个人中心输出的是完整版本;接入层就是各种API接口,包括客户端接口、后台管理接口、内部RPC接口。这套分层非常常见,但真正做的时候,难点在于每一层的职责不能模糊。
举个例子,很多新手会把字段校验逻辑写在服务层,然后在接入层又写一遍,两边稍微不一致就出线上事故。我的做法是校验逻辑统一收敛在服务层,接入层只做协议转换和参数标准化,不允许做业务判断;数据存储层则只做读写,不做任何逻辑。这样出了问题排查路径非常短:要么是接口层参数问题,要么是服务层逻辑问题,要么是存储层数据问题,不存在夹缝里藏bug的情况。
再聊一下模块的技术选型。存储主库用的MySQL,版本8.0,引擎InnoDB,字符集utf8mb4;缓存用的是Redis,主要做热点资料缓存和防穿透处理。资料模块的读写比大约是10比1,缓存的价值非常大。至于为什么不直接上MongoDB这类文档数据库,是因为在知光项目早期阶段,团队对MySQL的运维经验最丰富,而且资料数据的关联性并没有那么强,关系型数据库的强一致性和事务能力反而更稳妥。后期如果字段膨胀到几百个,再考虑引入文档型存储也不迟,前期没必要为了技术上的“先进”牺牲团队可控性。
模块的名称定为“用户资料域”,英文缩写UPM(User Profile Module),内部服务名叫知光-资料服务。本文后续的所有讨论,都是围绕这个UPM域展开的。
1.1 核心需求解析
用户资料模块听起来简单,但需求一展开就有一堆细节。我从知光项目的产品文档里抽出几个最核心的需求点,逐个拆了一遍。
需求一:用户资料的展示这是最基础的需求。个人中心页展示头像、昵称、性别、年龄、地区、签名、职业、教育经历、兴趣标签等。前端希望在一次请求里拿全所有资料,不想拆多个接口。这个需求直接决定了我不能用一张“用户主表+多张扩展表”的复杂拆表方案,否则查询聚合成本太高,而是要在主表之外设计一个可扩展的JSON字段区,用一次查询返回完整资料。
需求二:用户资料的编辑用户编辑资料,后台要校验字段合法性,比如昵称的长度、敏感词过滤、性别枚举值校验、生日的合理区间判断等。编辑是高频操作,但并发量远低于读取,所以更新的核心矛盾不是性能,而是并发一致性。用户双端同时编辑资料,怎么保证最后只落一个版本,数据不丢,这是后面要重点解决的问题。
需求三:后台运营干预运营需要能帮用户修改资料,比如昵称违规强制改名、活动需要给用户加一个临时标签、用户申诉修改性别年龄等。这个需求意味着后台管理端必须有一套独立于用户端编辑的权限通道,并且每一次后台修改都要有操作日志记录留痕。
需求四:资料向其他模块输出推荐系统想用用户年龄段和兴趣标签做内容分发;搜索模块想用昵称做前缀匹配;客服系统想快速看到用户的基本资料以判断问题归属。这些下游模块各自关心的字段不一样,读取频率也不一样,有的要求实时性,有的可以接受T+1数据。
需求五:资料完整度统计与运营引导产品想要知道全站用户的资料完整度是多少,运营针对完整度低的用户推送通知引导完善资料。这要求对字段有完整度的权重定义,比如手机号绑定权重20分、头像上传权重15分、昵称填写权重10分,总分100分。这个统计不能实时扫全表,得想办法做增量统计。
这些需求单个拎出来都不算复杂,但合在一起,就要求这个模块的数据模型非常好扩展、读取非常高效率、写路径有严格纪律、外部输出有标准协议。这也是我在设计时反复权衡的重点。
1.2 方案选型的思路推演
方案选型阶段,我对比过几套方案,也踩过一些认知上的坑。这里把推演过程分享一下。
第一套方案:一张大宽表,全字段平铺。用户资料涉及的字段大约四十多个,全部建成独立字段,好处是查询直观、索引好设计、SQL写起来简单;坏处是每次产品加一个字段就要ALTER TABLE加列,表结构变更是线上风险很高的操作。而且很多字段很多用户根本用不到,大量空列浪费存储。对于知光这种快速迭代的项目,这套方案不够灵活。
第二套方案:主表+扩展属性表(KV表)。一张用户主表存稳定字段,扩展字段放KV表。好处是扩展性极强,加字段不用改表;坏处是查询时要么多条记录拼装,要么用GROUP_CONCAT聚合,性能和复杂度都上来了。更重要的是,KV表的数据类型是字符串,没法做精细的字段校验,运营和统计都不方便,排查问题时还需要拼接数据,可读性很差。
第三套方案:主表+JSON扩展字段。主表存核心高频字段,其余字段统一放JSON字段里。MySQL 8.0对JSON类型的支持已经比较成熟,有JSON_EXTRACT等函数可以查询,支持JSON索引,整体灵活性和性能是个不错的平衡点。产品加字段时只需要在JSON里加key,不用改表结构。缺点是JSON字段内部的校验需要在服务层自己做,没法用数据库约束硬卡。
最终我选择了第三套方案,但做了一定的折中:十来个非常核心的字段比如用户ID、昵称、头像、性别、生日等抽出来作为独立字段建索引,支持高频查询和搜索;其余三十多个低频字段统一存放在一个JSON字段内。这样既保证了核心查询的高性能,又保住了扩展的灵活性。
这个决策背后还有一层考虑:资料字段的稳定性差异极大。像昵称、头像、性别这种从产品上线第一天就有,而且永远是核心展示字段,独立出来不会亏;而像“个性签名”“个人主页背景图”“所在城市”这些字段,虽然也常用,但不会用于搜索过滤条件,放在JSON里完全够用。
再往下是缓存策略的选型。用户资料的读取热点非常集中,活跃用户的资料会被反复读取,不活跃用户的资料可能几个月都没人看一眼。如果对全部用户做缓存,内存浪费严重;如果完全不做缓存,数据库压力又扛不住。我的方案是“多级缓存+热点识别”:Redis缓存全量用户资料(主要是核心字段),通过设置合理的过期时间和主动更新来保证一致性;针对超热用户,比如粉丝量很大的创作者,再增加一层本地缓存,减少Redis访问压力;针对冷门用户,不做缓存,直接走数据库。这套策略在流量模型上做了一个“二八切分”。
关于写路径,我设置了严格的数据流规范:所有资料变更必须走同一个服务入口,也就是资料域服务内的一个统一Update方法,任何调用方都不允许绕过这个入口直连数据库修改。这个设计的核心目标是保证数据校验、敏感词过滤、版本号更新、缓存淘汰、异步消息通知这五件事逻辑一致。如果每个业务方都自己写一套SQL去改用户资料,那第二天就能在线上看到各种脏数据。
2. 数据模型设计与核心字段拆解
数据模型是一个资料模块的灵魂。我很认同一句话:代码写得乱最多是重构,数据结构设计错了那就是推倒重来。用户资料模块的数据模型,我前前后后改了三个版本,最后这一版基本稳定跑到了现在。
先看核心表结构,这是整个UPM域的基础。
CREATE TABLE `user_profile` ( `user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID,主键', `nickname` varchar(64) NOT NULL DEFAULT '' COMMENT '昵称', `avatar_url` varchar(512) NOT NULL DEFAULT '' COMMENT '头像地址', `gender` tinyint(4) NOT NULL DEFAULT '0' COMMENT '性别: 0未知 1男 2女', `birthday` date DEFAULT NULL COMMENT '生日', `country` varchar(64) NOT NULL DEFAULT '' COMMENT '国家', `province` varchar(64) NOT NULL DEFAULT '' COMMENT '省份', `city` varchar(64) NOT NULL DEFAULT '' COMMENT '城市', `signature` varchar(255) NOT NULL DEFAULT '' COMMENT '个性签名', `profile_ext` json DEFAULT NULL COMMENT '扩展字段JSON', `version` int(11) NOT NULL DEFAULT '0' COMMENT '版本号,乐观锁', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态: 1正常 0禁用', `profile_score` tinyint(4) NOT NULL DEFAULT '0' COMMENT '资料完整度得分', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`user_id`), KEY `idx_updated_at` (`updated_at`), KEY `idx_status_score` (`status`, `profile_score`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料主表';这里有几个设计细节值得展开讲。
主键选择:直接用user_id做主键,不额外加自增ID。原因是用户资料和用户账号是一一对应的,user_id天然唯一,而且写入时直接指定主键,避免了自增主键的生成开销。InnoDB是聚簇索引表,主键即数据行,用user_id做主键还能让后续的关联查询直接走主键聚簇索引,少一次回表。
昵称字段:定的是64字符。这个长度足够覆盖绝大多数场景,同时保证单列索引不会太长。这里我踩过一个坑,最初的方案给昵称定了varchar(100),在utf8mb4字符集下索引长度会很大,InnoDB单列索引最大支持768字节,utf8mb4一个字符最多4字节,100个字符就是400字节,虽然没有超限,但索引效率已经受了影响。后来收紧到64,实际使用中没发现任何业务受影响。
版本号字段:这是整个并发控制的核心。我用了乐观锁机制,每一次更新资料时都要求携带当前的version值,更新SQL里带WHERE version = 当前值,如果更新影响行数为0,说明版本冲突,需要重试或提示用户。这个设计在后面的并发更新场景里帮了大忙。
资料完整度得分字段:这是一个冗余统计字段。它不是每次编辑资料时临时算的,而是在更新逻辑里根据字段填写情况实时计算出来,存到这个字段里。运营后台可以按这个得分做批量筛选,比如找出得分低于40的用户做拉新或活跃激励。如果不冗余这个字段,每次筛选都要全表扫描所有资料字段再逐个判断,性能完全不可接受。
状态字段:软删除和封禁标识。用户主动注销资料,不是物理DELETE,而是把status置为0。这么做有几个原因:一是保留历史数据用于安全审计;二是用户反悔注销后可以快速恢复,避免资料重建;三是可以和账号体系的状态解耦,账号可以被封禁但资料仍然保留展示给客服人员做审核。
再来看JSON扩展字段的设计。我把Profile_ext设计为一个宽松的JSON对象,内部key全部采用下划线命名,同时约定了几个规范:所有值要么是字符串、要么是数字、要么是布尔,不允许嵌套复杂对象;key命名要有上下文关联,比如homepage_bg_url、job_title、school_name、interest_tags;必须包含一个schema_version字段,方便未来格式变更时做兼容处理。
为什么强调不嵌套复杂对象?因为MySQL的JSON字段查询虽然支持JSON_EXTRACT,但嵌套越深,查询写起来越痛苦,而且容易出错。更关键的是,服务层在读取扩展字段时,需要将JSON反序列化成Java对象,嵌套太深意味着服务里要建一堆内部类。扁平化的JSON结构,在用Gson或者Jackson反序列化的时候,可以直接映射到一个Map或者一个扁平DTO,处理起来干净利落。
实际项目里扩展字段大概有三十多个,我归纳成了几个分组:
- 身份信息类:实名认证状态、认证类型、认证时间、职业、学历。
- 社交展示类:主页背景图、个人标签、兴趣标签、交友状态。
- 运营个性化类:会员等级、活动徽章、临时标签、邀请码。
- 偏好设置类:内容偏好、消息接收开关、语言偏好、时区设置。
这些字段单独拆成表不值得,全建列又太浪费,JSON存储是在当前场景下综合成本最低的方案。
2.1 核心字段的使用规范
核心字段独立出来之后,最大的好处是查询直接走索引。我在实际使用中给这些字段划分了明确的职责边界,不能乱用。
user_id:所有查询的入口,任何接口都必须带这个参数才能访问其他资料字段。它是模块内所有缓存key的基础,也是防数据泄露的关键节点。我要求所有接口在返回数据前都要校验请求方的用户身份和数据归属权,防止A用户通过遍历user_id枚举B用户的资料。
nickname:除了展示,还被搜索模块用来做前缀匹配。这时候字段的字符集校验就很重要了。昵称不允许包含表情符号、不允许纯数字、不允许包含歧义词。表情符号的坑很隐蔽,utf8mb4能存emoji,但搜索模块用的分词器不一定支持,会导致搜索索引构建失败。我加了服务层校验,用正则表达式过滤掉非BMP字符(除少数合法场景外)。
avatar_url:这个字段存的是CDN地址,不是图片二进制。上传图片的流程是客户端先调OSS服务的上传接口,拿到URL后再调资料更新接口写入。这里有个安全细节:URL的域名必须校验白名单,防止用户传一个外部链接诱导其他人访问。我在接入层加了一个域名校验函数,只要不是知光项目配置的几个合法CDN域名,一律拒绝更新。
birthday:存date类型,不存年龄。这个设计是为未来做生日营销准备的。从生日推年龄是实时计算,但从年龄推生日是完全不可能的,存年龄等于自断后路。年龄计算放服务层,按当前年份减去出生年份,考虑是否过了生日的逻辑,保证输出的是周岁。
country/province/city:这三组字段用独立的行政区划编码来存。不用字符串存城市名,因为城市名有重名问题,还会有简繁体差异。我在全局维护了一份行政区划编码表,客户端选城市时提交编码,服务端校验编码有效性后存储。下游推荐系统读取时再根据编码反查城市名做展示。这样做还有一个好处:未来如果城市改名或区划调整,只需要改编码映射表,不用批量更新用户数据。
2.2 JSON扩展字段的读写策略
JSON字段的读写策略如果不设计好,很容易变成事故现场。我这里的实践是:服务层将JSON字段封装成一个ProfileExt对象,对象内所有属性都是可空类型,属性命名与JSON的key保持一致。
读取时,服务层从MySQL里把整行记录捞出来,把profile_ext反序列化成ProfileExt对象。如果某个扩展字段不存在,对应属性就是null。返回给前端时,根据业务需要决定是否输出null字段。一个值得分享的习惯是:写接口返回时,不输出值为null的扩展字段,这样能显著减少响应体体积,尤其在移动端网络环境下,能省一点是一点。
写入时,客户端传一个Map或者一个JSON对象,服务层需要做三重校验:
- 白名单校验:所有要写入的key必须在配置的合法key集合内,不认识的key直接拒绝。
- 类型校验:每个key有约定的数据类型,不匹配就抛参数异常。
- 长度校验:字符串类型的值统一限制长度,比如标签最大10个,单个标签最大20个字符。
三重校验通过后,更新整个profile_ext字段,而不是按key做部分更新。这个策略是刻意选择的:MySQL JSON字段的局部更新(JSON_SET)虽然在8.0里有性能优化,但会引入一部分复杂性:比如并发场景下,两个线程各改各的key,如果都只更新局部,不检查版本号,就可能出现丢失更新的问题。我的方案是无论改一个key还是多个key,都走同一套全量更新+版本号校验的逻辑,确保并发安全。从性能上说,JSON字段的全量更新会增加一点写入流量,但对资料更新这种低频操作完全可接受。
扩展字段我特意提了schema_version这个key,它的作用是做字段的向后兼容。未来如果某个扩展字段的定义需要变化,比如原来是布尔型的is_vip要改成字符串型的vip_type,服务层可以根据schema_version做新旧格式的转换逻辑。如果没有这个版本号,老数据在反序列化时就会直接报错,线上事故喷涌而出。加这个版本号成本极低,但收益极高,实名推荐所有用JSON扩展字段的项目都这么做。
3. 核心功能模块设计与实现
数据模型定下来之后,接下来就是服务层功能的实现了。用户资料模块的核心功能在我看来有五个:资料查询、资料更新、资料完整度计算、隐私控制、操作审计。这一节我逐个拆开讲,每一步都附带我当时踩过的坑和做的取舍。
3.1 资料查询链路的缓存设计
先说资料查询。这是RT最高的一个接口,个人中心页一打开就要拉一次全量资料。流量大、并发高,如果不做缓存,数据库会被瞬间打爆。
我设计了三级缓存,由快到慢依次是本地缓存、Redis缓存、MySQL主库。本地缓存我用的是Guava Cache,每个服务实例本地保存一份热点用户资料,过期时间60秒,最大容量1万条。为什么要做本地缓存?因为Redis的访问虽然快,但毕竟有一次网络IO,对超高QPS的接口来说,省掉这次网络IO能显著降低P99延迟。但本地缓存有个天然问题:多实例之间不一致。同一个用户的数据,在实例A上更新了,实例B上的旧数据可能还要存活最多60秒。对于用户资料这种一致性要求没那么苛刻的数据,这个短暂的不一致是可接受的。
Redis缓存存储的是用户资料的完整序列化字符串,key的格式是upm:profile:{user_id}。写入时用的是Jackson序列化,读取时反序列化成ProfileDTO。过期时间设置的是30分钟,但更新资料时会主动删除缓存,而不是等它自然过期。这个细节很重要:主动删除能保证更新后立即生效,被动过期则可能导致用户改完资料后刷新页面还是旧数据,体验很差。
缓存穿透是查询链路里必须防的问题。如果有恶意攻击者或者爬虫,用一堆不存在的user_id循环请求,Redis缓存全部miss,所有请求都打到MySQL上,数据库压力陡增。我的方案是缓存空值:对于查询不到的用户,在Redis里也写入一个空标记,比如存储一个特殊字符串EMPTY_PROFILE,过期时间5分钟。这样连续请求同一个不存在的user_id时,前面几次打到数据库之后,后续请求会命中空缓存,直接返回“用户不存在”,数据库得到保护。
查询链路还做了一个“读扩散”优化:个人中心页打开时,不会只查资料表,还要查用户的状态、等级、认证信息等。如果每个信息都单独查一次数据库,一次页面打开就要四五次DB查询。我做的优化是ProfileAggregate聚合查询:一次查询把用户资料主表、扩展字段、状态字段、等级信息全部组装好,返回一个聚合DTO给前端。代码层面用一个聚合服务协调多个数据源,然后做并行查询,最后合并结果。实测下来,个人中心页的接口耗时从平均80ms降到了30ms左右。
3.2 资料更新的版本控制与并发防护
资料更新是整个模块里最容易出bug的地方,尤其是并发场景。我做了一个非常严谨的更新流程,这里逐步拆解。
第一步,参数校验。客户端提交的更新参数,先做字段合法性校验。昵称检查长度和字符类型,头像检查URL域名,生日检查日期能否被解析且不超过当前日期,性别检查枚举范围,扩展字段做白名单和类型校验。所有校验都在服务层统一处理,接口层不重复校验。
第二步,加分布式锁。更新某个用户的资料时,先按user_id取模算出锁的key,然后去Redis里尝试加锁。加锁逻辑用的SET NX EX命令,设置5秒的自动过期时间防止死锁。同一时间只有一个线程能拿到同一用户的更新锁,避免了两个请求同时修改同一用户资料导致的数据竞争。
第三步,读取当前版本号和最新资料。加锁成功后,从Redis缓存里读资料数据,同时读version字段。如果Redis里没有,就去MySQL读。拿到当前version值和当前资料。
第四步,合并更新数据。将客户端提交的新值覆盖到当前资料上,生成新的资料数据。有些字段可能客户端没有传,那就保持原值不变。这一步不能粗暴地用客户端数据整体替换服务端数据,否则客户端漏传的字段会被清空,这是很常见的线上事故。
第五步,写库。UPDATE语句里带WHERE version = 当前版本号,执行更新。如果影响行数为0,说明版本冲突,有其他线程已经改过了,此时释放锁,重新走一遍第三步到第五步,最多重试3次,如果仍失败则向上抛异常。
第六步,清理缓存并通知下游。更新成功后主动删除Redis缓存,同时发一条异步消息到消息队列,通知搜索模块、推荐模块、客服系统等下游刷新该用户的资料数据。这里用消息队列异步化,避免下游服务同步调用影响主链路的RT。
这里有一个容易忽略的点:分布式锁和乐观锁是不是重复了?其实不是,二者防护的目标不同。分布式锁防的是同一进程内多个线程并发修改同一用户的数据,乐观锁防的是分布式环境下多实例之间的并发冲突。锁是前置防线,乐观锁是兜底防线。而且分布式锁还有一个作用:在更新过程中,如果另一个线程也想读取数据,会阻塞一会儿,避免读到中间状态的数据。
关于更新频率,我在接入层做了限流。单个用户在1分钟内最多允许提交10次资料更新,超出后返回“操作过于频繁”的友好提示。这个限流不是为了保护数据库,数据库扛得住,而是为了防止客户端出现死循环或用户恶意反复修改导致下游消息队列被刷爆。消息队列每一条消息都会触发搜索索引的更新,成本不低,不能被滥刷。
3.3 资料完整度计算与运营标签
资料完整度是运营侧非常关心的一个指标。产品部希望在用户中心页面展示一个“资料完整度:85%”的提示条,引导用户补全缺失的资料项。
我的实现方案是配置驱动的权重计算。在后台配置中心维护一张完整度权重表,每个资料字段对应一个分值,所有字段分值之和为100。比如昵称10分、头像15分、性别5分、生日10分、地区10分、签名10分、职业信息15分、社交标签25分。计算时,遍历该用户的全部资料字段,检查字段是否有有效值,有则累加对应分值,最终得分作为profile_score存入主表。
关键点在于计算时机的选择。最初我的想法是每次更新资料后都全量计算一次完整度,后来一想不对:用户可能只更新了头像,其他字段没变,但完整度计算逻辑要把所有字段都扫描一遍,浪费性能。优化是增量计算策略:配置中心里维护每个字段的分值和对应的检查函数,更新资料时,只检查本次涉及的字段,将变化的分值差累加到原得分上。比如用户原得分60分,本次新增了头像,头像权重15分,那么新得分就是75分。这个增量计算逻辑跑起来很轻快,成本可以忽略不计。
增量计算有一个边界情况要处理:用户修改了某个已填写的字段,分值不变,完整度得分不应该波动,但这种情况下得分差是0,累加0没有影响。还有用户删除了某个字段,得分要相应扣减,这个逻辑也要写对。我见过某个项目的完整度统计因为忘记处理删除场景,导致用户资料越删完整度越高,运营数据完全失真。
完整度数据除了展示,还给运营提供了筛选能力。运营后台可以按完整度区间筛选用户,比如找得分低于40分的用户做短信触达,引导他们完善资料。这里的批量筛选就会用到我在建表时设计的联合索引idx_status_score。在大几百万用户的表上,按状态和得分区间过滤的效率非常高,全表扫描的性能问题被索引直接化解。
运营标签是另一个高频需求。运营希望给特定用户打标签,比如“高活跃创作者”“活动参与用户”“需要挽回的流失用户”,标签要展示在个人中心页或者用于后续运营活动的定向推送。这个实现不难,就是在扩展字段里维护一个tags数组,运营平台修改时直接更新这个数组。但我这里做了一点差异化设计:区分了用户自选标签和运营强打标签。自选标签用户可自行增减,运营强打标签用户不可自行移除,只能由运营后台移除。这个逻辑如果不区分清楚,就会出现用户把自己的活动标签删了,导致运营后续找不到人。
3.4 隐私字段分级控制与脱敏处理
用户资料里有一些敏感字段,比如手机号、实名认证信息、生日、具体地理位置,这些字段不能随随便便让所有业务方都拉到。我在模块内做了一套字段分级控制机制。
首先定义字段敏感度级别。公开级别:昵称、头像、性别、签名,所有业务方都能读取。受控级别:生日、城市、职业、学校,只有经过授权的业务方才能读取,且授权的业务方需要申请开通权限并在后台配置。机密级别:手机号、邮箱、实名认证信息,任何业务方都不能通过资料模块读取,它们都在账号体系域名下,走独立的授权流程。
其次控制数据出口。内部RPC接口在返回数据前,根据调用方传入的appKey查询权限配置,判断该调用方是否有权读取受控字段。如果无权,返回数据里就把对应字段置为null。这个逻辑在适配层做,不影响服务层的核心逻辑。
然后是脱敏处理。客服系统在查看用户资料时,手机号需要脱敏成138****1234,具体地址只展示到城市级别。脱敏逻辑统一封装在一个脱敏工具类里,所有出口都要经过这个工具类。有一个坑我要特别提醒:脱敏操作不能只在前端做。如果服务端返回了完整手机号,前端再脱敏展示,任何人都可以抓包看到完整数据,脱敏就形同虚设。必须服务端脱敏后输出,前端只负责展示。
最后是审计日志。所有对机密级别字段的读取,都要记录审计日志,包括调用方身份、接口名、参数、时间、返回数据的摘要。这个审计日志平时没人看,但一旦出了数据安全事件,它就是追溯问题根源的关键证据。日志我采用的是异步写入,不阻塞主链路,存储保留周期是180天。
3.5 后台管理通道与审计链路
后台管理通道和用户端编辑通道是两套完全独立的链路,不能共用同一个Update方法。原因很直接:用户端编辑有严格的字段校验和权限控制,而运营修改资料时可能需要绕过一些限制,比如运营可以把某个用户的昵称改为空字符串(配合封禁处理),或者强制修改用户的性别信息(用户申诉成功后人工修正)。
我在实现时做了个UmsAdminProfileService,专供后台管理端调用。这个服务包含几个方法:修改用户资料、批量修改用户资料、查看用户资料变更历史、强制清理用户资料缓存。每个方法都要求调用者必须是已登录的运营人员,并且携带操作原因。操作原因在写入日志时是必填项,这个约束倒逼运营人员每次操作前想清楚目的,也方便日后审计。
后台管理通道在做批量修改时要特别小心。批量更新不能一条SQL搞定,因为每一条更新都可能触发下游消息通知,而且每一条都要记录审计日志。我的方案是同步遍历逐条处理,先查数据、再更新、再发消息、再写日志。对于大几千条的批量操作,这个流程会比较慢,但安全第一。后来我优化成了分批并行处理:每100条一个批次,批次之间并行执行,整体耗时从几分钟降到几十秒,同时单批失败不会影响其他批次。
审计日志的字段我设计得比较全:操作人ID、操作类型、目标用户ID、修改前资料快照、修改后资料快照、操作原因、操作时间、来源IP。其中修改前快照和修改后快照是两个JSON字段,把整行数据的变更完整记录。后续如果用户投诉说“我的资料被偷偷改了”,管理人员可以非常直观地看到是哪位运营在什么时间改了什么字段,效率和说服力都远超口头解释。
4. 上线实战中的几个硬骨头
再完善的设计落到真实环境里,总会遇到纸面上想不到的问题。这一节我把上线后遇到的几个硬骨头逐一拿出来说,都是我用真金白银的用户体验换来的教训。
4.1 缓存与数据库的一致性问题
上线初期,我遇到了一个典型的缓存一致性问题:用户修改昵称后,个人中心页立即显示新昵称,但其他服务(比如评论列表)展示用户昵称时,仍然是旧值。排查下来,根因出在缓存淘汰的时序上。
当时的更新流程是:先更新MySQL,再删Redis缓存。这个顺序本身没问题,但问题是下游服务(比如评论服务)也缓存了一份用户昵称,这份缓存并没有在用户更新资料后及时失效。评论服务的缓存策略是读多写少,缓存过期时间很长,用户改了昵称,评论列表头像是新的,昵称却还是旧的,体验非常割裂。
这个问题的根治方案是建立一个资料变更广播机制。用户资料更新成功后,资料模块向消息队列发送一条UserProfileChanged事件,消息体里包含用户ID和变更字段清单。所有依赖用户资料的业务方,订阅这个消息,收到后清理自己域内的用户资料缓存。这个广播机制让所有下游服务都能感知资料变更,从根上解决了多服务之间的数据不一致问题。
消息队列用的是RocketMQ,默认用的是异步发送,消息体大小控制在1KB以内。这里还有一个细节:发送消息时要设置消息去重键,比如用user_id + 时间戳拼接,防止消息重复导致下游重复刷新缓存。RocketMQ本身有至少一次投递语义,消费端要自己做幂等。我的消费端逻辑是直接用user_id去删除缓存,缓存删两次和删一次效果一样,天然幂等,不需要额外处理。
4.2 高并发场景下的缓存击穿防护
某个运营活动上线当天,知光项目迎来了建站以来最大的流量峰值。我个人中心接口的QPS瞬间从几百涨到几万,这时候出现了一个严重问题:部分热点用户的资料缓存因为被动过期,被高并发请求同时打到数据库,数据库连接池被打满,整个服务的RT开始飙升。
这就是经典的缓存击穿问题。单条数据的缓存失效瞬间,大量并发请求同时穿透缓存直达数据库。我的防护方案做了两层。
第一层是互斥锁。在查询链路里,命中缓存miss之后,不直接查数据库,而是先去Redis里尝试获取一个锁,key格式是upm:lock:profile:{user_id}。拿到锁的线程查数据库并回填缓存,拿不到锁的线程短暂sleep后重试读缓存。这样数据库同一时刻只有一个线程在查询同一条用户数据,压力锐减。这个逻辑当时用Redisson的分布式锁实现,大约20行代码就搞定了,但效果非常显著。
第二层是逻辑过期时间。我给Redis缓存设计了两个过期时间:物理过期时间和逻辑过期时间。物理过期时间设为30分钟,逻辑过期时间是5分钟。读取缓存时,判断逻辑过期时间是否到了,如果到了但物理过期还早,就说明数据已经进入“准过期”状态,此时不直接返回旧数据,而是触发一个后台刷新任务,从数据库捞最新数据并回填缓存。结合互斥锁,这个准过期数据最多容忍几秒的延迟,但能保证数据库永远不再被高并发打穿。
活动当天实测下来,在QPS五万的情况下,数据库的查询QPS稳定在800左右,全部命中索引,连接池占用不到10%,整个链路稳如泰山。这个组合方案帮了大忙,后面凡是涉及缓存的设计,我都会默认带上逻辑过期时间,而不是简单的被动过期。
4.3 数据迁移与兼容实践
知光项目做过一次从旧版用户表到新版profile表的全量数据迁移。这个迁移的难度不在于数据量,而在于新老模型字段映射关系错综复杂,稍不留神就丢数据。
我复盘了整个迁移过程,总结了几个关键原则。
迁移前必须做数据抽样比对。不要只对比总数,一定要抽查具体用户的数据,每个字段逐项比对。我当时写了一个比对脚本,随机抽出5000个用户ID,从源表和目标表分别取出资料数据,一一对比字段值,发现并修复了三个映射错误。印象最深的是性别字段,源表里用字符串“male”“female”存储,目标表用整型1、2,映射规则写在Excel文档里,执行迁移时发现有几个历史脏数据“其他”值无法映射,比对脚本直接标记出来,避免了线上出现性别丢失的情况。
迁移过程分两步走。先做存量迁移:把全量旧数据同步到新表,同步过程中源表数据仍在持续变化,但是哪个用户的资料发生变化,源表会记录一个updated_at时间戳;目标是新表的最新数据,以源表updated_at大于迁移开始时间的那批用户作为增量补迁范围。第二步做增量补迁:从迁移开始时间点往后,持续轮询源表的变更记录,把变化同步到新表。直到某个时间点增量追平,然后做一次切换,读流量切到新表,写流量同时切到新表,完成整个切换。
切换过程中要保证写入不丢失,我对表做了一个只读保护。在切换写流量前10分钟,把源表改为只读状态,任何写入请求都会被拒绝并返回提示。这10分钟内如果有用户编辑资料,会被前端拦截提示“系统升级中”,虽然影响了一小批用户,但是确保了数据零丢失。这个取舍我认为非常值得。
迁移上线后,我保留了老表数据90天作为备份,防止切换后发现数据问题需要回滚。90天后确认新表运行稳定,老表才做了归档清理。
4.4 下游接口的数据标准与兼容策略
用户资料模块的数据不能只给自己用,还要输出给各类下游业务方。这里我踩过一个大坑,就是没有一开始就建立明确的接口规范,导致各个下游各写各的解析逻辑,数据格式微调一下,下游就崩一片。
后来我建立了一套严格的接口兼容策略。所有下游调用方接入资料模块,必须走统一的RPC接口,禁止直接访问数据库。RPC接口返回的数据结构以protobuf协议定义,字段都带明确的字段编号和类型,版本变更时新增字段只增加编号,不修改旧编号。下游如果依赖某个字段,这个字段结构永远稳定不变;如果某个字段废弃了,标记为deprecated,保守保留一年再移除。
我还建立了一个下游健康度巡检机制。每周跑一次脚本,模拟调用各个下游依赖的资料字段,逐个做断言检查,确认输出的数据格式符合下游预期。这个巡检机制执行了三个月,帮我们发现了好几个潜在问题,有的是下游改了字段名没有通知我们,有的是上游返回了空值导致下游解析报错。这类问题如果不是主动巡检,往往要等线上故障爆发才会被发现,代价完全不同。
下游接口性能也要重点盯。资料模块是高QPS的基础服务,如果因为某个下游调用方式不当拖垮了主链路,全站业务都会受到影响。我在接入层设置了调用方级别的限流和熔断。限流是对单个调用方设置QPS上限,比如搜索服务每秒最多调用5000次资料接口。熔断是当某个调用方持续调用失败超过阈值时,直接熔断该调用方的请求,快速失败,不拖垮整个服务。
5. 性能优化与高可用治理
用户资料模块上线后,我的工作重心从功能开发转移到性能优化和稳定性治理上。这部分的经验价值不亚于前面的设计过程,因为真实流量会疯狂拷打你之前做的每一个决策。
5.1 慢查询分析与索引调优
上线两周后,我在监控面板上发现几条慢查询日志,耗时都在300ms以上,频率不高但让人不踏实。逐个分析之后发现,问题集中在几个场景。
第一个场景是按状态和资料完整度得分反向筛选用户,就是运营后台“找出得分低于40的活跃用户”这种需求。这类查询的过滤条件不是主键,是联合索引,如果命中的数据占比太大,比如“活跃用户”占全站30%以上,MySQL优化器会选择全表扫描而不是走索引。我的优化方案是要求运营后台的这类筛选必须附带额外条件,比如限定创建时间范围,把扫描范围缩小。这个限制不是拍脑袋定的,是根据索引选择和扫描成本估算做出来的,本质上是通过缩小数据范围来改变优化器的选择策略。
第二个场景是按照城市批量拉取用户,比如某城市群发活动需要拉出该城市的所有用户。如果城市条件对应的用户量很大,单次查询就要返回几万条记录,传输成本极高。我的优化是分页拉取,每次只取1000条,配合游标分页而不是偏移量分页。知道优化的原因吗?偏移量分页在深翻页时有严重的性能问题,MySQL需要先把前N条数据全部查出来再丢弃,页数越深,性能越差。游标分页用WHERE id > 上次最大ID的方式,始终走主键索引,性能稳定。
第三个场景是扩展字段的内容搜索,比如根据兴趣标签找用户。MySQL的JSON函数查询效率不高,这是所有用JSON扩展字段的人都绕不开的问题。我的处理方案很务实:不直接在MySQL里按JSON内容过滤大范围数据,而是将这类搜索需求剥离出去,交给搜索引擎。知光项目的技术栈里没有专门的搜索引擎服务,所以我做了一个变通方案:每晚定期将用户资料的索引字段同步到一张镜像表,镜像表用正则索引字段独立列存储,支持快速查询。这样既保证了扩展字段的灵活性,又满足了运营搜索的实时性要求。
5.2 连接池与线程池参数调优
基础服务的连接池配置,是上线性能优化的第一站。我当时用Druid连接池,核心参数做了细致调优。
初始连接数设的是10,最小空闲连接数5,最大活跃连接数50。这里有个反直觉的经验:最大连接数不是越大越好。因为连接数越大,数据库的线程上下文切换就越频繁,单个连接的响应速度反而会变慢。50是压测后得出的最优值,再往上加,数据库的CPU使用率没有明显变化,但RT开始上升。
线程池方面,资料服务用的是Tomcat线程池,最大线程数设为200,核心线程数50。这里有一个关键参数:拒绝策略。我的配置是CallerRunsPolicy,即当线程池满了之后,新任务不直接丢弃,而是反向抛出到调用方线程执行。这个策略能保证请求不丢失,但调用方线程会因此阻塞,起到天然限流的作用。实测在高峰期,偶尔会有几百毫秒的排队等待,但没有任何请求被丢弃。
另外,网络超时参数也做了调优。数据库连接的超时时间设的是5秒,超过就判定连接不可用并重建。调用下游RPC的超时时间设的是1秒,超过就快速失败。1秒的取值是我精心压测过的:正常链路耗时大约200ms,1秒可以覆盖绝大多数波动情况,同时不会让调用方等待太久。
5.3 流量高峰的资源冗余与限流预案
知光项目经历过两次大的流量活动,一次是版本大促,一次是年度盛典。这两次活动前,我都做了专项的压测和资源冗余。
压测工具用的JMeter,模拟线上流量的比例模型:90%读请求,10%写请求。压测指标重点关注P99延迟和错误率。第一轮压测就发现了问题:在3000QPS的读压力下,P99延迟从平时的30ms飙到了200ms,分析后发现是Java Full GC频繁导致。罪魁祸首是Redis缓存反序列化时创建了大量临时对象,JVM堆内存不断膨胀。优化方案是调整JVM堆内存参数,把-Xms和-Xmx都设为4GB,同时将G1垃圾回收器的目标暂停时间调为50ms。优化后Full GC频率从每分钟一次降到每10分钟一次,P99延迟回落到40ms以内。
限流预案是我重点设计的内容。资料模块有全局限流和接口级限流两层。全局限流用Redis的令牌桶算法实现,每秒放行5000个请求,超出部分直接返回“系统繁忙”。接口级限流区分不同接口:查询接口每秒3000,更新接口每秒500,后台管理接口每秒100。这个数字是通过压测得到的上限,确保数据库和下游不会被打垮。
活动当天,我盯着监控面板看了一整天。流量峰值的瞬间,限流器拦截了约15%的请求,数据库连接池使用率稳定在60%左右,缓存命中率保持在98.5%以上,全链路没有出现一次超时熔断。活动结束后我写了复盘报告,最深刻的体会是:限流不是服务的敌人,而是保护服务不被流量冲垮的最后一道防线。没有限流的服务,在突发流量面前就像没有护栏的悬崖,一旦掉下去就是雪崩。
5.4 关键时刻的监控告警与应急预案
监控告警体系是模块稳定性的眼睛。我在资料模块上线之前就搭建了一套三层监控体系。
第一层是业务监控。核心指标包括:资料查询接口的QPS、成功率、平均RT、P99 RT;资料更新接口的失败率、缓存命中率;数据库的连接池活跃数、慢查询数量。每个指标都配置了告警阈值:比如成功率低于99.9%就触发P0告警,平均RT超过100ms触发P1告警,慢查询超过10条触发P2告警。
第二层是JVM监控。关注堆内存使用率、GC频率、线程数、类加载数。特征是堆内存使用率超过80%持续5分钟,会触发P1告警;Full GC频率超过每分钟1次,直接P0告警。
第三层是下游依赖监控。对Redis、MySQL、RocketMQ的调用耗时和错误率做监控,任何一个下游的P99超过200ms都会触发告警。
告警通知渠道接入了电话、短信、企业微信三种。P0告警的电话通知会在30秒内拨出,确保值班同学第一时间响应。我在告警配置上有一个经验:告警的准确率比告警数量更重要。如果告警天天响但都是误报或者可以容忍的小问题,团队会形成“狼来了”效应,真正的大故障反而没人关注了。我的做法是宁缺毋滥,对于P2级别的告警,可以接受一定的延迟合并,用5分钟的聚合窗口减少通知轰炸。
应急预案方面,我写了三份SOP文档:缓存故障应急、数据库故障应急、消息队列故障应急。每份SOP都包含故障判定标准、紧急处置动作、影响评估和恢复流程。比如缓存故障的处置动作是“降级为直查数据库+限流保护”,消息队列故障的处置动作是“关闭异步通知,改为本地日志存储,恢复后补发”。这些预案不是纸上谈兵,每季度会组织一次故障演练,大家在演练中暴露问题、修正SOP,真正把预案变成肌肉记忆。
6. 模块治理的复盘与经验沉淀
用户资料模块经历了一个完整的生命周期:从需求分析、方案设计、开发、测试、上线、优化,到现在稳定运行。这个过程中踩坑不少,收获更多。这一节我从项目管理的视角复盘一下,哪些事情做对了,哪些事情如果再给我一次机会我会用不同的方式做。
6.1 开发过程中的节奏与取舍
先说开发节奏。用户资料模块这种基础性功能,最大的风险不是技术难点,而是需求蔓延。刚开始做的时候,产品经理提了一个需求,说希望支持用户上传多张头像轮播。我当时觉得工作量不大,就顺手做了。做到一半发现,多张头像涉及存储结构变更、前端UI改造、审核机制介入,复杂度是单张头像的好几倍。因为已经做了一半,舍不得扔掉,硬着头皮做完了。结果就是这个需求占了总开发时长的20%,但对业务价值的贡献可能只有2%。
这个教训很深刻。后来我定了一个原则:接到需求后,不急着开工,先花半天时间和产品经理理清楚这个需求的核心价值是什么,有哪些可选的实现路径,最低成本的方案是什么。对于锦上添花型的需求,明确排期和处理优先级,不在核心路径上消耗太多精力。资料模块后来的迭代中,90%的精力都花在了核心资料字段、缓存一致性、数据安全这些根基性的能力上,剩下的10%才用于扩展功能。这个分配比例让我在项目后期非常从容。
技术债的处理也是重要一课。资料模块早期为了快速上线,代码里有一些硬编码的枚举值和魔法数字,比如性别字段的0、1、2直接写在业务逻辑里,没有做成枚举类。后期加了一个新的性别选择“保密”,就需要把所有涉及性别判断的代码都翻出来改一遍。我在上线后花了两个晚上专门清理这些魔法数字,统一替换为枚举类。这个重构本身不产生新功能,但是有效防止了未来改需求时的线上事故。
6.2 跨团队协作中的接口契约管理
资料模块是典型的底座型服务,需要和很多团队对接。跨团队协作中最大的坑就是接口契约不一致。我们曾经和推荐系统团队对接时,对方需要的是用户年龄段,但我们给的字段是生日,两边没有对齐格式,推荐系统上线的第一版模型因为数据格式错乱,效果完全跑偏。后来排查才发现问题,影响了两个迭代的推荐质量。
这个问题的根源是,接口契约只停留在口头交流和文档上,没有形成强约束的自动化校验。吃了这个亏之后,我强制推行了一个机制:每一个下游接入方,必须在接入前签署接口契约文档,文档里明确每个字段的含义、类型、取值范围、是否必填、变更流程。契约文档以代码仓库的形式管理,每次变更都要走评审和版本控制。
另外我还推动建立了定期的接口联调会。每周抽出半小时,资料模块和相关团队一起过一遍接口改动清单,确认本周有没有接口定义变化、字段调整、数据格式变更。这个习惯看起来很笨,但对减少跨团队摩擦非常有效。很多问题在例会上一说就能当场对齐,省去了之后在线排查的漫长时间。
6.3 长期维护视角下的模块演进规划
模块上线只是开始,长期维护才是挑战。我在做资料模块的时候,花了相当多的时间思考未来半年到一年这个模块可能会遇到什么问题,提前做一些结构性规划。
第一是字段容量的演进。当前JSON扩展字段的设计可以支撑到两百个字段以内不出现明显性能问题,但超过这个量级,读取时的反序列化开销会增加,存储占用也会上升。我预留了一个演进路径:如果扩展字段数量超过阈值,将高频使用的字段从JSON提升为独立列,低频冷数据继续留在JSON里。这个演进不需要大改架构,只需要做一次数据回填和代码适配。
第二是分库分表的准备。知光项目的用户量在快速上升,虽然当前单表撑得住,但按增长速度估算,一年后主表数据量会突破3000万行。我在设计时就留了分表的余地:主键仍然是user_id,未来可以根据user_id的哈希值分到16个分表。只要SQL里都带user_id条件,分表对上层逻辑的影响就是可控的。缓存key的设计也天然支持分表后的路由,不需要额外改造。
第三是数据资产化方向。用户资料不仅仅是业务数据,随着规模增长,它会成为重要的数据资产。我在规划中预留了一个数据同步管道,将用户资料实时同步到数据仓库,供数据分析团队做用户画像、行为分析、增长实验。这个管道的建设要前置,不能等业务方提需求时才开始搭。我现在基本每天都会看一次同步任务的健康度,确保关键数据不落库。
这些规划不一定会全部落地,但提前思考的意义在于,当业务发展需要这些能力时,我不是从零开始,而是有了一条清晰的路径图。这让资料模块在知光项目的大盘中始终保持主动,而不是被动响应各种突发需求。
最后分享一个很实在的经验:在做基础模块时,一定要把设计决策背后的理由记录下来。当时选JSON扩展字段、选乐观锁、选多级缓存,每个决策都有详细的思考和取舍过程。半年后团队有新同学加入,看这些文档能快速理解模块的设计意图,不用重新踩一遍坑。这比写一万行代码都有价值。
用户资料模块未来还会遇到新的业务需求和技术挑战,但只要设计时把根扎稳了,后续的每一次改动就都是在稳固的地基上添砖加瓦。这个模块目前在知光项目中承担着每日数千万级的数据读写,稳定性一直保持在99.95%以上。我对这个结果还是满意的。