目录
一个商城的秒杀活动,在本身服务器配置和网络带宽完全可以应付的情况下,如果代码写得不好,会让大部分用户下单速度变得非常慢。下面我将用一个简单的代码例子来演示如何应对秒杀型的高并发问题。
本文将从新建工程开始,讲解代码的原理。
秒杀活动需要三个微服务:
- 产品中心服务:负责给产品减库存、创建购买订单。
- 资产服务:负责扣减用户余额。
- 对外服务:负责对外提供接口,统一编排微服务的调用。
通常,在下单时不需要立即扣减余额,只有在订单支付时才会扣减余额。在这里,我们将扣减余额的行为与库存扣减一起执行,以便演示如何使用分布式事务来确保多个微服务的事务一致性。
准备工作
运行微服务网关
本次采用的是JMSFramework微服务框架,它的基础设施包括一个网关和WebApi,WebApi通常用于正式环境,本地测试只运行一个网关也可以,因为网关本身也具备WebApi的能力。
我们先到以下地址下载网关程序:
我是windows环境,运行 JMS.Gateway.exe 后是这样:
安装vs工程模板
打开visual studio 2022,点击菜单【扩展】-》【管理扩展】,搜索 jms ,安装 JMS.MicroServiceProjectTemplate2022 ,有了这个模板,才能创建JMS工程
数据库设计
涉及到的数据库结构会尽量简化,能满足教程即可。为方便演示,数据库采用Sqlite。
产品库存数据库
资产数据库
创建微服务
产品进销存服务 ProductServiceHost
打开vs2022,创建新项目,搜索jms模板,用JMS工程模板直接创建项目。
在项目中引用 Microsoft.EntityFrameworkCore.Sqlite 7.0.0 和 Dapper
修改项目配置文件
打开 appsettings.json 文件,把网关地址改成你的网关地址
...
"Gateways": [ //网关地址
{
"Address": "127.0.0.1",
"Port": 8912
}
],
修改数据库操作类
工程里面的 SystemDBContext.cs 只是一个空的示范类,基本上就是提示你操作数据库的类应该实现 JMS.IStorageEngine 接口,这样才能支持分布式事务。
简单完善一下这个类,代码如下:
public class SystemDBContext : IDisposable, JMS.IStorageEngine
{
static SystemDBContext()
{
//创建表
using var con = CreateConnection();
con.Open();
con.Execute(@"
CREATE TABLE IF NOT EXISTS Product (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Name VARCHAR(50),
TotalQuantity INTEGER,
Quantity INTEGER,
Price REAL,
PromotionId VARCHAR(50)
);
CREATE TABLE IF NOT EXISTS [Order] (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ProductId INTEGER,
UserId INTEGER,
Quantity INTEGER,
PromotionId VARCHAR(50),
Status INTEGER
);
");
}
static SqliteConnection CreateConnection()
{
return new SqliteConnection("data source='data.db'");
}
public event EventHandler AfterCommit;
public event EventHandler AfterRollback;
public SqliteConnection Connection { get; }
public SystemDBContext()
{
Connection = CreateConnection();
Connection.Open();
}
/// <summary>
/// 当前事务对象
/// </summary>
public object CurrentTransaction { get; set; }
public void BeginTransaction()
{
if (this.CurrentTransaction == null)
{
this.CurrentTransaction = this.Connection.BeginTransaction();
}
}
public void CommitTransaction()
{
if (this.CurrentTransaction != null)
{
((SqliteTransaction)this.CurrentTransaction).Commit();
((SqliteTransaction)this.CurrentTransaction).Dispose();
this.CurrentTransaction = null;
AfterCommit?.Invoke(this, EventArgs.Empty);
}
}
public void RollbackTransaction()
{
if (this.CurrentTransaction != null)
{
((SqliteTransaction)this.CurrentTransaction).Rollback();
((SqliteTransaction)this.CurrentTransaction).Dispose();
this.CurrentTransaction = null;
AfterRollback?.Invoke(this, EventArgs.Empty);
}
}
public void Dispose()
{
if (this.CurrentTransaction != null)
{
RollbackTransaction();
}
this.CurrentTransaction = null;
Connection.Dispose();
}
}
秒杀活动,是多人争抢一个产品,所以,产品的库存扣减,不能参与到数据库事务当中,否则,多人同时下单,反应会很慢,因为多个用户会同时想要锁定同一条数据库记录。
为了性能考虑,产品应该映射到内存对象当中,从内存对象中用原子性操作来扣减库存。
新建 Product 类,用来保存产品库存:
public class Product
{
public int Id { get; set; }
public int TotalQuantity { get; set; }
internal int quantity;//用来做原子性操作
public int Quantity { get=> quantity; set=> quantity = value; }
public string Name { get; set; }
public decimal Price { get; set; }
public string PromotionId { get; set; }
}
然后打开 DemoController.cs ,在controller里面编写扣减库存的函数:
static ConcurrentDictionary<int, Models.Product> Products = new ConcurrentDictionary<int, Models.Product>();
static Way.Lib.Collections.ConcurrentDictionaryActionQueue<int> UpdateQuantityQueue = new Way.Lib.Collections.ConcurrentDictionaryActionQueue<int>();
/// <summary>
/// 创建订单
/// </summary>
/// <param name="userid">购买人id</param>
/// <param name="productId">产品id</param>
/// <param name="quantity">产品数量</param>
/// <returns>消费总金额</returns>
public async Task<decimal> CreateOrder(int userid,int productId, int quantity)
{
//获取产品对应的内存对象
var productInfo = Products.GetOrAdd(productId, _ =>
{
return this.CurrentDBContext.Connection.QueryFirst<Models.Product>("select Id,Quantity,PromotionId,Price from Product where Id=@Id", new
{
Id = productId
});
});
if (productInfo.Quantity >= quantity)
{
var nowQuantity = Interlocked.Add(ref productInfo.quantity, -quantity);
if(nowQuantity < 0)
{
//归还库存
Interlocked.Add(ref productInfo.quantity, quantity);
throw new ServiceException("产品数量不足");
}
//扣减库存成功, 标识自己支持分布式事务,以便分布式事务回滚时,这里能触发AfterRollback事件
this.CurrentDBContext.BeginTransaction();//标识自己支持分布式事务
this.CurrentDBContext.AfterCommit += (s, e) =>
{
//如果提交事务成功,那么同步一下数据库的库存数量
UpdateQuantityQueue.Add(1, () => {
using (var db = new SystemDBContext())
{
db.Connection.Execute("update Product set Quantity=Quantity-@quantity where Id=@productId",new { productId,quantity });
}
});
};
this.CurrentDBContext.AfterRollback += (s, e) => {
//如果分布式事务发生回滚,那么归还库存
Interlocked.Add(ref productInfo.quantity, quantity);
};
//生成待支付订单
await this.CurrentDBContext.Connection.ExecuteAsync("insert into [Order] (UserId,ProductId,Quantity,PromotionId,Status) values (@UserId,@ProductId,@Quantity,@PromotionId,@Status)", new
{
ProductId = productId,
Quantity = quantity,
PromotionId = productInfo.PromotionId,
Status = 2,
UserId = userid
} , (IDbTransaction)this.CurrentDBContext.CurrentTransaction);
//最后不用提交事务,事务由JMS底层框架控制提交
return productInfo.Price * quantity;
}
else
{
throw new ServiceException("产品数量不足");
}
}
上面的代码主要是用来处理订单创建时的库存扣减操作。因为有多个用户同时购买同一个商品,如果大家都去数据库里扣减库存,那个商品的数据就容易出现锁竞争,只有一个人能修改那条记录,其他人只能等待,直到这个人提交事务,其他人的程序才能继续运行。这就是为什么很多时候用户下单时会感觉服务器响应很慢。
为了解决这个问题,上面的代码采用了将库存数量映射到 Product 对象的方式,通过原子性的 Interlocked.Add() 方法来扣减商品的数量字段。这样即使有很多人同时进行库存扣减操作,也不会出现扣减后数量不正确的情况,而且扣减的速度也很快。
接下来,在创建订单时,开启了分布式事务,让订单的创建包含在事务里面。如果出现任何问题,可以回滚事务,保证数据的一致性。
借助 AfterCommit 事件,如果交易成功,将商品数量同步到数据库。
借助 AfterRollback 事件,如果交易失败,将数量返回给内存对象。
我们继续代码的编写,打开Program.cs文件,改一下注册的服务名称
找到:msp.Register<Controllers.DemoController>("DemoService", "测试服务", false);
修改为:msp.Register<Controllers.DemoController>("ProductService", "产品服务", false);
如果这个程序突然出问题崩溃了,那么产品的数量可能会出问题,因此,在程序启动的时候,我们需要检查一下产品的数量,确保它们是正确的。
找到 Program.Msp_ServiceProviderBuilded 方法,在其中加入以下代码:
//修正产品的剩余数量
using var db = new SystemDBContext();
var products = db.Connection.Query<Product>("select * from Product where Quantity>0");
foreach (var product in products)
{
//计算已经下单的总数量
var buyQuantity = db.Connection.ExecuteScalar<int>("select sum(Quantity) from [Order] where ProductId=@ProductId and PromotionId=@PromotionId", new
{
ProductId = product.Id,
PromotionId = product.PromotionId
});
if ( buyQuantity + product.Quantity != product.TotalQuantity )
{
db.Connection.Execute("update Product set Quantity=@Quantity where Id=@Id",new {
Id = product.Id,
Quantity = product.TotalQuantity - buyQuantity
});
}
}
好的,关于这个服务代码的内容就是这些了。现在让我们来运行这个程序,你将会看到以下的输出信息:
[16:20:25 INF] Gateways:[{"Address":"127.0.0.1","Port":8912,"UseSsl":false}]
[16:20:25 INF] Service is starting
[16:20:25 INF] Listening on port 7901
[16:20:25 INF] Listening on port 7901 of IPv6
[16:20:25 INF] 和网关连接成功,网关ip:127.0.0.1 网关端口:8912 网关版本:3.3.10.0
为了方便调试,还可以在DemoController里面增加一个添加产品的方法
/// <summary>
/// 添加产品
/// </summary>
/// <param name="product"></param>
/// <returns>产品id</returns>
public async Task<int> AddProduct(Product product)
{
//先判断产品是否存在
if (await this.CurrentDBContext.Connection.ExecuteScalarAsync<int>("select count(*) from [Product] where Name=@Name", product) == 0)
{
//标识支持分布式事务
this.CurrentDBContext.BeginTransaction();
await this.CurrentDBContext.Connection.ExecuteAsync("insert into [Product] (Name,TotalQuantity,Quantity,PromotionId) values (@Name,@TotalQuantity,@Quantity,@PromotionId)",
product, (IDbTransaction)this.CurrentDBContext.CurrentTransaction);
return await this.CurrentDBContext.Connection.ExecuteScalarAsync<int>("select last_insert_rowid()", null, (IDbTransaction)this.CurrentDBContext.CurrentTransaction);
}
else
{
return await this.CurrentDBContext.Connection.ExecuteScalarAsync<int>("select Id from [Product] where Name=@Name", product);
}
}
资产服务 AssetServiceHost
再创建一个JMS工程,名为:AssetServiceHost
同样引用这两个nuget包
<PackageReference Include="Dapper" Version="2.1.21" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
然后打开工程的配置文件 appsettings.json,修改端口和网关地址
"ServiceAddress": "127.0.0.1", //把微服务注册为什么ip地址,空表示注册为自己的外网地址
"Port": 7902, //微服务端口
"Gateways": [ //网关地址
{
"Address": "127.0.0.1",
"Port": 8912
}
],
SystemDBContext.cs 基本和 ProductServiceHost 一样,只是改改建表语句
public class SystemDBContext : IDisposable, JMS.IStorageEngine
{
static SystemDBContext()
{
//创建表
using var con = CreateConnection();
con.Open();
con.Execute(@"
CREATE TABLE IF NOT EXISTS MoneyAccount (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
UserId INTEGER,
Balance REAL
);
");
}
static SqliteConnection CreateConnection()
{
return new SqliteConnection("data source='data.db'");
}
......
在DemoController.cs里面,增加一个用来支付的函数:
/// <summary>
/// 支付
/// </summary>
/// <param name="userid">用户id</param>
/// <param name="amount">支付金额</param>
/// <returns></returns>
public async Task Pay(int userid,decimal amount)
{
//标识支持分布式事务
this.CurrentDBContext.BeginTransaction();
if( await this.CurrentDBContext.Connection.ExecuteAsync("update MoneyAccount set balance=balance-@amount where UserId=@userid and balance>=@amount", new {
userid,
amount
} , (IDbTransaction)this.CurrentDBContext.CurrentTransaction) == 0)
{
throw new ServiceException("余额不足");
}
}
为了方便调试,可以再增加一个创建账户的函数
/// <summary>
/// 创建一个资金账户
/// </summary>
/// <param name="userid">用户id</param>
/// <param name="balance">账户余额</param>
/// <returns></returns>
public async Task AddMoneyAccount(int userid,decimal balance)
{
//标识支持分布式事务
this.CurrentDBContext.BeginTransaction();
await this.CurrentDBContext.Connection.ExecuteAsync("insert into MoneyAccount (UserId,Balance) values (@userid,@balance)",
new { userid , balance }, (IDbTransaction)this.CurrentDBContext.CurrentTransaction);
}
打开Program.cs文件,改一下注册的服务名称
找到:msp.Register<Controllers.DemoController>("DemoService", "测试服务", false);
修改为:msp.Register<Controllers.DemoController>("AssetService", "资产服务", false);
现在让我们来运行这个程序,你将会看到以下的输出信息:
[15:41:59 INF] Gateways:[{"Address":"127.0.0.1","Port":8912,"UseSsl":false}]
[15:41:59 INF] Service is starting
[15:41:59 INF] Listening on port 7902
[15:41:59 INF] Listening on port 7902 of IPv6
[15:41:59 INF] 和网关连接成功,网关ip:127.0.0.1 网关端口:8912 网关版本:3.3.10.0
对外服务 BusinessServiceHost
再创建一个JMS工程,名为:BusinessServiceHost
你也可以创建一个标准的 Asp.net 工程来做下面这些事情,这样,这个服务就可以直接对外提供 http 服务,而不需要经过网关反向代理,本次使用 JMS 工程,主要是为了带大家熟悉一下 JMS 工程是如何对外提供 http 服务的,毕竟有时会把一些私有服务和对外服务都写在一个工程里
引用nuget包: JMS.Invoker
引用nuget包: JMS.IdentityModel.JWT.Authentication
然后打开工程的配置文件 appsettings.json,修改端口和网关地址
"Port": 7903, //微服务端口
"Gateways": [ //网关地址
{
"Address": "127.0.0.1",
"Port": 8912
}
],
打开Program.cs文件,改一下注册的服务名称
找到:msp.Register<Controllers.DemoController>("DemoService", "测试服务", false);
修改为:msp.Register<Controllers.DemoController>("BusinessService", "对外业务", true);
注意第三个参数是 true , 表示外界可以通过网关、WebApi反向代理访问到此服务
打开 Program.cs,在 InitServices() 注册JWT身份验证组件:
services.AddJwtTokenAuthentication("mysuperret_secretkey!123");
然后,往DemoController.cs里面增加一个登录函数:
/// <summary>
/// 用户登录
/// </summary>
/// <param name="userid">直接传用户id,直接返回token</param>
/// <returns></returns>
public string Login(long userid)
{
//直接生成jwt token
var claims = new Claim[]
{
new Claim(ClaimTypes.NameIdentifier, userid.ToString())
};
//token有效期1年
var time = DateTime.Now.AddYears(1);
var token = JwtHelper.GenerateToken(claims, "mysuperret_secretkey!123", time);
return token;
}
为了测试,我们增加一个函数,负责初始化一些数据:
/// <summary>
/// 初始化所有测试数据
/// </summary>
/// <returns>产品id</returns>
public async Task<int> InitDatas(int count)
{
using ( var rc = new RemoteClient(Global.GatewayAddresses))
{
var productService = await rc.GetMicroServiceAsync("ProductService");
var assetService = await rc.GetMicroServiceAsync("AssetService");
//开启分布式事务
rc.BeginTransaction();
//创建一个产品
var productId = await productService.InvokeAsync<int>("AddProduct", new {
Name = "Product1",
TotalQuantity = 20000,
Quantity = 20000,
Price = 5000,
PromotionId = Guid.NewGuid().ToString("N")
});
//创建资金账户
for(int i = 0; i < count; i++)
{
var userid = i + 1;
var balance = 10000m;
assetService.InvokeAsync("AddMoneyAccount", userid, balance);
}
//提交事务
await rc.CommitTransactionAsync();
return productId;
}
}
接着增加一个购买商品的函数,并用 [JMS.Authorize] 标识此函数必须登录后才能调用
/// <summary>
/// 购买商品
/// </summary>
/// <param name="productId">商品id</param>
/// <param name="quantity">数量</param>
/// <returns></returns>
[JMS.Authorize]
public async Task<decimal> Buy(int productId,int quantity)
{
var userid = int.Parse( this.UserContent.FindFirst(ClaimTypes.NameIdentifier).Value);
//假设商品单价为
using (var rc = new RemoteClient(Global.GatewayAddresses))
{
var productService = await rc.GetMicroServiceAsync("ProductService");
var assetService = await rc.GetMicroServiceAsync("AssetService");
//开启分布式事务
rc.BeginTransaction();
//下单,获取支付金额
var amount = await productService.InvokeAsync<decimal>("CreateOrder", userid, productId, quantity);
//支付
assetService.InvokeAsync("Pay", userid, amount);
//提交事务
await rc.CommitTransactionAsync();
return amount;
}
}
写个控制台程序来测试并发
现在,把三个微服务全部运行起来,然后打开浏览器,输入 http://127.0.0.1:8912/jmsdoc ,可以看到前端接口文档
由于只有【对外业务】这个服务是对外的,所以,接口文档中就只有它的描述了。
下面,我们写个控制台程序来进行测试:
其中需要引用 Microsoft.Extensions.Http 包
static async Task Main(string[] args)
{
Console.WriteLine("按任意键继续");
Console.ReadKey();
const string webApiAddr = "http://127.0.0.1:8912"; //webapi的地址,由于网关具有webapi的所有功能,所以这里可以用网关的地址当webapi用
long productId = 0;
var userCount = 100;//用户数量
string[] tokens = new string[userCount];
int[] indexes = new int[userCount];
for(int i = 0; i < indexes.Length; i++)
{
indexes[i] = i;
}
var services = new ServiceCollection();
services.AddHttpClient();
var serviceProvider = services.BuildServiceProvider();
var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
//初始化数据
if (true)
{
Console.WriteLine("初始化数据...");
var httpClient = httpClientFactory.CreateClient();
StringContent stringContent = new StringContent($"[{userCount}]" , Encoding.UTF8, "application/json");
var sw = Stopwatch.StartNew();
var ret = await httpClient.PostAsync($"{webApiAddr}/BusinessService/InitDatas" , stringContent);
sw.Stop();
var es = sw.ElapsedMilliseconds;
var text = await ret.Content.ReadAsStringAsync();
if(ret.StatusCode == System.Net.HttpStatusCode.OK)
{
Console.WriteLine($"初始化完毕,产品id:{text} 耗时:{es}毫秒 用户数:{userCount}");
productId = long.Parse(text);
}
else
{
Console.WriteLine("初始化异常," + text);
}
}
//登录
if (true)
{
Console.WriteLine("登录...");
long min = long.MaxValue, max = long.MinValue;
await Parallel.ForEachAsync(indexes, CancellationToken.None, async (index , cancellationToken) => {
var httpClient = httpClientFactory.CreateClient();
var userid = index + 1;
StringContent stringContent = new StringContent($"[{userid}]", Encoding.UTF8, "application/json");
var sw = Stopwatch.StartNew();
var ret = await httpClient.PostAsync($"{webApiAddr}/BusinessService/Login", stringContent);
sw.Stop();
var es = sw.ElapsedMilliseconds;
lock (indexes)
{
if (es < min)
min = es;
if (es > max)
max = es;
}
var text = await ret.Content.ReadAsStringAsync();
if (ret.StatusCode == System.Net.HttpStatusCode.OK)
{
tokens[index] = text;
}
else
{
Console.WriteLine($"用户{userid}登录异常,{text}");
}
});
Console.WriteLine($"用户登录完毕,最小耗时:{min}毫秒 最大耗时{max}毫秒");
}
//下单
if (true)
{
Console.WriteLine("开始下单...");
long min = long.MaxValue, max = long.MinValue;
await Parallel.ForEachAsync(indexes, CancellationToken.None, async (index, cancellationToken) => {
var httpClient = httpClientFactory.CreateClient();
var userid = index + 1;
var token = tokens[index];
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(token);
StringContent stringContent = new StringContent($"[{productId} , 1]", Encoding.UTF8, "application/json");
var sw = Stopwatch.StartNew();
var ret = await httpClient.PostAsync($"{webApiAddr}/BusinessService/Buy", stringContent);
sw.Stop();
var es = sw.ElapsedMilliseconds;
var text = await ret.Content.ReadAsStringAsync();
if (ret.StatusCode == System.Net.HttpStatusCode.OK)
{
Console.WriteLine($"用户{userid}下单完毕,耗时{es}毫秒");
}
else
{
Console.WriteLine($"用户{userid}下单异常,{text}");
}
lock (indexes)
{
if (es < min)
min = es;
if (es > max)
max = es;
}
});
Console.WriteLine($"用户下单完毕,最小耗时:{min}毫秒 最大耗时{max}毫秒");
}
Console.ReadLine();
}
最后用 release 模式运行这个程序。
由于Sqlite本身是个单线程数据库,所以并发效率不高,文章涉及的源码都在下面的git仓库里,其中postgresql分支是采用pg数据库,装有pg数据库的朋友可以用这个分支进行测试,我本机电脑测试并发500个用户,大部分用户的下单耗时都在100-300毫秒左右,最大耗时的用户是1秒多一点。
本文代码克隆地址
https://gitcode.com/simpleman2000/JmsDemoProjects.git
master分支:采用sqlite数据库
postgresql分支: 采用pg数据库