电商秒杀系统:单机服务器性能压测+链路监控+优化(数据库连接池、缓存(字典、内存)、消息队列)

说明

使用压测工具,配合链路监控,来查看程序的性能瓶颈,然后继续优化,继续压测。

因为本机硬件配置一般,压测工具、数据库、SkyWalking、Elasticsearch对性能影响大,后面的测试都是在阿里云服务上完成。

常见压测工具

  • Apache JMeter:java 开发,使用比较简单
  • ApacheBench (ab): c语言
  • Gatling:scala 语言,使用命令脚本
  • k6: js开发
  • Locust:python开发 单台测试 使用命令脚本
  • Netling: c#开发, 测试起来比较简单
  • Vegeta: go语言 ,命令行工具和一个开发库

备注:如果要非常精准的压测,建议在服务器使用命令行的压测工具来压测

服务器配置

阿里云

1、cpu 8核

2、内存 16G

3、硬盘 100G

4、带宽 10M

环境准备

链路监控 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;
        }
    }
}
View Code
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);
    }
}
View Code

优化后代码:继承缓存类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;
        }

    }
}
View Code

其实就是一个字典类型的字段,服务发现时如果字典中存在就直接从字典中获取,没有时再从远程获取,获取后再存到字典中,以达到缓存重用的效果

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);
    }
}
View Code 

时序图

第二阶段优化:秒杀库内存存缓存优化-->每秒340

参考:ASP.NET Core 中的缓存内存

减少网络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);
        }
    }
}
View Code

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;
        }
    }
}
View Code

在秒杀聚合服务的订单控制器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);
            }
    View Code
  • 订阅:订单服务中的订单控制器中的方法头上通过过滤器订阅,然后是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);
            }
    View Code

问题:发布和订阅的地址是一样,但是数据库不一样,难道两个数据库?  

消息队列存储在内存中

消息队列存在数据库中,消息队列不会丢失,但是性能不够好,存在数据库是是使用异步吗?

消息队列存在内存中性能好,但是如果断机,会总成消息丢失,可以使用分布式事务保证数据完整,还可以使用后面说到的溟等

在聚合服务的Startup.cs中使用内存存储消息队列,代码是:x.UseInMemoryStorage();

时序图

 

posted @ 2021-01-07 22:04  日积月累码农  阅读(826)  评论(0编辑  收藏  举报