从构建分布式秒杀系统聊聊WebSocket推送通知

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 前言 秒杀架构到后期,我们采用了消息队列的形式实现抢购逻辑,那么之前抛出过这样一个问题:消息队列异步处理完每个用户请求后,如何通知给相应用户秒杀成功? 场景映射 首先,我们举一个生活中比较常见的例子:我们去银行办理业务,一般会选择相关业务打印一个排号纸,然后就可以坐在小板凳上玩着手机,等待被小喇叭报号。

前言

秒杀架构到后期,我们采用了消息队列的形式实现抢购逻辑,那么之前抛出过这样一个问题:消息队列异步处理完每个用户请求后,如何通知给相应用户秒杀成功?

场景映射

timg

首先,我们举一个生活中比较常见的例子:我们去银行办理业务,一般会选择相关业务打印一个排号纸,然后就可以坐在小板凳上玩着手机,等待被小喇叭报号。当小喇叭喊到你所持有的号码,就可以拿着排号纸去柜台办理自己的业务。

这里,假设当我们取排号纸的时候,银行根据时间段内的排队情况,比较人性化的提示用户:排队人数较多,您是否继续等待?否的话我们可以换个时间段再来办理。

由此我们把生活场景映射到真实的秒杀业务逻辑中来:

  • 我们可以把柜台比喻成商品下单处理逻辑单元
  • 拿到排号纸说明你进入相应商品处理队列
  • 拿到排号纸的请求直接返回前台,提示用户抢购进行中
  • 排号纸进入队列后,等待商品业务处理逻辑
  • 小喇叭叫到自己的排号相当于服务端通知用户秒杀成功,这时候可以进行支付逻辑
  • 那些拿不到票号的同学,相当于队列已满直接返回秒杀失败

解决方案

通过上面的场景,我们很容易能够想到一种方案就是服务端通知,那么如何做到服务端异步通知的呢?下面,主角开始登场了,就是我们的Websocket。

1327868440

WebSocket是HTML5开始提供的一种浏览器与服务器间进行全双工通讯的网络技术。依靠这种技术可以实现客户端和服务器端的长连接,双向实时通信。

bg2017051502

特点:

  • 异步、事件触发
  • 可以发送文本,图片等流文件
  • 数据格式比较轻量,性能开销小,通信高效
  • 使用ws或者wss协议的客户端socket,能够实现真正意义上的推送功能

缺点:

  • 部分浏览器不支持,浏览器支持的程度与方式有区别,需要各种兼容写法。

集成案例

由于我们的秒杀架构项目案例中使用了SpringBoot,因此集成webSocket也是相对比较简单的。

首先pom.xml引入以下依赖:

<!-- webSocket 秒杀通知-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

WebSocketConfig 配置:

/**
 * WebSocket配置
 * 创建者  爪哇笔记
 * 创建时间    2018年5月29日
 */
@Configuration  
public class WebSocketConfig {  
    @Bean  
    public ServerEndpointExporter serverEndpointExporter() {  
        return new ServerEndpointExporter();  
    }  
} 

WebSocketServer 配置:

@ServerEndpoint("/websocket/{userId}")
@Component
public class WebSocketServer {
    private final static Logger log = LoggerFactory.getLogger(WebSocketServer.class);
    //静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
    private static int onlineCount = 0;
    //concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
    private static CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<WebSocketServer>();

    //与某个客户端的连接会话,需要通过它来给客户端发送数据
    private Session session;

    //接收userId
    private String userId="";
    /**
     * 连接建立成功调用的方法*/
    @OnOpen
    public void onOpen(Session session,@PathParam("userId") String userId) {
        this.session = session;
        webSocketSet.add(this);     //加入set中
        addOnlineCount();           //在线数加1
        log.info("有新窗口开始监听:"+userId+",当前在线人数为" + getOnlineCount());
        this.userId=userId;
        try {
             sendMessage("连接成功");
        } catch (IOException e) {
            log.error("websocket IO异常");
        }
    }

    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose() {
        webSocketSet.remove(this);  //从set中删除
        subOnlineCount();           //在线数减1
        log.info("有一连接关闭!当前在线人数为" + getOnlineCount());
    }

    /**
     * 收到客户端消息后调用的方法
     * @param message 客户端发送过来的消息*/
    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("收到来自窗口"+userId+"的信息:"+message);
        //群发消息
        for (WebSocketServer item : webSocketSet) {
            try {
                item.sendMessage(message);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("发生错误");
        error.printStackTrace();
    }
    /**
     * 实现服务器主动推送
     */
    public void sendMessage(String message) throws IOException {
        this.session.getBasicRemote().sendText(message);
    }
    /**
     * 群发自定义消息
     * */
    public static void sendInfo(String message,@PathParam("userId") String userId){
        log.info("推送消息到窗口"+userId+",推送内容:"+message);
        for (WebSocketServer item : webSocketSet) {
            try {
                //这里可以设定只推送给这个userId的,为null则全部推送
                if(userId==null) {
                    item.sendMessage(message);
                }else if(item.userId.equals(userId)){
                    item.sendMessage(message);
                }
            } catch (IOException e) {
                continue;
            }
        }
    }

    public static synchronized int getOnlineCount() {
        return onlineCount;
    }

    public static synchronized void addOnlineCount() {
        WebSocketServer.onlineCount++;
    }

    public static synchronized void subOnlineCount() {
        WebSocketServer.onlineCount--;
    }
}

KafkaConsumer 消费配置,通知用户是否秒杀成功:

/**
 * 消费者 spring-kafka 2.0 + 依赖JDK8
 * @author 科帮网 By https://blog.52itstyle.com
 */
@Component
public class KafkaConsumer {
    @Autowired
    private ISeckillService seckillService;
    
    private static RedisUtil redisUtil = new RedisUtil();
    /**
     * 监听seckill主题,有消息就读取
     * @param message
     */
    @KafkaListener(topics = {"seckill"})
    public void receiveMessage(String message){
        //收到通道的消息之后执行秒杀操作
        String[] array = message.split(";"); 
        if(redisUtil.getValue(array[0])!=null){//control层已经判断了,其实这里不需要再判断了
            Result result = seckillService.startSeckil(Long.parseLong(array[0]), Long.parseLong(array[1]));
            if(result.equals(Result.ok())){
                WebSocketServer.sendInfo(array[0].toString(), "秒杀成功");//推送给前台
            }else{
                WebSocketServer.sendInfo(array[0].toString(), "秒杀失败");//推送给前台
                redisUtil.cacheValue(array[0], "ok");//秒杀结束
            }
        }else{
            WebSocketServer.sendInfo(array[0].toString(), "秒杀失败");//推送给前台
        }
    }
}

webSocket.js 前台通知逻辑:

$(function(){
    socket.init();
});
var basePath = "ws://localhost:8080/seckill/";
socket = {
    webSocket : "",
    init : function() {
        //userId:自行追加
        if ('WebSocket' in window) {
            webSocket = new WebSocket(basePath+'websocket/1'); 
        } 
        else if ('MozWebSocket' in window) {
            webSocket = new MozWebSocket(basePath+"websocket/1");
        } 
        else {
            webSocket = new SockJS(basePath+"sockjs/websocket");
        }
        webSocket.onerror = function(event) {
            alert("websockt连接发生错误,请刷新页面重试!")
        };
        webSocket.onopen = function(event) {
            
        };
        webSocket.onmessage = function(event) {
            var message = event.data;
            alert(message)//判断秒杀是否成功、自行处理逻辑
        };
    }
}

客户端API

客户端与服务器通信

  • send() 向远程服务器发送数据
  • close() 关闭该websocket链接

监听函数 

  • onopen 当网络连接建立时触发该事件
  • onerror 当网络发生错误时触发该事件
  • onclose 当websocket被关闭时触发该事件
  • onmessage 当websocket接收到服务器发来的消息的时触发的事件,也是通信中最重要的一个监听事件。msg.data

readyState属性

这个属性可以返回websocket所处的状态。

  • CONNECTING(0) websocket正尝试与服务器建立连接
  • OPEN(1) websocket与服务器已经建立连接
  • CLOSING(2) websocket正在关闭与服务器的连接
  • CLOSED(3) websocket已经关闭了与服务器的连接

开源方案

goeasy

GoEasy实时Web推送,支持后台推送和前台推送两种:后台推送可以选择Java SDK、 Restful API支持所有开发语言;前台推送:JS推送。无论选择哪种方式推送代码都十分简单(10分钟可搞定)。由于它支持websocket 和polling两种连接方式所以兼顾大多数主流浏览器,低版本的IE浏览器也是支持的。

地址:http://goeasy.io/

Pushlets

Pushlets 是通过长连接方式实现“推”消息的。推送模式分为:Poll(轮询)、Pull(拉)。

地址:http://www.pushlets.com/

Pushlet

Pushlet 是一个开源的 Comet 框架,Pushlet 使用了观察者模型:客户端发送请求,订阅感兴趣的事件;服务器端为每个客户端分配一个会话 ID 作为标记,事件源会把新产生的事件以多播的方式发送到订阅者的事件队列里。

地址:https://github.com/wjw465150/Pushlet

总结

其实前面有提过,尽管WebSocket有诸多优点,但是,如果服务端维护很多长连接也是挺耗费资源的,服务器集群以及览器或者客户端兼容性问题,也会带来了一些不确定性因素。大体了解了一下各大厂的做法,大多数都还是基于轮询的方式实现的,比如:腾讯PC端微信扫码登录、京东商城支付成功通知等等。

有些小伙伴可能会问了,轮询岂不是会更耗费资源?其实在我看来,有些轮询是不可能穿透到后端数据库查询服务的,比如秒杀,一个缓存标记位就可以判定是否秒杀成功。相对于WS的长连接以及其不确定因素,在秒杀场景下,轮询还是相对比较合适的。

思考

最后,思考一个问题:100件商品,假如有一万人进行抢购,该如何设置队列长度?

秒杀案例:https://gitee.com/52itstyle/spring-boot-seckill

参考

https://blog.52itstyle.com/archives/736/

https://www.xoriant.com/blog/mobility/websocket-web-stateful-now.html

目录
相关文章
|
1月前
|
人工智能 Java Nacos
基于 Spring AI Alibaba + Nacos 的分布式 Multi-Agent 构建指南
本文将针对 Spring AI Alibaba + Nacos 的分布式多智能体构建方案展开介绍,同时结合 Demo 说明快速开发方法与实际效果。
1611 58
|
2月前
|
存储 Kubernetes 微服务
Dapr:用于构建分布式应用程序的便携式事件驱动运行时
Dapr 是一个可移植、事件驱动的运行时,简化了分布式应用程序的开发。它支持多语言、多框架,适用于云和边缘计算环境,提供服务调用、状态管理、消息发布/订阅等构建模块。通过 sidecar 模式,Dapr 帮助开发者轻松应对微服务架构的复杂性,实现弹性、可扩展的应用部署。
228 9
Dapr:用于构建分布式应用程序的便携式事件驱动运行时
|
3月前
|
监控 Java API
Spring Boot 3.2 结合 Spring Cloud 微服务架构实操指南 现代分布式应用系统构建实战教程
Spring Boot 3.2 + Spring Cloud 2023.0 微服务架构实践摘要 本文基于Spring Boot 3.2.5和Spring Cloud 2023.0.1最新稳定版本,演示现代微服务架构的构建过程。主要内容包括: 技术栈选择:采用Spring Cloud Netflix Eureka 4.1.0作为服务注册中心,Resilience4j 2.1.0替代Hystrix实现熔断机制,配合OpenFeign和Gateway等组件。 核心实操步骤: 搭建Eureka注册中心服务 构建商品
682 3
|
4月前
|
存储 消息中间件 NoSQL
体育赛事直播系统中基于 WebSocket 实现的聊天室与弹幕模块设计与实践
本文详解东莞梦幻网络科技体育直播系统中「聊天室+弹幕」模块的实时通信技术实现,涵盖 WebSocket 架构、连接池管理、多房间逻辑、消息转发与并发控制等核心要点,助力构建高并发、低延迟的直播互动体验。
|
1月前
|
负载均衡 Java API
《深入理解Spring》Spring Cloud 构建分布式系统的微服务全家桶
Spring Cloud为微服务架构提供一站式解决方案,涵盖服务注册、配置管理、负载均衡、熔断限流等核心功能,助力开发者构建高可用、易扩展的分布式系统,并持续向云原生演进。
|
2月前
|
存储 算法 安全
“卧槽,系统又崩了!”——别慌,这也许是你看过最通俗易懂的分布式入门
本文深入解析分布式系统核心机制:数据分片与冗余副本实现扩展与高可用,租约、多数派及Gossip协议保障一致性与容错。探讨节点故障、网络延迟等挑战,揭示CFT/BFT容错原理,剖析规模与性能关系,为构建可靠分布式系统提供理论支撑。
216 2
|
2月前
|
消息中间件 缓存 监控
中间件架构设计与实践:构建高性能分布式系统的核心基石
摘要 本文系统探讨了中间件技术及其在分布式系统中的核心价值。作者首先定义了中间件作为连接系统组件的&quot;神经网络&quot;,强调其在数据传输、系统稳定性和扩展性中的关键作用。随后详细分类了中间件体系,包括通信中间件(如RabbitMQ/Kafka)、数据中间件(如Redis/MyCAT)等类型。文章重点剖析了消息中间件的实现机制,通过Spring Boot代码示例展示了消息生产者的完整实现,涵盖消息ID生成、持久化、批量发送及重试机制等关键技术点。最后,作者指出中间件架构设计对系统性能的决定性影响,
|
2月前
|
机器学习/深度学习 算法 安全
新型电力系统下多分布式电源接入配电网承载力评估方法研究(Matlab代码实现)
新型电力系统下多分布式电源接入配电网承载力评估方法研究(Matlab代码实现)
120 3
|
3月前
|
数据采集 存储 NoSQL
Scrapy 框架实战:构建高效的快看漫画分布式爬虫
Scrapy 框架实战:构建高效的快看漫画分布式爬虫
|
4月前
|
数据采集 缓存 NoSQL
分布式新闻数据采集系统的同步效率优化实战
本文介绍了一个针对高频新闻站点的分布式爬虫系统优化方案。通过引入异步任务机制、本地缓存池、Redis pipeline 批量写入及身份池策略,系统采集效率提升近两倍,数据同步延迟显著降低,实现了分钟级热点追踪能力,为实时舆情监控与分析提供了高效、稳定的数据支持。
160 1
分布式新闻数据采集系统的同步效率优化实战