✅百万级会员的用户平台,如何实现快到期的会员的消息提醒?
典型回答
这是一个典型的方案设计类的题目,题目的难点和隐藏的考点有这么几个:
1、百万级数据量
- 如何快速查询出要到期的数据
- 如何高效的针对大批量用户做推送
2、快到期的会员
- 如何知道哪些用户快到期了
3、消息提醒
- 用具体什么样的提醒方式
- 如何避免一个用户被频繁提醒
逐一拆解一下这几个问题吧。
首先可以明确的是,**百万级会员,这个量级不算大!!!**才百万级,根本不需要上分库分表、读写分离,直接单表就能抗,稍微有点索引就能扛得住了。
如果你查GPT或者DeepSeek,他会告诉你百万级太大了,需要做分库分表了,甚至有的还告诉你用到期时间做个分区。。。这根本就不靠谱,这个数据量根本不需要,而且到期时间是可能会变的,用一个可变字段做分区,这不是坑人么。
还有的是说针对这些数据做冷热分离,将历史过期会员归档到独立表中,减少主表数据量。。。这也完全是过度设计,百万级的数据量,根本不需要做归档。
需不需要一张消息推送表
针对消息推送的这个场景,我们其实是需要一张表记录下所有的推送的(有的时候可以不建表,可以通过固定的格式打印日志,然后拉取日志之后解析日志。)。
有这样表的好处是可以知道具体的推送的情况,什么时间、用什么渠道、给谁做了推送,推送了什么内容,结果是什么,都比较清晰。
有这样表之后,还可以有一个好处就是可以做幂等控制、疲劳度控制、以及失败的重试、还有数据分析。可以参考以下设计:
| 字段名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| id | BIGINT UNSIGNED | 是 | 自增 | 主键,唯一标识 |
| user_id | BIGINT UNSIGNED | 是 | - | 接收用户ID(与用户表关联) |
| message_type | VARCHAR(20) | 是 | - | 消息类型(如:**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">到期提醒</font>**) |
| channel | VARCHAR(20) | 是 | - | 推送渠道(如:**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">sms</font>**/**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">email</font>**/**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">app_push</font>**/**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">站内信</font>**) |
| title | VARCHAR(200) | 否 | NULL | 消息标题(邮件主题、推送标题) |
| content | TEXT | 是 | - | 消息内容(支持模板变量,如**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">{username}</font>**) |
| status | TINYINT | 是 | 0 | 状态(**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">0=待发送</font>**/**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">1=已发送</font>**/**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">2=发送失败</font>**/**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">3=已重试</font>**) |
| send_time | DATETIME | 否 | NULL | 实际发送时间 |
| create_time | DATETIME | 是 | NOW() | 创建时间 |
| update_time | DATETIME | 是 | NOW() | 更新时间 |
| retry_count | TINYINT UNSIGNED | 是 | 0 | 重试次数(超过阈值后标记为失败) |
| third_msg_id | VARCHAR(100) | 否 | NULL | 第三方平台消息ID(如短信服务商返回的ID,用于对账) |
| error_info | TEXT | 否 | NULL | 错误详情 |
| is_read | TINYINT(1) | 是 | 0 | 是否已读(仅对站内信有效,**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">0=未读</font>**/**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">1=已读</font>**) |
| click_time | DATETIME | 否 | NULL | 用户点击时间(用于统计转化率) |
| extra_info | JSON | 否 | NULL | 扩展字段(如:模板参数、业务上 |
如何识别即将到期的用户
首先,我们可以在会员表中增加一个到期时间的字段,如expire_time,然后针对这个字段建立一个索引。这样我们在查询的时候,根据 expire_time < now() 就能一次性的捞出所有已经过期的用户了,如果想要查询还有三天过期的用户,那么可以用以下SQL:
SELECT id
FROM users
WHERE expire_time
BETWEEN CURDATE() + INTERVAL 3 DAY
AND CURDATE() + INTERVAL 4 DAY - INTERVAL 1 SECOND;不要用DATEDIFF函数,这样会使索引失效。用范围查询就可以了。
如何高效的查询这些用户
完成了数据库的设计之后,我们就可以通过以上的SQL查询出这些用户了,按照上面的配置来看,只需要一天执行一次任务就行了。
为了提升查询的性能,我们可以通过分布式任务框架来实现,借助分布式任务的集群能力,可以更快的查询出所有即将到期的用户。
比如我们使用XXL-JOB的分片任务,按照用户ID进行分片,比如我们有10台机器,那么就可以分表扫描尾号为0-9的用户。
SELECT id
FROM users
WHERE expire_time
BETWEEN CURDATE() + INTERVAL 3 DAY
AND CURDATE() + INTERVAL 4 DAY - INTERVAL 1 SECOND and user_id like "%0";
SELECT id
FROM users
WHERE expire_time
BETWEEN CURDATE() + INTERVAL 3 DAY
AND CURDATE() + INTERVAL 4 DAY - INTERVAL 1 SECOND and user_id like "%9";至于每台机器如何知道自己要扫尾号是多少的数据,可以基于分片任务传进来的shardIndex和shardTotal识别,详见:
这样,就可以借助集群的能力,10台机器一起扫描这100万的数据。
当然,这里还可以进一步优化,因为上面的SQL中,查询用了**like "%0"****这个是没办法走索引的,**我们可以用like优化的方式,让能用到索引,这样就可以吧user_id和expire_time建一个联合索引,进一步提升查询性能。
这里可以参考我的项目课中的实现,其实就是用一个逆序的user_id就行了,即原来的用户id是12345,把他按照54321的方式同时保存在reverse_user_id字段上,然后查询就变成了:
SELECT id
FROM users
WHERE expire_time
BETWEEN CURDATE() + INTERVAL 3 DAY
AND CURDATE() + INTERVAL 4 DAY - INTERVAL 1 SECOND and reverse_user_id like "0%";
SELECT id
FROM users
WHERE expire_time
BETWEEN CURDATE() + INTERVAL 3 DAY
AND CURDATE() + INTERVAL 4 DAY - INTERVAL 1 SECOND and reverse_user_id like "9%";为啥不直接用user_id做like “xxx%"?因为用户id如果是自增生成的,尾号会更加均匀一些。
如何高效的做消息推送
上面我们用分片任务可以高效的把要到期的用户都查出来了,那查出来之后就要推送消息了,如何更加高效的推送呢?
好的办法就是异步推送。
这样可以把扫表任务和推送任务解耦开,扫表不需要等推送。各自干各自的就行了。
这里可以借助MQ消息队列实现解耦+异步的作用。讲定时任务扫描出来的要到期的user_id推送到消息队列中,消息队列监听到消息之后,再做消息推送。
如果这里性能还要继续提升,可以用批量消息,一次拉过来一批消息,然后用多线程推送就行了。
用什么样的提醒方式
这个就是个业务问题了,一般消息提醒有很多手段,包括站内信、push、短信、邮件、电话、钉钉、企业微信等等很多的。那么具体用什么样的方式应该是可以配置和选择的。
但是需要注意的是,这里面有一些方式是需要依赖第三方的,比如短信、电话、钉钉、企微等,所以需要做好流量控制,别被限流了。
这里还需要注意的就是,有些渠道的推送是同步能拿到结果的,有些可能是异步才能拿到结果的,需要注意。
如何避免一个用户被频繁提醒
做消息通知的,最重要的一个问题就是防疲劳,不能频繁的给用户推送,这样会引来客诉,尤其是短信和电话这种。
那么就需要做好防疲劳的控制,避免一个用户被频繁提醒。
那么就根据业务上设定的阈值,比如1天最多推送一次,设置疲劳度。实现方案上,可以借助Redis来实现,因为他更加的高效。
可以设置一个key,记录一次消息推送,比如 user_id:message_type,然后设置24小时的超时时间,每一次在发送通知前,先检查这个key是否存在,如果不存在再发送,发送后把这个key设置上值和超时时间。确保不要重复提醒。
另外,防疲劳还有个要求,就是针对一些特殊的时间点,比如每天晚上10点以后,节假期,特殊日子等,不要推送了。需要在系统中配置一些前置拦截的规则。
异常情况处理
以上都做好之后,就不是万事大吉了,还会有各种异常情况,都需要考虑的。
1、失败重试
任务可能会失败的,外部渠道在推送的时候也可能会失败,所以需要有一个重试的机制,基于消息表的状态可以做重试。但是重试也不要无脑一直重试,可以设定一个阈值,重试一定次数之后就不要再重试了。
2、消息表数据堆积
随着推送越来越多,消息表会越来越大,数据越来越多,可以考虑做一些数据归档,把历史的数据归档掉。
3、失败监控
如果遇到大规模的失败,需要做好监控,可能是渠道出了问题,需要人工介入。