这项目快把我逼疯了,上个月用户量刚过五万,API响应时间直接从200毫秒飙到1.2秒。我盯着New Relic的监控曲线,那根线爬得比我的血压还快。问题出在“扩展性”这三个字上,我们当初为了快速上线,把所有用户行为数据——点击、浏览、停留时长——全塞进了一张叫`user_events`的大宽表里。字段倒是定义得挺全,`user_id`, `event_type`, `page_url`, `element_id`, `timestamp`,还加了十几个`custom_attr_*`的扩展字段以备不时之需。结果呢?这个“不时之需”成了每天的常态。
现在这张表有四千多万行,单日增量接近百万。最要命的是我们的核心分析查询:要按用户、按事件类型、按时间范围做聚合,算出转化漏斗。原来的查询一跑就是十几秒,MySQL的CPU直接打满,`SELECT COUNT(DISTINCT user_id) FROM user_events WHERE event_type IN (‘page_view’, ‘button_click’) AND timestamp BETWEEN ? AND ? GROUP BY DATE(timestamp)` 这种语句成了性能杀手。索引加了又加,从联合索引到覆盖索引,甚至试了分区表,但数据增长的速度永远比优化快一步。团队里的小孩问我能不能上Elasticsearch,我心想,这他妈是架构问题,不是换引擎就能解决的。根子在于,我们混淆了“流水”和“状态”。
所以这次重构,我彻底推翻了“一张表打天下”的思路。核心就一条:把高频、实时要求高的核心业务数据,和低频、用于深度分析的行为数据彻底拆开。这不是分库分表那种物理拆分,而是逻辑和模型上的分离。
新设计了三张表:
第一张是 `user_sessions`,解决“谁在什么时候干了什么”的实时问题。字段极简:`session_id` (UUID), `user_id`, `started_at`, `ended_at`, `last_active_at`。这里只记录会话的生命周期,所有明细行为都不放进来。前端SDK每30秒发一个心跳来更新`last_active_at`,判断用户是否在线、计算实时活跃人数,查这张表就够了,数据量小,索引就一个`(user_id, last_active_at)`。
第二张是 `core_events`,只存放决定性的、影响业务状态的事件。比如“提交订单”、“支付成功”、“加入购物车”。这张表需要强一致,而且经常需要和用户、订单表做JOIN。所以字段包括 `event_id`, `user_id`, `event_type` (枚举值,严格控制类型数量), `entity_id` (如订单ID), `entity_type`, `occurred_at`。它必须快,所以放在和主业务同一个数据库实例里,利用现有的事务保证。
第三张才是重头戏 `analytics_events`,这才是原来那张巨无霸表的继承者,但只做一件事:存储所有原始行为流水,用于离线分析和数据挖掘。它和业务完全解耦,写入可以走异步队列,甚至可以直接用Kafka灌进数据仓库。表结构反而更“宽”了,除了基础字段,还有一个 `JSON` 类型的 `properties` 字段,把所有自定义属性、浏览器信息、设备指纹全扔进去。查询?不用实时查它。我们通过Airflow每天凌晨跑ETL任务,把`analytics_events`里的数据按需聚合,生成新的聚合表(比如每日用户行为摘要`user_daily_stats`),供BI工具和报表查询。这样,在线业务的数据库压力就卸掉了。
设计完这套结构,画ER图的时候,我手有点抖。不是激动,是后怕。2018年做第一个爬虫项目的时候,我也干过类似的事,把抓到的原始HTML全塞进一个`pages`表里,后来要解析结构化数据,差点没把自己累死。历史总是螺旋式上升,只不过这次代价更大。团队里有人质疑,说复杂度增加了,开发要适配多张表。我直接怼回去:现在的复杂度是隐形的,藏在每晚的数据库告警里,藏在产品经理抱怨“报表为什么又刷不出来”的唾沫星子里。我们把复杂度显性化、提前化了,用开发时多一点思考,换线上运行时少一百次救火。
数据架构没有银弹,但一定有“更不坏”的选择。这次重构,我赌的就是业务增长的曲线,会比我们优化速度的曲线更陡。如果赌错了,大不了再拆一次。至少我知道,下一次该从哪里开始拆了。














