视频地址:https://kaiwu.lagou.com/course/courseInfo.htm?courseId=1198

作者:乔峰

配音:康老师


在实际面试中,我作为面试官,常常会出这样一道考题:

假设你现在的公司要在春节期间做一个红包雨活动,有 1 亿的金额,发出去10 亿个红包,TPS 预计在百万级,你要怎么设计?

你遇到过类似的题目吗?想一想,你有清晰的回答思路吗?

你可能会想,为什么很多大厂面试官都喜欢出这个题目?

首先这个场景很典型。电商大促和春晚红包是中国互联网的两个流量巅峰,新的应用与玩法层出不穷,也不断地给互联网技术带来各种挑战,特别是春节抢红包活动,并发流量一年比一年高。

并且,作为系统设计题,它不单单考查高并发的理论知识,还要求候选人结合业务实际,考虑到更多维度。

因此,如何设计一个高性能、高可靠的红包系统,成为各位系统架构师、高级开发人员的一项必备技能,也成为了现在的面试热点。

下面就来具体讲讲,对这样的面试题,你要如何理清设计思路,以及如何作答。

面试官如何考查系统设计题?

在开始分析面试题目前,先讲下系统设计题的回答套路。

很多人遇到系统设计题,要么就说自己没有相关经验,直接放弃了;要么就会条件反射,马上联想到网上看过的类似的设计方案,进入“八股文”背题模式,实际上这些回答都是很难达到面试官要求的。

1. 面试官究竟考查哪些维度?

系统设计,不像算法题或者基础知识题,考查单一维度的能力,而是会考查一个人的综合能力,这更接近真正工作时的表现。一般,面试官更看重以下几方面。

image.png

  • 沟通表达能力:面试官一开始给的信息一般不全面,例如红包雨的每个红包额度大小是否相等,每个用户抢到的红包数量有没有限制等,这些条件都需要你在面试过程中不断细化,逐步沟通清楚。
  • 知识广度和深度:系统设计会涉及很多中间件、数据库的知识,包括这些中间件的优缺点,应用场景,支撑的数据量等。
  • 技术和业务上的取舍:在实际业务中,我们很难把系统设计得十足完美,那么就要做出一些取舍,例如很多互联网场景为了追求高性能高吞吐通常放弃数据强一致性,只做到最终一致也是可以接受的。你在面试中可以结合实际经验来作方案的补充。

2. 系统设计目标是什么?

image.png

看到题目后,你首先要跟面试官确认有哪些重要的设计目标。一般来说,抢红包场景下,至少要考虑下面几点——

  • 高性能:主要是为了保证用户体验,即用户能尽快看到结果,尽快把抢到金额加到账户。
  • 高可靠:不能超发,红包超发或者促销活动超卖,都会给企业带来损失,所以这点是肯定要保证的。
  • 高可用:活动期间保证服务不挂。

接下来,我会从这 3 个设计目标出发,给你提供一些设计思路。其中,高性能和高可靠在实际业务中是设计方案的重点,这两方面跟抢红包的业务结合比较密切,所以我会详细阐述。

如何实现高性能?

1. 机房流量调度

看题目的性能指标,百万级的 TPS。如果流量入口是同一个的话那么肯定压力很大,用 Nginx 做负载均衡是顶不住的,要考虑 F5 这种商业设备。

Nginx 的性能是万级,一般的 Linux 服务器上装个 Nginx 大概能到 5 万/秒;LVS 的性能是十万级,据说可达到 80万/秒;F5 性能是百万级,从 200 万/秒到 800 万/秒都有。

如果我们不想用这种商业负载均衡设备,或者想尽量减少网络延迟,那么可以这样设计。
视频学习 | 大厂面试热门:如何设计一个红包雨系统 - 图3
直接把红包根据某种规则拆分好,放在不同的机房。不同地区的用户,在活动开始前就已经分配好了机房,可以是用 HTTPDNS 或者不同的域名来实现机房流量调度。

当用户抢红包或者查询红包结果时,只需在本地机房做处理就可以,最后再把结果通过 MQ 异步/同步到账户服务,完成最后一步。

当然,这里对业务是做出了一定牺牲的,红包金额同步到用户账户有一定延迟,用户红包没办法马上入账。

这样处理,我们就可以把 TPS 的数量级降下来。如果机房足够多,甚至以后边缘计算发展起来,后端抢红包服务基本上都不需要太关注大流量的问题了。

2. 后端服务设计

通过机房流量调度,我们可以把并发压力降下来,但是即使降了一个数量级,每个机房可能还有几十万的 TPS,怎么办呢?

继续分而治之。

image.png
视频学习 | 大厂面试热门:如何设计一个红包雨系统 - 图5
如图所示

  • 存储用到 Redis,这里的集群是指直接部署多个不同的实例,假设要达到 50万 TPS,按照单机 8万 TPS 来算,只需要 7 个实例,可以冗余部署到 10 个。然后在应用层做轮询,如果应用层发现有实例挂了马上剔除掉。每个 Redis 实例都会存储一个拆分好的 红包id+红包金额 list,抢红包的时候用 lua 脚本从 list 里面 pop 一个元素,同时记录到另外一个 list 里面,存储 uid+红包 id+红包金额。
  • 定时任务集群不断从 Redis 集群里面取 uid+金额数据,批量插入到 MySQL 集群,同时发送 MQ 通知账户服务入库。当然也可以在活动结束时,读取 MySQL 查询每个用户汇总结果,同步给账户服务,这样还能减少消息量。
  • MySQL 集群我们这里用 uid 为维度来分库,主要是用来查询用户已抢到的红包列表。

如何实现高可靠:红包拆分算法

前面通过一系列分治法,你能够把流量分散到不同地方,达到高性能的效果,用户马上可以看到抢了多少金额,也能尽快看到总金额,以及把金额放入账户,这就达到了第一个设计目标。而第二个目标,可以通过提前拆分红包来实现,那接下来看看红包要怎么拆。

1. 二倍均值法

剩余红包金额为 M,剩余人数为 N,那么有如下公式:

每次抢到的金额=随机区间(0,M/N X 2)

这个公式,保证了每次随机金额的平均值是相等的,不会因为抢红包的先后顺序而造成不公平。

假设有 10 个人,红包总额 100 元。100/10X2=20,所以第一个人的随机范围是(0,20),平均可以抢到 10 元。

如果第一个人随机抢到 10 元,那么剩余金额是 100-10=90 元。90/9X2=20 元,所以第二个人的随机范围同样是(0,20),平均可以抢到 10 元。

如果第二个人随机抢到 10 元,那么剩余金额是 90-10=80 元。80/8X2=20 元,所以第三个人的随机范围同样是(0,20),平均可以抢到 10 元。

以此类推,每一次随机范围的均值是相等的。可以参考以下 demo 代码:

  1. public static List<Integer> divideRedPackage(Integer totalAmount, Integer totalPeopleNum) {
  2. List<Integer> amountList = new ArrayList<Integer>();
  3. Integer restAmount = totalAmount;
  4. Integer restPeopleNum = totalPeopleNum;
  5. Random random = new Random();
  6. for (int i = 0; i < totalPeopleNum - 1; i++) {
  7. //不能从0开始随机 我们还得保证每个人分得的钱至少是0.01元
  8. //随机范围:[1,剩余人均金额的两倍),左闭右开
  9. int amount = random.nextInt(restAmount / restPeopleNum * 2 - 1) + 1;
  10. restAmount -= amount;
  11. restPeopleNum--;
  12. amountList.add(amount);
  13. }
  14. amountList.add(restAmount);
  15. return amountList;
  16. }
  17. public static void main(String[] args) {
  18. List<Integer> amountList = divideRedPackage(5000, 30);
  19. for (Integer amount : amountList) {
  20. System.out.println("抢到:" + new BigDecimal(amount).divide(new BigDecimal(100)) + "元");
  21. }
  22. }

2. 线段切割法

把红包总金额想象成一条很长的线段,而每个人抢到的金额,则是这条主线段所拆分出的若干子线段。

当 N 个人一起抢红包的时候,就需要确定 N-1 个切割点。

image.png

因此,当 N 个人一起抢总金额为 M 的红包时,我们需要做 N-1 次随机运算,以此确定 N-1 个切割点。

随机的范围区间是(1,100*M)。当所有切割点确定以后,子线段的长度也随之确定。这样每个人来抢红包的时候,只需要顺次领取与子线段长度等价的红包金额即可。

demo 代码:

  1. private static List<Integer> divideRedPackage(Integer totalAmount, Integer totalPeopleNum) {
  2. List<Integer> boards = new ArrayList<>();
  3. boards.add(0);
  4. boards.add(totalAmount);
  5. while (boards.size() <= totalPeopleNum) {
  6. int index = new Random().nextInt(totalAmount - 1) + 1;
  7. if (boards.contains(index)) {
  8. //保证切割点的位置不相同
  9. continue;
  10. }
  11. boards.add(index);
  12. }
  13. Collections.sort(boards);
  14. List<Integer> list = new ArrayList<>();
  15. for (int i = 0; i < boards.size() - 1; i++) {
  16. Integer e = boards.get(i + 1) - boards.get(i);
  17. list.add(e);
  18. }
  19. return list;
  20. }
  21. public static void main(String[] args) {
  22. List<Integer> amountList = divideRedPackage(5000, 30);
  23. for (Integer amount : amountList) {
  24. System.out.println("抢到:" + new BigDecimal(amount).divide(new BigDecimal(100)) + "元");
  25. }
  26. }

3. 两种算法优缺点

image.png

在面试中,你要先将这两种算法的优缺点进行比较说明,然后针对具体情况选择其中一种方法来实现。

  • 二倍均值法除了最后一次,任何一次抢到的金额都不会超过人均金额的两倍,相对来说不会给用户太多惊喜,它的优点是实现简单,空间复杂度低;线段切割法则相反。
  • 如开篇面试题的情况,红包总个数过大,那么可以考虑先分段,再用多个线程来拆分,可以提高效率。

两种算法各有千秋,所以你在面试过程中,要先和面试官沟通好,这个红包系统想要什么效果,再见招拆招。

4. 技术与业务的取舍:风控防刷

image.png

说到红包系统,还有一个重要设计方面是风控。根据实际业务经验来看,每年都会有一些囤积大量账号准备在春晚大发横财的公司和个人,而风控规则是很复杂的,需要从各个维度来做。

但是你需要注意一点,复杂风控规则跟高并发是矛盾的,每次请求都要走一遍是很耗性能的。那要如何权衡呢?

这里我们可以把复杂的风控规则后置。因为 抢红包——>金额加入账户——>使用账户金额,整个链路需要经过一段时间,所以可以利用这段时间来跑异步风控规则(例如跑风控模型等),实时规则只取简单的即可(例如控制频率、黑白名单等)。

这是技术和业务取舍之后的结果,你在面试中说出这些,能体现出你的经验丰富度和更多的思考。

如何实现高可用?

高可用、限流等技术,比较通用,如果你之前有过相关的项目经验可以灵活迁移。而具体到这个红包雨系统的高可用,你需要注意以下几点——

image.png

  • 中间件高可用:系统设计中尽量选择有成熟高可用方案的组件,例如上面的 Redis、MySQL、MQ 等。
  • 无状态服务弹性伸缩:对于抢红包服务,做成无状态的,有利于在机器不够的时候紧急扩容。
  • 压测和限流:活动上线前,一定要做好压测,预估好容量,并做好限流,保证流量大小在能承受的范围内,避免流量过大造成服务雪崩。

总结

以上,就针对面试题目,提供了一套高性能、高可靠、高可用的红包雨系统设计方案。你可以在之后的面试中从这些角度回答,也可以参考我提供的具体设计方案。

但是方案细节不一定适合你的每一场面试,关键在于,你在面试过程中一定要多沟通、多思考、多对比,才能体现出你的能力,从而达到面试官的要求。