✅百万级会员的用户平台,如何实现快到期的会员的消息提醒?

✅百万级会员的用户平台,如何实现快到期的会员的消息提醒?

典型回答

这是一个典型的方案设计类的题目,题目的难点和隐藏的考点有这么几个:

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的用户。

✅基于XXL-JOB的分片实现分库分表后的扫表

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识别,详见:

✅xxl-job 支持分片任务吗?实现原理是什么?

这样,就可以借助集群的能力,10台机器一起扫描这100万的数据。

当然,这里还可以进一步优化,因为上面的SQL中,查询用了**like "%0"****这个是没办法走索引的,**我们可以用like优化的方式,让能用到索引,这样就可以吧user_id和expire_time建一个联合索引,进一步提升查询性能。

✅MySQL中like的模糊查询如何优化

这里可以参考我的项目课中的实现,其实就是用一个逆序的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、失败监控

如果遇到大规模的失败,需要做好监控,可能是渠道出了问题,需要人工介入。