面试官:如何设计一个排队系统、pk系统

先赞后看,Java进阶一大半

在这里插入图片描述

各位好,我是南哥,相信对你通关面试、拿下Offer有所帮助。

⭐⭐⭐一份南哥编写的《Java学习/进阶/面试指南》:https://github/JavaSouth

1.排队功能设计

1.1 数据结构

排队的一个特点是一个元素排在另一个元素的后面,形成条状的队列。List结构、LinkedList链表结构都可以满足排队的业务需求,但如果这是一道算法题,我们要考虑的是性能因素。

排队并不是每个人都老老实实排队,现实会有多种情况发生,例如有人退号,那属于这个人的元素要从队列中删除;特殊情况安排有人插队,那插入位置的后面那批元素都要往后挪一挪。结合这个情况用LinkedList链表结构会更加合适,相比于List,LinkedList的性能优势就是增、删的效率更优。

但我们这里做的是一个业务系统,采用LinkedList这个结构也可以,不过要接受修改、维护起来困难,后面接手程序的人难以理解。大家都知道,在实际开发我们更常用List,而不是LinkedList。

List数据结构我更倾向于把它放在Redis里,有以下好处。

(1)数据存储与应用程序拆分。放在应用程序内存里,如果程序崩溃,那整条队列数据都会丢失。

(2)性能更优。相比于数据库存储,Redis处理数据的性能更加优秀,结合排队队列排完则销毁的特点,甚至可以不存储到数据库。可以补充排队记录到数据库里。

简单用Redis命令模拟下List结构排队的处理。

# 入队列(将用户 ID 添加到队列末尾)
127.0.0.1:6379> RPUSH queue:large user1
127.0.0.1:6379> RPUSH queue:large user2

# 出队列(将队列的第一个元素出队)
127.0.0.1:6379> LPOP queue:large

# 退号(从队列中删除指定用户 ID)
127.0.0.1:6379> LREM queue:large 1 user2

# 插队(将用户 ID 插入到指定位置,假设在 user1 之前插入 user3)
127.0.0.1:6379> LINSERT queue:large BEFORE user1 user3

1.2 业务功能

先给大家看看,南哥用过的费大厨的排队系统,它是在公众号里进行排队。

我们可以看到自己现在的排队进度。

在这里插入图片描述

同时每过 10 号,公众号会进行推送通知;如果 10 号以内,每过 1 号会微信公众号通知用户实时排队进度。最后每过 1 号就通知挺人性化,安抚用户排队的焦急情绪。

在这里插入图片描述

总结下来,我们梳理下功能点。虽然上面看起来是简简单单的查看、通知,背后可能隐藏许多要实现的功能。

在这里插入图片描述

1.3 后台端

(1)排队开始

后台管理员创建排队活动,后端在Redis创建List类型的数据结构,分别创建大桌、中桌、小桌三条队列,同时设置没有过期时间。

// 创建排队接口
@Service
public class QueueManagementServiceImpl {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // queueType为桌型
    public void createQueue(String queueType) {
        String queueKey = "queue:" + queueType;
        redisTemplate.delete(queueKey); // 删除队列,保证队列重新初始化
    }
}

(2)排队操作

前面顾客用餐完成后,后台管理员点击下一号,在Redis的表现为把第一个元素从List中踢出,次数排队的总人数也减 1。

// 排队操作
@Service
public class QueueManagementServiceImpl {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 将队列中的第一个用户出队
     */
    public void dequeueNextUser(String queueType) {
        String queueKey = "queue:" + queueType;
        String userId = redisTemplate.opsForList().leftPop(queueKey);
    }
}

1.4 用户端

(1)点击排队

用户点击排队,把用户标识添加到Redis队列中。

// 用户排队
@Service
public class QueueServiceImpl {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public void enterQueue(String queueType, String userId) {
        String queueKey = "queue:" + queueType;
        redisTemplate.opsForList().rightPush(queueKey, userId);
        log.info("用户 " + userId + " 已加入 " + queueType + " 队列");
    }
}

(2)排队进度

用户可以查看三条队列的总人数情况,直接从Redis三条队列中查询队列个数。此页面不需要实时刷新,当然可以用WebSocket实时刷新或者长轮询,但具备了后面的用户通知功能,这个不实现也不影响用户体验。

而用户的个人排队进度,则计算用户所在队列前面的元素个数。

// 查询排队进度
@Service
public class QueueServiceImpl {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public long getUserPositionInQueue(String queueType, String userId) {
        String queueKey = "queue:" + queueType;
        List<String> queue = redisTemplate.opsForList().range(queueKey, 0, -1);
        if (queue != null) {
            return queue.indexOf(userId);
        }
        return -1;
    }
}

(3)用户通知

当某一个顾客用餐完成后,后台管理员点击下一号。此时后续的后端逻辑应该包括用户通知。

从三个队列里取出当前用户进度是 10 的倍数的元素,微信公众号通知该用户现在是排到第几桌了。

从三个队列里取出排名前 10 的元素,微信公众号通知该用户现在的进度。

// 用户通知
@Service
public class NotificationServiceImpl {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

 private void notifyUsers(String queueType) {
        String queueKey = "queue:" + queueType;
        // 获取当前队列中的所有用户
        List<String> queueList = jedis.lrange(queueKey, 0, -1);

        // 通知排在10的倍数的用户
        for (int i = 0; i < queueList.size(); i++) {
            if ((i + 1) % 10 == 0) {
                String userId = queueList.get(i);
                sendNotification(userId, "您的排队进度是第 " + (i + 1) + " 位,请稍作准备!");
            }
        }

        // 通知前10位用户
        int notifyLimit = Math.min(10, queueList.size()); // 避免队列小于10时出错
        for (int i = 0; i < notifyLimit; i++) {
            String userId = queueList.get(i);
            sendNotification(userId, "您已经在前 10 位,准备好就餐!");
        }
    }
}

这段逻辑应该移动到前面后台端的排队操作。

1.5 存在问题

上面的业务情况,实际上排队人员不会太多,一般会比较稳定。但如果每一条队列人数激增的情况下,可以预见到会有问题了。

对于Redis的List结构,我们需要查询某一个元素的排名情况,最坏情况下需要遍历整条队列,时间复杂度是O(n),而查询用户排名进度这个功能又是经常使用到。

对于上面情况,我们可以选择Redis另一种数据结构:Zset。有序集合类型Zset可以在O(lgn)的时间复杂度判断某元素的排名情况,使用ZRANK命令即可。

# zadd命令添加元素
127.0.0.1:6379> zadd 100run:ranking 13 mike
(integer) 1
127.0.0.1:6379> zadd 100run:ranking 12 jake
(integer) 1
127.0.0.1:6379> zadd 100run:ranking 16 tom
(integer) 1
# zrank命令查看排名
127.0.0.1:6379> zrank 100run:ranking jake
(integer) 0
127.0.0.1:6379> zrank 100run:ranking tom
(integer) 2
# zscore判断元素是否存在
127.0.0.1:6379> zscore 100run:ranking jake
"12"

2. pk功能设计

2.1 pk玩法

直播pk的功能,要设计出来看起来容易,实则一点都不简单。直播pk玩法在抖音、虎牙、斗鱼各大平台都有出现,能帮互联网公司、主播赚不少钱。

南哥先说说pk的玩法是如何如何?它的流程是这样,主播点击申请pk按钮,匹配其他同时申请pk的主播,粉丝通过送礼给心爱的主播提高pk进度条,pk结束后失败的一方主播接受惩罚。但惩罚又有何妨呢,失败的主播也赚到收益了。

看看直播pk的大概界面。

在这里插入图片描述

2.2 pk进度条

pk进度条数据我们打算存储到高性能内存数据库Redis,这里使用Redis的Map结构,存储两个pk主播的进度条数据。

# Map的k-v结构
pk:progress:pk_id = [{主播A : 100}, {主播B : 90}]

但进度条数据主要是提供给在pk开始后才进来直播间的观众,这类人进行直播间后,客户端调用pk进度的查询接口,获取最新的pk进度条。

// 查询pk进度条接口
public Map<Object, Object> getPKProgress(String pkId) {
    String pkProgressKey = "pk:progress:" + pkId;
    return redisTemplate.opsForHash().entries(pkProgressKey);
}

而处于直播间的用户的进度条增加,我们给他设计为WebSocket数据实时推送,只要主播的进度有增加,把增加的数值推送到所有在pk直播间的用户。

但有个问题,如果刚进来的观众第一次进来直播间后,他获取了最新的pk进度。此时刚好某个主播的pk进度增加,但由于是新进来的观众,WebSocket数据推送不到这个最新用户,怎么办

这涉及到数据一致性的问题!我们可以在用户进入直播间后,每隔一段时间调用以上的接口,获取pk最新进度条,进行数据纠正

同时,在pk结束后,仍然要调用一次查询接口,确保不会出现这个情况:欸,主播你的分数明明比她高,怎么输了呢?这个情况还是数据不一致的问题。

2.3 pk匹配

主播点击pk申请按钮,我们把主播id与直播间信息加入到pk匹配池。

这个pk池子我们依然利用Redis,采用Redis五大基本数据类型之一:Zset。Zset的元素存储主播id与直播间id,元素的score存储主播的pk积分。那Zset会根据主播的积分进行顺序排序。

后面就是匹配算法的设计了,通过匹配算法 + Zset主播的积分,挑选出积分相近的两个pk主播进行匹配。

# Zset结构:
pk:matching_pool = [{anchor_id_1_room_id_1 : 100}, {anchor_id_2_room_id_2 : 110}]

南哥上面这几个关键数据结构都存储在Redis,我们要保证Redis的高可用性。那用Redis集群可以吗?

如果采用这种Redis架构,因为Redis集群把键值分为16384个槽给到各个集群节点,建议给集群里每个节点配上从节点,即集群架构搭配主从模型。防止某个集群节点失效了导致数据全部丢失。

2.4 pk倒计时

每场pk都有倒计时,这里我们在pk匹配成功时就在Redis里设置一个倒计时键值对,该键值对的初始值是本场pk的总pk时间。

// 设置pk倒计时
public void setPKCountdown(String pkId, int totalTime) {
    String pkCountdownKey = "pk:countdown:" + pkId;
    
    // 在 Redis 中设置倒计时
    redisTemplate.opsForValue().set(pkCountdownKey, totalTime, totalTime, TimeUnit.SECONDS);
}

2.5 pk流程设计

总结上文,清晰地梳理下整个pk流程的设计。

主播发送pk申请 -> 匹配 -> 成功则WebSockett推送成功通知、倒计时信息 -> 创建监控线程 -> pk中 -> pk结算

首先两个主播在客户端点击pk申请按钮,申请请求到达后端,客户端告知主播:pk匹配中

主播申请后,后端服务把主播加入pk匹配池。而专门用于配对pk主播的微服务持续处理pk池子中请求,合适则把两个主播进行pk配对,同时把两个主播踢出pk匹配池。

当然匹配成功后还有后续流程需要处理,配对成功后使用WebSocket服务端主动推送技术,实时告知主播包括直播间用户:pk已配对成功。

同时,在Redis创建上文1.3节的pk倒计时,同步也推送给主播包括观众。

在后台,我们还需要创建一个监控线程,来去监控pk是否结束,当结束时进行pk结算,告知观众与主播究竟哪一方获胜。

监控线程取自监控线程池子,方便线程复用,线程池的最大好处就是减少了系统频繁创建、销毁线程带来的资源消耗。

// 从监控线程池获取一个线程
public void monitorPKProgress(String pkId) {
    ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(10);
    
    scheduler.scheduleAtFixedRate(() -> {
        // 检查倒计时是否结束
        String countdownKey = "pk:countdown:" + pkId;
        Integer countdown = (Integer) redisTemplate.opsForValue().get(countdownKey);
        if (countdown != null && countdown <= 0) {
            System.out.println("PK结束,开始结算...");
            // 调用结算逻辑
        }
    }, 0, 1, TimeUnit.SECONDS);  // 每秒监控一次
}

2.6 WebSocket长连接问题

pk匹配成功通知、pk进度条增加等,都需要WebSocket技术去实时推送数据。

但一个直播间成千上万个观众,大多数观众的客户端都长连接着不同的WebSocket服务器。要推送数据时,怎么知道要从哪些WebSocket服务器进行推送??

(1)集中式连接状态管理

有一些公司WebSocket服务器只有固定一台,推送数据时绑定这台服务器的ip即可,也不需要处理我们讨论的问题。

我们把用户的连接信息,包括用户id、长连接的WebSocket服务器地址,都存储在Redis中进行集中式的状态管理。当要推送数据时,获取用户所在WebSocket服务器地址即可。

(2)广播推送

进行数据推送时,对所有WebSocket服务器进行消息广播。接收到广播消息后,服务器检查本地是否有该用户的连接信息,如果有则进行消息推送。

(3)WebSocket集群框架

如果WebSocket框架使用的是Socket.IO的话,以上的问题已经有很好的集群解决方案了。Socket.IO Redis adapter适配器可以将事件广播到多个单独的 socket.io 服务器节点,用于在多台WebSocket服务器共享连接状态。

我是南哥,南就南在Get到你的点赞点赞点赞。

在这里插入图片描述

看了就赞,Java进阶一大半。点赞 | 收藏 | 关注,各位的支持就是我创作的最大动力❤️

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值