1. 项目背景与挑战
去年参与了一个面向中老年群体的垂直社交产品研发,这个项目让我对特定人群的数据库设计有了全新认识。与常规社交平台不同,中老年用户的使用习惯呈现出明显的"早高峰"特征——每天6:00-8:00的活跃度是平日的3-5倍,节假日期间内容生产量会暴增10倍以上。我们最初使用单机MySQL的方案,在用户量突破50万时开始出现严重的性能瓶颈。
最典型的问题是春节期间的"拜年消息风暴":大年初一上午的2小时内,系统要处理超过200万条祝福消息的收发,导致数据库连接池耗尽,消息延迟达到惊人的15分钟。这次事故让我们意识到,必须重构整个数据存储架构。经过三个月的迭代,我们最终通过分库分表方案将系统吞吐量提升了20倍,今天就来分享这段实战经历。
2. 数据库选型核心考量
2.1 中老年社交场景的特殊性
这类产品有几个关键特征需要特别关注:
- 内容形态简单但量大:以短文本、图片为主,单条内容体积小但并发写入高
- 访问模式高度集中:早晚高峰明显,且存在节假日突发流量
- 数据冷热分明:三个月前的互动数据访问量骤降80%以上
- 强地域属性:同城交友是核心功能,70%的社交关系发生在同省市范围内
2.2 候选方案对比测试
我们针对三种主流方案进行了压力测试(模拟10万并发用户):
| 方案 | QPS(读) | QPS(写) | 延迟(ms) | 成本(月) | 运维复杂度 |
|---|---|---|---|---|---|
| MySQL单机 | 1.2万 | 0.8万 | 120 | ¥800 | 低 |
| MySQL集群 | 3.5万 | 2.1万 | 65 | ¥3500 | 中 |
| MongoDB分片集群 | 5.8万 | 4.3万 | 28 | ¥4200 | 高 |
最终选择MySQL集群方案,主要基于:
- 团队技术栈匹配(已有成熟运维经验)
- 事务支持完善(支付、积分等强一致性场景)
- 地理空间索引可通过PostGIS扩展实现
- 冷数据归档方案成熟(配合ClickHouse)
关键决策点:虽然MongoDB性能更好,但考虑到中老年用户对数据准确性的敏感度(不能接受"消息已读却显示未读"),最终选择了强一致性的关系型方案。
3. 分库分表实施方案
3.1 数据拆分策略
采用"地域+时间"双维度分片:
- 水平分库:按省份划分(全国31个库)
- 水平分表:按月分表(如comments_202307)
- 垂直分表:将用户基础信息与社交关系分离
拆库逻辑示例:
sql复制-- 根据用户ID前缀确定库(前两位代表省份)
SELECT * FROM user_db_{province_code}.user_info
WHERE user_id LIKE '42%'; -- 湖北用户
3.2 路由中间件选型
对比了ShardingSphere和MyCat后,选择了ShardingSphere-Proxy 5.2.1,主要因为:
- 对MySQL协议完全兼容,客户端无需改造
- 支持弹性伸缩(后续新增节点不影响业务)
- 内置的SQL改写能力更强(特别是JOIN查询)
配置示例(schema.yaml):
yaml复制rules:
- !SHARDING
tables:
user_relation:
actualDataNodes: user_db_${0..30}.user_relation_${202301..202312}
databaseStrategy:
standard:
shardingColumn: province_code
preciseAlgorithmClassName: com.our.pkg.ProvinceShardingAlgorithm
tableStrategy:
standard:
shardingColumn: create_time
preciseAlgorithmClassName: com.our.pkg.MonthShardingAlgorithm
3.3 特殊场景处理
跨地域查询优化:
当用户查看"同城好友"时,通过本地缓存省份映射表,避免全库扫描:
java复制// 伪代码:先查缓存再定向查询
List<String> provinceCodes = geoCache.getProvinceByCity(request.getCity());
String sql = "SELECT * FROM user_db_"+ provinceCodes[0] +".user_info...";
历史数据归档:
每月1号凌晨自动执行归档任务:
- 将12个月前的关系数据迁移到ClickHouse
- 保留MySQL中最近3个月的"热数据"
- 建立双向同步通道(通过Canal)
4. 性能优化关键点
4.1 索引设计原则
针对中老年用户行为特点,我们制定了特殊的索引策略:
- 消息表:组合索引 (receiver_id, send_time) DESC
- 动态表:覆盖索引 (user_id, visible, create_time)
- 关系表:唯一索引 (user_id, friend_id) + 反向索引
血泪教训:曾因在500万数据表上漏建visible字段索引,导致首页查询从50ms飙升到2.3s
4.2 连接池调优
配置项对比(基于Druid连接池):
| 参数 | 初始值 | 优化值 | 效果 |
|---|---|---|---|
| initialSize | 5 | 30 | 避免冷启动连接延迟 |
| maxActive | 50 | 120 | 支撑早高峰流量 |
| minIdle | 5 | 20 | 维持备用连接 |
| maxWait (ms) | 3000 | 800 | 快速失败降级 |
| timeBetweenEviction | 300000 | 60000 | 及时回收空闲连接 |
4.3 缓存策略设计
采用三级缓存架构:
- 本地缓存(Caffeine):用户基础信息,TTL=5分钟
- 分布式缓存(Redis):社交关系,TTL=1小时
- CDN缓存:用户头像等静态资源,TTL=7天
缓存更新策略特别重要:
java复制// 伪代码:先更新DB再失效缓存
@Transactional
public void updateUserInfo(User user) {
userDao.update(user);
redis.del("user:"+user.getId());
localCache.invalidate(user.getId());
}
5. 典型问题与解决方案
5.1 热点问题处理
春节期间发现湖北库的QPS是其他库的8倍,解决方案:
- 动态扩容:将湖北库拆分为湖北_武汉、湖北_其他两个库
- 读写分离:为热点库配置3个只读副本
- 请求限流:对非关键接口实施令牌桶限流(2000请求/秒)
5.2 分布式事务挑战
在"加好友+发消息"场景下,我们对比了三种方案:
| 方案 | 成功率 | 平均耗时 | 实现复杂度 |
|---|---|---|---|
| 本地消息表 | 99.2% | 120ms | 低 |
| Seata AT模式 | 99.8% | 210ms | 高 |
| 最终一致性(事件驱动) | 99.5% | 85ms | 中 |
最终选择事件驱动方案,通过RocketMQ实现:
java复制// 发送准备消息
Message prepMsg = new Message(topic,
JSON.toJSONString(new FriendEvent(userId, friendId)));
prepMsg.setTags("PREPARE");
SendResult sendResult = producer.send(prepMsg);
// 本地事务执行
boolean dbSuccess = relationDao.insertRelation(userId, friendId);
// 提交或回滚
Message resolveMsg = new Message(topic, prepMsg.getKeys());
resolveMsg.setTags(dbSuccess ? "COMMIT" : "ROLLBACK");
producer.send(resolveMsg);
5.3 监控体系建设
搭建的监控指标包括:
- 数据库层:连接池使用率、慢查询占比、复制延迟
- 中间件层:ShardingSphere路由耗时、SQL改写正确率
- 业务层:好友添加成功率、消息送达延迟
使用Grafana配置的关键看板:
- 地域分布热力图(按省份着色显示QPS)
- 分库分表均衡度监测(标准差<15%为健康)
- 节假日流量预测(基于历史数据+机器学习)
6. 成果与经验总结
经过半年运行,系统关键指标变化:
- 峰值QPS从1.5万提升到32万
- 消息送达延迟从15分钟降至800ms
- 月度运维成本降低40%(主要靠冷数据归档)
几点重要心得:
- 预分片比动态扩容更可靠:我们提前预留了10个空库,后续扩容只需导入数据无需停服
- 不要过度设计:初期曾考虑用TiDB,后来证明MySQL分片完全能满足需求
- 监控要走在故障前:现在当某个库QPS达到均值2倍时就会触发告警
- 中老年用户更在意稳定性:任何超过3秒的加载都会导致10%的用户流失
这个项目让我深刻体会到,数据库架构设计必须紧密结合业务特征。对于中老年社交这类特殊场景,比起追求技术先进性,保证系统稳定可靠才是首要目标。