电商秒杀系统:单机服务器性能压测+链路监控+优化(数据库连接池、缓存(字典、内存)、消息队列)
说明
使用压测工具,配合链路监控,来查看程序的性能瓶颈,然后继续优化,继续压测。
因为本机硬件配置一般,压测工具、数据库、SkyWalking、Elasticsearch对性能影响大,后面的测试都是在阿里云服务上完成。
常见压测工具
- Apache JMeter:java 开发,使用比较简单
- ApacheBench (ab): c语言
- Gatling:scala 语言,使用命令脚本
- k6: js开发
- Locust:python开发 单台测试 使用命令脚本
- Netling: c#开发, 测试起来比较简单
- Vegeta: go语言 ,命令行工具和一个开发库
备注:如果要非常精准的压测,建议在服务器使用命令行的压测工具来压测
服务器配置
阿里云
2、内存 16G
3、硬盘 100G
环境准备
链路监控 SkyWalking
项目中添加使用SkyWalking
安装SkyWalking
安装Elasticsearch
压测工具 JMeter
参考:JMeter使用教程
需要依赖Java
- 安装java jdk1.8及以上版本
- 配置java环境变量
JMeter
- 官网下载后,打开bin文件夹下的jmeter.bat
- 线程组:添加=》线程(用户)=》线程组
- HTTP请求:添加=》取样器=》HTTP请求
- 聚合报告:添加=》监听器=》去和报告
- 察看结果树:添加=》监听器=》察看结果树
- 图形结果树:添加=》监听器=》图形结果树
- HTTP消息头管理器:添加=》配置元件=》HTTP消息头管理器,然后添加UserId和UserName来配置token和用户,避免登录认证
- 也可以另外使【用户参数】来登录,不过每次都需要登录会造成压测不准确,所以建议用【HTTP消息头管理器】
第一阶段优化:consul服务发现使用缓存字典+数据库连接池AddDbContextPool-->每秒170
优化前
- 多次压测和链路监控发现,1s能处理100个请求左右。
- 通过链路监控发现【秒杀聚合服务】耗时多,通过排查是consul注册时耗时非常多。
优化后
- 结果:压测后发现1s能处理170个请求
数据库连接池优化
- 用到AddDbContext的服务都可以使用连接池,如OrderServices服务和Seckill服务的Startup类的AddDbContext改为AddDbContextPool
consul服务发现缓存优化
consul服务发现组件位置:Projects.Cores\Registry\Consul\ConsulServiceDiscovery.cs
优化前代码:是继承IServiceDiscovery类
using Consul; using Microsoft.Extensions.Options; using RuanMou.Projects.Commons.Exceptions; using RuanMou.Projects.Cores.Registry.Options; using System; using System.Collections.Generic; using System.Net; namespace RuanMou.Projects.Cores.Registry { /// <summary> /// consul服务发现实现 /// </summary> public class ConsulServiceDiscovery : IServiceDiscovery { private readonly ServiceDiscoveryOptions serviceDiscoveryOptions; public ConsulServiceDiscovery(IOptions<ServiceDiscoveryOptions> options) { this.serviceDiscoveryOptions = options.Value; } public List<ServiceNode> Discovery(string serviceName) { // 1.2、从远程服务器取 CatalogService[] queryResult = RemoteDiscovery(serviceName); var list = new List<ServiceNode>(); foreach (var service in queryResult) { list.Add(new ServiceNode { Url = service.ServiceAddress + ":" + service.ServicePort }); } return list; } private CatalogService[] RemoteDiscovery(string serviceName) { // 1、创建consul客户端连接 var consulClient = new ConsulClient(configuration => { //1.1 建立客户端和服务端连接 configuration.Address = new Uri(serviceDiscoveryOptions.DiscoveryAddress); }); // 2、consul查询服务,根据具体的服务名称查询 var queryResult = consulClient.Catalog.Service(serviceName).Result; // 3、判断请求是否失败 if (!queryResult.StatusCode.Equals(HttpStatusCode.OK)) { throw new FrameException($"consul连接失败:{queryResult.StatusCode}"); } return queryResult.Response; } } }
using System.Collections.Generic; using System.Threading.Tasks; namespace RuanMou.Projects.Cores.Registry { /// <summary> /// 服务发现 /// </summary> public interface IServiceDiscovery { /// <summary> /// 服务发现 /// </summary> /// <param name="serviceName">服务名称</param> /// <returns></returns> List<ServiceNode> Discovery(string serviceName); } }
优化后代码:继承缓存类AbstractServiceDiscovery类,且把Discovery方法放到里面,把从远程获取改为从缓存获取
using Consul; using Microsoft.Extensions.Options; using RuanMou.Projects.Commons.Exceptions; using RuanMou.Projects.Cores.Registry.Options; using System; using System.Collections.Generic; using System.Net; namespace RuanMou.Projects.Cores.Registry { /// <summary> /// consul服务发现实现 /// </summary> public class ConsulServiceDiscovery : AbstractServiceDiscovery { public ConsulServiceDiscovery(IOptions<ServiceDiscoveryOptions> options) : base(options) { } protected override CatalogService[] RemoteDiscovery(string serviceName) { // 1、创建consul客户端连接 2s 1、使用单例全局共享 2、使用数据缓存(进程:字典,集合) 3、使用连接池 var consulClient = new ConsulClient(configuration => { //1.1 建立客户端和服务端连接 configuration.Address = new Uri(serviceDiscoveryOptions.DiscoveryAddress); }); // 2、consul查询服务,根据具体的服务名称查询 var queryResult = consulClient.Catalog.Service(serviceName).Result; // 3、判断请求是否失败 if (!queryResult.StatusCode.Equals(HttpStatusCode.OK)) { throw new FrameException($"consul连接失败:{queryResult.StatusCode}"); } return queryResult.Response; } } }
其实就是一个字典类型的字段,服务发现时如果字典中存在就直接从字典中获取,没有时再从远程获取,获取后再存到字典中,以达到缓存重用的效果
AbstractServiceDiscovery类代码如下:
using Consul; using Microsoft.Extensions.Options; using RuanMou.Projects.Commons.Exceptions; using RuanMou.Projects.Cores.Registry.Options; using System; using System.Collections.Generic; using System.Net; using System.Threading.Tasks; namespace RuanMou.Projects.Cores.Registry { /// <summary> /// 抽象服务发现,主要是缓存功能 /// </summary> public abstract class AbstractServiceDiscovery : IServiceDiscovery { // 字典缓存 private readonly Dictionary<string, List<ServiceNode>> CacheConsulResult = new Dictionary<string, List<ServiceNode>>(); protected readonly ServiceDiscoveryOptions serviceDiscoveryOptions; public AbstractServiceDiscovery(IOptions<ServiceDiscoveryOptions> options) { this.serviceDiscoveryOptions = options.Value; // 1、创建consul客户端连接 var consulClient = new ConsulClient(configuration => { //1.1 建立客户端和服务端连接 configuration.Address = new Uri(serviceDiscoveryOptions.DiscoveryAddress); }); // 2、consul 先查询服务 var queryResult = consulClient.Catalog.Services().Result; if (!queryResult.StatusCode.Equals(HttpStatusCode.OK)) { throw new FrameException($"consul连接失败:{queryResult.StatusCode}"); } // 3、获取服务下的所有实例 foreach (var item in queryResult.Response) { QueryResult<CatalogService[]> result = consulClient.Catalog.Service(item.Key).Result; if (!queryResult.StatusCode.Equals(HttpStatusCode.OK)) { throw new FrameException($"consul连接失败:{queryResult.StatusCode}"); } var list = new List<ServiceNode>(); foreach (var service in result.Response) { list.Add(new ServiceNode { Url = service.ServiceAddress + ":" + service.ServicePort }); } CacheConsulResult.Add(item.Key, list); } } public List<ServiceNode> Discovery(string serviceName) { // 1、从缓存中查询consulj结果 if (CacheConsulResult.ContainsKey(serviceName)) { return CacheConsulResult[serviceName]; } else { // 1.2、从远程服务器取 CatalogService[] queryResult = RemoteDiscovery(serviceName); var list = new List<ServiceNode>(); foreach (var service in queryResult) { list.Add(new ServiceNode { Url = service.ServiceAddress + ":" + service.ServicePort }); } // 1.3 将结果添加到缓存 CacheConsulResult.Add(serviceName, list); return list; } } /// <summary> /// 远程服务发现 /// </summary> /// <param name="serviceName"></param> /// <returns></returns> protected abstract CatalogService[] RemoteDiscovery(string serviceName); } }
时序图
第二阶段优化:秒杀库内存存缓存优化-->每秒340
减少网络IO:秒杀库存本来是在秒杀服务中的,但是为了减少网络IO,然后直接把放到秒杀聚合服务中的缓存中
使用内存缓存 MemoryCache ,减少操作数据库,把秒杀库存缓存放在秒杀聚合服务中:SeckillAggregateServices \ Caches \ SeckillStock \ SeckillStockCache.cs,代码如下:
using Microsoft.Extensions.Caching.Memory; using RuanMou.Projects.SeckillAggregateServices.Models.SeckillService; using RuanMou.Projects.SeckillAggregateServices.Services; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace RuanMou.Projects.SeckillAggregateServices.Caches.SeckillStock { /// <summary> /// 秒杀库存缓存 /// </summary> public class SeckillStockCache : ISeckillStockCache { /// <summary> /// 秒杀微服务客户端 /// </summary> private readonly ISeckillsClient seckillsClient; /// <summary> /// 内存缓存 /// </summary> private readonly IMemoryCache memoryCache; public SeckillStockCache(ISeckillsClient seckillsClient, IMemoryCache memoryCache) { this.seckillsClient = seckillsClient; this.memoryCache = memoryCache; } public int GetSeckillStocks(int ProductId) { Seckill seckillStock = memoryCache.Get<Seckill>(ProductId); return seckillStock.SeckillStock; } /// <summary> /// 秒杀库存加载到MemoryCache中 /// </summary> public void SkillStockToCache() { // 1、查询所有秒杀活动 List<Seckill> seckills = seckillsClient.GetSeckills(); // 2、存储秒杀库存到缓存 foreach (var seckill in seckills) { // 2.1 将所有秒杀活动存储到缓存中 memoryCache.Set<Seckill>(seckill.ProductId, seckill); } } public void SubtractSeckillStock(int ProductId, int ProductCount) { // 1、获取秒杀活动信息 Seckill seckill = memoryCache.Get<Seckill>(ProductId); // 2、扣减库存 int SeckillStock = seckill.SeckillStock; SeckillStock = seckill.SeckillStock - ProductCount; seckill.SeckillStock = SeckillStock; // 3、更新库存 memoryCache.Set<Seckill>(seckill.ProductId, seckill); Seckill seckill2 = memoryCache.Get<Seckill>(ProductId); } } }
SeckillStockCacheHostedService:服务启动时,加载秒杀库存到缓存
using Microsoft.Extensions.Hosting; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace RuanMou.Projects.SeckillAggregateServices.Caches.SeckillStock { /// <summary> /// 服务启动时,加载秒杀库存到缓存 /// </summary> public class SeckillStockCacheHostedService : IHostedService { private readonly ISeckillStockCache seckillStockCache; public SeckillStockCacheHostedService(ISeckillStockCache seckillStockCache) { this.seckillStockCache = seckillStockCache; } /// <summary> /// 加载秒杀库存缓存 /// </summary> /// <param name="cancellationToken"></param> /// <returns></returns> public Task StartAsync(CancellationToken cancellationToken) { Console.WriteLine("加载秒杀库存到缓存中"); return Task.Run(() => seckillStockCache.SkillStockToCache()); } public Task StopAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } } }
在秒杀聚合服务的订单控制器OrderController中调用秒杀缓存
时序图:
第三阶段:上云服务器+流量削峰(使用消息队列CAP. RabbitMQ生成订单)-->每秒3000
使用CAP框架的消息队列
就是把超过服务器承受的请求存放在消息队列中,当前先处理一部分,然后再分批处理消息队列中的请求
异步:使用消息队列Rabbitmq发送消息优化,不直接操作数据库。 在聚合服务上,订单控制器直接把请求发送给Rabbitmq
位置:SeckillAggregateServices \ Controllers \ OrderController.cs,代码如下:
RabbitMQ发布订阅模式:聚合服务发送消息给RabbitMQ直接返回,RabbitMQ发送消息给订单服务服务,聚合服务和订单服务通过RabbitMQ解耦,不直接调用
- 发布:秒杀聚合服务的订单控制器中发布消息给RabbitMQ后直接返回,不用再等请求执行完毕后再返回。对应数据库发布表:Published
//在Startup中添加CAP services.AddCap(x => { // 8.1 使用内存存储消息(消息发送失败处理) x.UseInMemoryStorage(); // 8.4 使用RabbitMQ进行事件中心处理 x.UseRabbitMQ(rb => { rb.HostName = "10.96.0.3";// K8s集群service rb.UserName = "guest"; rb.Password = "guest"; rb.Port = 5672; rb.VirtualHost = "/"; }); // 8.5添加cap后台监控页面(人工处理) x.UseDashboard(); }); //在订单控制器中发布消息给RabbitMQ using DotNetCore.CAP; private readonly ICapPublisher capPublisher; /// <summary> /// 3.1 发送创建订单消息 /// </summary> /// <param name="ProductId"></param> /// <param name="ProductCount"></param> private void SendOrderCreateMessage(int userId, string orderSn, OrderPo orderPo) { var configuration = new MapperConfiguration(cfg => { cfg.CreateMap<OrderPo, Order>(); }); IMapper mapper = configuration.CreateMapper(); // 2、设置订单 Order order = mapper.Map<OrderPo, Order>(orderPo); order.OrderSn = orderSn; order.OrderType = "1";// 订单类型(1、为秒杀订单) order.UserId = userId; // 3、设置订单项 OrderItem orderItem = new OrderItem(); orderItem.ItemCount = orderPo.ProductCount; orderItem.ItemPrice = orderPo.OrderTotalPrice; orderItem.ItemTotalPrice = orderPo.OrderTotalPrice; orderItem.ProductUrl = orderPo.ProductUrl; orderItem.ProductId = orderPo.ProductId; orderItem.OrderSn = orderSn; List<OrderItem> orderItems = new List<OrderItem>(); orderItems.Add(orderItem); order.OrderItems = orderItems; // 4、发送订单消息 capPublisher.Publish<Order>("seckill.order", order); }
- 订阅:订单服务中的订单控制器中的方法头上通过过滤器订阅,然后是RabbitMQ调用订单服务。对应数据库订阅表:Received
//在Stratup中添加cap services.AddCap(x => { // 7.1 使用EntityFramework进行存储操作 x.UseEntityFramework<OrderContext>(); // 7.2 使用sqlserver进行事务处理 x.UseMySql(Configuration.GetConnectionString("DefaultConnection")); // 7.3 使用RabbitMQ进行事件中心处理 x.UseRabbitMQ(rb => { rb.HostName = "localhost"; // 本地主机 rb.HostName = "10.96.0.3";// docker集群service rb.UserName = "guest"; rb.Password = "guest"; rb.Port = 5672; rb.VirtualHost = "/"; }); //在订单控制器中订阅RabbitMQ消息 using DotNetCore.CAP; private readonly IOrderService OrderService; /// <summary> /// 创建订单 /// </summary> /// <param name="Order"></param> /// <returns></returns> [NonAction] [CapSubscribe("seckill.order")] public ActionResult<Order> CapPostOrder(Order Order) { // 1、创建订单 Order.Createtime = new DateTime(); OrderService.Create(Order); return CreatedAtAction("GetOrder", new { id = Order.Id }, Order); }
问题:发布和订阅的地址是一样,但是数据库不一样,难道两个数据库?
消息队列存储在内存中
消息队列存在数据库中,消息队列不会丢失,但是性能不够好,存在数据库是是使用异步吗?
消息队列存在内存中性能好,但是如果断机,会总成消息丢失,可以使用分布式事务保证数据完整,还可以使用后面说到的溟等
在聚合服务的Startup.cs中使用内存存储消息队列,代码是:x.UseInMemoryStorage();
时序图
本文版权归作者和博客园共有,欢迎转载,但必须在文章页面给出原文链接,否则保留追究法律责任的权利。