.net 到底行不行!2000 人在线的客服系统真实屏录演示(附技术详解) 📹
业余时间用 .net 写了一个免费的在线客服系统:升讯威在线客服与营销系统。
时常有朋友问我性能方面的问题,正好有一个真实客户,在线的访客数量达到了 2000 人。在争得客户同意后,我录了一个视频。
升讯威在线客服系统可以在极低配置的服务器环境下,轻松应对这种情况,依然可以做到:
消息毫秒级送达,操作毫秒级响应
您的浏览器不支持 video 标签。
性能
以官方在线使用环境为例,每日处理 HTTPS 请求数大于 16 万次, PV 请求大于 25 万 次的情况下,服务端主程序内存占用小于 300MB,服务器 CPU 占用小于 5%。
每日处理 HTTPS 请求数大于 16 万次:

每日处理 PV 请求大于 25 万 次:

服务端主程序内存占用小于 300MB:

服务器 CPU (Intel Xeon Platinum 8163 / 4 核 2.5 GHz) 占用稳定约 5%:

安全性
- 访客端使用 https 与 wss 安全连接,数据全程加密传输。
- 客服端数据报文使用 AES 加密传输。(Advanced Encryption Standard,美国联邦政府区块加密标准)。
- 可以 100% 私有化部署在您的自有服务器。
拦截的报文中消息以密文传输:

实现效果
客服端

访客端

怎么做到的?技术详解
使用NetworkStream的TCP服务器
在Pipelines之前用.NET编写的典型代码如下所示:
async Task ProcessLinesAsync(NetworkStream stream)
{
var buffer = new byte[1024];
await stream.ReadAsync(buffer, 0, buffer.Length);
// 在buffer中处理一行消息
ProcessLine(buffer);
}
此代码可能在本地测试时正确工作,但它有几个潜在错误:
一次ReadAsync调用可能没有收到整个消息(行尾)。
它忽略了stream.ReadAsync()返回值中实际填充到buffer中的数据量。(译者注:即不一定将buffer填充满)
一次ReadAsync调用不能处理多条消息。
这些是读取流数据时常见的一些缺陷。为了解决这个问题,我们需要做一些改变:
我们需要缓冲传入的数据,直到找到新的行。
我们需要解析缓冲区中返回的所有行
async Task ProcessLinesAsync(NetworkStream stream)
{
var buffer = new byte[1024];
var bytesBuffered = 0;
var bytesConsumed = 0;
while (true)
{
var bytesRead = await stream.ReadAsync(buffer, bytesBuffered, buffer.Length - bytesBuffered);
if (bytesRead == 0)
{
// EOF 已经到末尾
break;
}
// 跟踪已缓冲的字节数
bytesBuffered += bytesRead;
var linePosition = -1;
do
{
// 在缓冲数据中查找找一个行末尾
linePosition = Array.IndexOf(buffer, (byte)'\n', bytesConsumed, bytesBuffered - bytesConsumed);
if (linePosition >= 0)
{
// 根据偏移量计算一行的长度
var lineLength = linePosition - bytesConsumed;
// 处理这一行
ProcessLine(buffer, bytesConsumed, lineLength);
// 移动bytesConsumed为了跳过我们已经处理掉的行 (包括\n)
bytesConsumed += lineLength + 1;
}
}
while (linePosition >= 0);
}
}
这一次,这可能适用于本地开发,但一行可能大于1KiB(1024字节)。我们需要调整输入缓冲区的大小,直到找到新行。
因此,我们可以在堆上分配缓冲区去处理更长的一行。我们从客户端解析较长的一行时,可以通过使用ArrayPool避免重复分配缓冲区来改进这一点。
async Task ProcessLinesAsync(NetworkStream stream)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
var bytesBuffered = 0;
var bytesConsumed = 0;
while (true)
{
// 在buffer中计算中剩余的字节数
var bytesRemaining = buffer.Length - bytesBuffered;
if (bytesRemaining == 0)
{
// 将buffer size翻倍 并且将之前缓冲的数据复制到新的缓冲区
var newBuffer = ArrayPool<byte>.Shared.Rent(buffer.Length * 2);
Buffer.BlockCopy(buffer, 0, newBuffer, 0, buffer.Length);
// 将旧的buffer丢回池中
ArrayPool<byte>.Shared.Return(buffer);
buffer = newBuffer;
bytesRemaining = buffer.Length - bytesBuffered;
}
var bytesRead = await stream.ReadAsync(buffer, bytesBuffered, bytesRemaining);
if (bytesRead == 0)
{
// EOF 末尾
break;
}
// 跟踪已缓冲的字节数
bytesBuffered += bytesRead;
do
{
// 在缓冲数据中查找找一个行末尾
linePosition = Array.IndexOf(buffer, (byte)'\n', bytesConsumed, bytesBuffered - bytesConsumed);
if (linePosition >= 0)
{
// 根据偏移量计算一行的长度
var lineLength = linePosition - bytesConsumed;
// 处理这一行
ProcessLine(buffer, bytesConsumed, lineLength);
// 移动bytesConsumed为了跳过我们已经处理掉的行 (包括\n)
bytesConsumed += lineLength + 1;
}
}
while (linePosition >= 0);
}
}
这段代码有效,但现在我们正在重新调整缓冲区大小,从而产生更多缓冲区副本。它将使用更多内存,因为根据代码在处理一行行后不会缩缓冲区的大小。为避免这种情况,我们可以存储缓冲区序列,而不是每次超过1KiB大小时调整大小。
此外,我们不会增长1KiB的 缓冲区,直到它完全为空。这意味着我们最终传递给ReadAsync越来越小的缓冲区,这将导致对操作系统的更多调用。
为了缓解这种情况,我们将在现有缓冲区中剩余少于512个字节时分配一个新缓冲区:
public class BufferSegment
{
public byte[] Buffer { get; set; }
public int Count { get; set; }
public int Remaining => Buffer.Length - Count;
}
async Task ProcessLinesAsync(NetworkStream stream)
{
const int minimumBufferSize = 512;
var segments = new List<BufferSegment>();
var bytesConsumed = 0;
var bytesConsumedBufferIndex = 0;
var segment = new BufferSegment { Buffer = ArrayPool<byte>.Shared.Rent(1024) };
segments.Add(segment);
while (true)
{
// Calculate the amount of bytes remaining in the buffer
if (segment.Remaining < minimumBufferSize)
{
// Allocate a new segment
segment = new BufferSegment { Buffer = ArrayPool<byte>.Shared.Rent(1024) };
segments.Add(segment);
}
var bytesRead = await stream.ReadAsync(segment.Buffer, segment.Count, segment.Remaining);
if (bytesRead == 0)
{
break;
}
// Keep track of the amount of buffered bytes
segment.Count += bytesRead;
while (true)
{
// Look for a EOL in the list of segments
var (segmentIndex, segmentOffset) = IndexOf(segments, (byte)'\n', bytesConsumedBufferIndex, bytesConsumed);
if (segmentIndex >= 0)
{
// Process the line
ProcessLine(segments, segmentIndex, segmentOffset);
bytesConsumedBufferIndex = segmentOffset;
bytesConsumed = segmentOffset + 1;
}
else
{
break;
}
}
// Drop fully consumed segments from the list so we don't look at them again
for (var i = bytesConsumedBufferIndex; i >= 0; --i)
{
var consumedSegment = segments[i];
// Return all segments unless this is the current segment
if (consumedSegment != segment)
{
ArrayPool<byte>.Shared.Return(consumedSegment.Buffer);
segments.RemoveAt(i);
}
}
}
}
(int segmentIndex, int segmentOffest) IndexOf(List<BufferSegment> segments, byte value, int startBufferIndex, int startSegmentOffset)
{
var first = true;
for (var i = startBufferIndex; i < segments.Count; ++i)
{
var segment = segments[i];
// Start from the correct offset
var offset = first ? startSegmentOffset : 0;
var index = Array.IndexOf(segment.Buffer, value, offset, segment.Count - offset);
if (index >= 0)
{
// Return the buffer index and the index within that segment where EOL was found
return (i, index);
}
first = false;
}
return (-1, -1);
}
此代码只是得到很多更加复杂。当我们正在寻找分隔符时,我们同时跟踪已填充的缓冲区序列。为此,我们此处使用List查找新行分隔符时表示缓冲数据。其结果是,ProcessLine和IndexOf现在接受List作为参数,而不是一个byte[],offset和count。我们的解析逻辑现在需要处理一个或多个缓冲区序列。
我们的服务器现在处理部分消息,它使用池化内存来减少总体内存消耗,但我们还需要进行更多更改:
- 我们使用的byte[]和ArrayPool的只是普通的托管数组。这意味着无论何时我们执行ReadAsync或WriteAsync,这些缓冲区都会在异步操作的生命周期内被固定(以便与操作系统上的本机IO API互操作)。这对GC有性能影响,因为无法移动固定内存,这可能导致堆碎片。根据异步操作挂起的时间长短,池的实现可能需要更改。
- 可以通过解耦读取逻辑和处理逻辑来优化吞吐量。这会创建一个批处理效果,使解析逻辑可以使用更大的缓冲区块,而不是仅在解析单个行后才读取更多数据。这引入了一些额外的复杂性
- 我们需要两个彼此独立运行的循环。一个读取Socket和一个解析缓冲区。
- 当数据可用时,我们需要一种方法来向解析逻辑发出信号。
- 我们需要决定如果循环读取Socket“太快”会发生什么。如果解析逻辑无法跟上,我们需要一种方法来限制读取循环(逻辑)。这通常被称为“流量控制”或“背压”。
- 我们需要确保事情是线程安全的。我们现在在读取循环和解析循环之间共享多个缓冲区,并且这些缓冲区在不同的线程上独立运行。
- 内存管理逻辑现在分布在两个不同的代码段中,从填充缓冲区池的代码是从套接字读取的,而从缓冲区池取数据的代码是解析逻辑。
- 我们需要非常小心在解析逻辑完成之后我们如何处理缓冲区序列。如果我们不小心,我们可能会返回一个仍由Socket读取逻辑写入的缓冲区序列。
在线体验或下载完整私有化部署包:
希望能够打造: 开放、开源、共享。努力打造 .net 社区的一款优秀开源产品。
钟意的话请给个赞支持一下吧,谢谢~
.net 到底行不行!2000 人在线的客服系统真实屏录演示(附技术详解) 📹的更多相关文章
- CentOS 30分钟部署 .net core 在线客服系统
前段时间我发表了一系列文章,开始介绍基于 .net core 的在线客服系统开发过程.期间有一些朋友希望能够给出 Linux 环境的安装部署指导,本文基于 CentOS 8.3 来安装部署.在本文中我 ...
- Docker 版 3分钟部署 .net core 开源在线客服系统,他来了
我在博客园发表了一系列文章,开始介绍基于 .net core 的在线客服系统开发过程. 前些天又应朋友的要求,发了一篇 CentOS 版本的安装部署教程:https://www.cnblogs.com ...
- Linux + .net core 开发升讯威在线客服系统:同时支持 SQL Server 和 MySQL 的实现方法
前段时间我发表了一系列文章,开始介绍基于 .net core 的在线客服系统开发过程. 有很多朋友一直提出希望能够支持 MySQL 数据库,考虑到已经有朋友在用 SQL Server,我在升级的过程中 ...
- Linux + .net core 开发升讯威在线客服系统:首个经过实际验证的高性能版本
业余时间用 .net core 写了一个在线客服系统.并在博客园写了一个系列的文章,写介绍这个开发过程: .net core 和 WPF 开发升讯威在线客服系统:目录 https://blog.she ...
- .net core 和 WPF 开发升讯威在线客服系统:调用百度翻译接口实现实时自动翻译
业余时间用 .net core 写了一个在线客服系统.并在博客园写了一个系列的文章,写介绍这个开发过程. 我把这款业余时间写的小系统丢在网上,陆续有人找我要私有化版本,我都给了,毕竟软件业的初衷就是免 ...
- 开发升讯威在线客服系统启示录:怎样编写堪比 MSDN 的用户手册
本系列文章详细介绍使用 .net core 和 WPF 开发 升讯威在线客服与营销系统 的过程. 免费在线使用 & 免费私有化部署:https://kf.shengxunwei.com 文章目 ...
- 1个程序员单干之:怎样给我的升讯威在线客服系统编写堪比 MSDN 的用户手册
本系列文章详细介绍使用 .net core 和 WPF 开发 升讯威在线客服与营销系统 的过程. 免费在线使用 & 免费私有化部署:https://kf.shengxunwei.com 视频实 ...
- Linux 运行升讯威在线客服系统:同时支持 SQL Server 和 MySQL 的实现方法
前段时间我发表了一系列文章,开始介绍基于 .net core 的在线客服系统开发过程. 有很多朋友一直提出希望能够支持 MySQL 数据库,考虑到已经有朋友在用 SQL Server,我在升级的过程中 ...
- 详解升讯威在线客服系统前端 JavaScript 脚本加密技术(1)
我在业余时间开发维护了一款免费开源的升讯威在线客服系统,也收获了许多用户.对我来说,只要能获得用户的认可,就是我最大的动力. 这段时间有几个技术小伙伴问了我一个有意思的问题:"你的前端脚本是 ...
- 后端Python3+Flask结合Socket.io配合前端Vue2.0实现简单全双工在线客服系统
原文转载自「刘悦的技术博客」https://v3u.cn/a_id_158 在之前的一篇文章中:为美多商城(Django2.0.4)添加基于websocket的实时通信,主动推送,聊天室及客服系统,详 ...
随机推荐
- 学习笔记--Java合集
学习笔记--Java合集 JDK8 基础篇 我的第一个Java程序 Java标识符 Java 字面值 Java中的变量 Java中的数据类型 Java 运算符 Java 控制语句 方法 Java方法基 ...
- Prometheus 基于Python Django实现Prometheus Exporter
基于Python Django实现Prometheus Exporter 需求描述 运行监控需求,需要采集Nginx 每个URL请求的相关信息,涉及两个指标:一分钟内平均响应时间,调用次数,并且为每个 ...
- pytest批量执行多个测试文件(批量执行一个文件夹下的所有测试用例)
图片 代码 #!/usr/bin/env python # @File : test_runall.py import pytest import os # path = os.path.dirnam ...
- 使用update-alternatives管理GCC版本
目录 简介 操作过程 简介 当操作系统中存在多个版本的GCC时,可以使用使用update-alternatives管理默认使用的编译器版本. 本文使用gcc-9和gcc-11做演示,操作系统为ubun ...
- .NET 开源快捷的数据库文档查询和生成工具
前言 在实际项目开发中,需求变更和项目迭代是常态.要求我们能够迅速响应,对数据库结构进行相应的调整,如添加新表.更新现有表结构或增加字段等. 为了确保团队成员之间的信息同步,实时更新和维护数据库文档变 ...
- 4、SpringMVC之获取请求参数
4.1 环境搭建 创建名为spring_mvc_demo2的新module,过程参考3.1节 4.1.1.创建请求控制器 package org.rain.controller; import org ...
- 【RabbitMQ】13 消息补偿 & 幂等保障
https://www.bilibili.com/video/BV15k4y1k7Ep?p=34 我靠,幂等保障就是乐观锁控制啊 https://www.bilibili.com/video/BV15 ...
- Http、Https简介和Session、token的请求流程
Http Http (超文本输出协议) 是一种分布式.协作式和超媒体信息系统的应用层协议,它通常运行在TCP之上,因特网应用最广泛的便是Http协议,所有www都遵循这个标准.主要用于Web 浏览器与 ...
- 【转载】 机器人真·涨姿势了:比肩人类抓取能力,上海交大、非夕科技联合提出全新方法AnyGrasp
原文地址: https://developer.aliyun.com/article/822654 ================================================= ...
- (数据科学学习手札163)ibis:极具潜力的Python数据分析框架
本文完整代码及附件已上传至我的Github仓库https://github.com/CNFeffery/DataScienceStudyNotes 1 简介 大家好我是费老师,今天要给大家介绍的Pyt ...