大家都是做开发的人,看到“图书商城”这四个字,第一反应可能是“这不就是一个CRUD项目吗”。但如果真拿这种心态去做,项目中途大概率会被订单状态、库存并发、支付回调、ISBN查重这些东西反复折磨。这篇不是我临时编的“练手项目”,而是我从2023年初接到的一家出版社渠道部门图书商城系统需求开始,踩了大半年的坑之后沉淀下来的完整实现思路。本文会从整体技术栈拆解、核心业务细节、实操代码、上线排错、扩展方向五个部分展开,重点讲清楚每一个关键决策背后的“为什么”,你可以直接照着这个方案动手复现,也可以把它当作一套可扩展的骨架来改造。
如果你正准备用C#做商城、进销存或者信息化管理系统,这篇文章应该能帮你省掉不少调研时间。内容不涉及任何花哨的前沿技术,以 .NET 6/8 + EF Core + SQL Server 为主,穿插一些Redis和消息通知的实践,既有代码细节也有面向实际业务的思考过程。
1. 图书商城系统的整体设计与技术栈拆解
1.1 为什么选C#做图书商城,而不是Node或Java
先交代背景。当时对接的这个项目是给一家做教材和社科类图书的渠道商做线上销售系统,内部维护团队的技术栈是.NET,Windows Server是现成的,IIS部署经验也足,所以选型几乎没纠结,直接用C#。这里我不是说C#比其他语言强,而是技术选型这件事,团队熟悉度往往比“哪个语言更火”重要得多。
C#做商城类系统有几个扎实的优势:
- 强类型语言配合现代IDE,重构和排查问题的成本低。动辄几十张表的商城项目,类型安全能挡住一大波低级错误。
- 异步编程模型成熟,
async/await在高并发接口下写起来自然,配合IHttpClientFactory和连接池,不会像某些脚本语言那样一到并发就把CPU烧满。 - 生态完整,EF Core、Dapper、Hangfire、Serilog、FluentValidation这些库都很成熟,能覆盖从数据访问到日志监控的整条链路。
- 部署和维护方便,Windows环境下一个
dotnet publish发布完扔到IIS就能跑,内部系统也不需要搞K8s那套复杂东西。
有人可能会说Java生态更丰富,Node并发更强。但在“中小型团队维护一个业务复杂度大于技术复杂度的商城系统”这个场景里,C#的开发效率和维护体验确实更舒服。
1.2 图书商城系统的模块划分与核心表设计
图书商城和普通电商最大的区别在于“图书”这个商品类型的特殊性。图书有标准的ISBN编号、作者、出版社、版次、印次、条形码、分类(中图法分类)等字段,而且同一种书可能存在精装、平装、电子版等多种形态。这些字段直接影响了数据库表设计和后台录入流程。
我在实际项目里把系统划分成这几个核心模块:
- 商品中心:图书信息维护、ISBN查重、分类树管理、上下架管理。
- 会员中心:用户注册/登录、收货地址、积分、订单查询。
- 购物车与订单中心:购物车增删改查、订单创建、订单状态流转、订单超时关闭。
- 支付中心:对接微信/支付宝,支付回调签名验证、幂等处理。
- 库存中心:入库出库、库存扣减、库存预警。
- 权限系统:后台用户的RBAC权限控制,和C端会员体系完全分开。
核心表我分得很克制,没有一上来就上几十张表:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| Books | 图书主表 | ISBN、书名、作者、出版社、价格、库存、销量、状态 |
| BookCategories | 分类表 | ParentId、Name、Sort、Path |
| Users | C端用户表 | Mobile、NickName、PasswordHash、Score |
| Carts | 购物车表 | UserId、BookId、Quantity、Selected |
| Orders | 订单主表 | OrderNo、UserId、TotalAmount、Status、AddressSnapshot |
| OrderItems | 订单明细表 | OrderId、BookId、BookName、Price、Quantity |
| Payments | 支付流水表 | OrderId、PayType、TradeNo、PayStatus、Amount |
| Admins | 后台用户表 | UserName、PasswordHash、RoleIds |
| Roles / Permissions | 角色、权限表 | RoleId、PermissionCode |
这里有个我特别想强调的点:订单里一定要做“地址快照”和“图书信息快照”,不要直接关联用户地址表和图书表。因为图书价格、书名、出版社随时可能改,用户地址也可能在订单创建后被修改。如果下单后订单明细里不保存当时的快照,商家后台打单时看到的可能已经不是用户下单时的数据了。这是电商系统最容易忽略却影响体验的细节。
1.3 技术选型背后的取舍:EF Core、Redis、JWT怎么看
数据访问层面,我用的是EF Core 8,配合SQL Server 2019。EF Core在中小型系统里开发效率极高,代码里写LINQ,迁移机制也方便。但遇到复杂统计报表、大范围批量更新、多表关联的高性能查询时,我会直接写原生SQL,通过FromSqlRaw或者ExecuteSqlRaw执行,并不会强求所有操作都走EF Core。
举个例子,后台要统计“最近30天TOP100畅销书”,这种场景用EF Core写LINQ也不是不能做,但生成的SQL你可能控制不了。直接写一段带窗口函数的SQL,让DBA看了也放心,然后映射到DTO上,干净利落。
Redis在这个系统中的作用:一是存登录验证码,二是存图书分类树缓存,三是做接口防重和分布式锁。至于购物车数据,我没有放Redis,还是放在数据库里。图书商城用户购物的频次远没有外卖、电商平台那么高,购物车落库既能保证数据不丢,又能避免缓存和数据库数据不一致的问题。
认证方面,C端和后台用的是两套方案。C端用户登录后签发JWT,后台管理员登录后也是签发JWT但带不同的Role标识,通过中间件做权限校验。JWT方案的好处是后端无状态,接口随便水平扩展,不用像Session那样需要考虑会话节点问题。
注意:JWT是无状态不假,但一旦泄露就很难在服务端主动让它失效。网上说的“黑名单”和“访问令牌+刷新令牌”方案要结合业务量来决定复杂度。我在这套系统的后台管理入口额外加了IP白名单校验,C端则用短期Token + 长期RefreshToken的方式控制风险。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心业务细节解析与实操要点
2.1 ISBN校验、去重与图书分类的树形结构
图书商城有一个容易被忽略的坑:ISBN录入和查重。
ISBN分为ISBN-10和ISBN-13两种,老书很多是ISBN-10,新书都是ISBN-13。录入时如果不做校验,用户下单后图书信息就乱了,所以我在后台录入图书时做了两步:先校验ISBN格式合法性,再用ISBN+书名做联合去重。
ISBN-13的校验码算法是加权求和:前12位数字,奇数位乘以1,偶数位乘以3,求和后对10取模,再用10减去余数得到校验位。C#实现如下:
csharp复制public static bool IsValidIsbn13(string isbn)
{
isbn = isbn.Replace("-", "").Replace(" ", "");
if (isbn.Length != 13 || !isbn.All(char.IsDigit))
return false;
var sum = 0;
for (int i = 0; i < 12; i++)
{
var digit = isbn[i] - '0';
sum += (i % 2 == 0) ? digit : digit * 3;
}
var checkDigit = (10 - (sum % 10)) % 10;
return checkDigit == (isbn[12] - '0');
}
这个校验逻辑在客户端做一遍只是提升体验,服务端必须再做一遍,因为客户端校验随时可能被绕过。后端校验不通过直接拒绝入库。
图书分类结构我用的经典ParentId树形表,但有个优化点:查询分类树时,用Path字段(比如“01-0101-010101”)保存从根到自身的ID路径,配合一次查询全部数据后用内存构建树。这样既不用递归查库,也不用引入CTE,对图书分类这种深度不超过4层的场景完全够用。
2.2 购物车加购与库存扣减的并发处理
图书商城虽然并发量比不上秒杀平台,但“库存扣减”这个环节依然是所有商城系统的核心难点。尤其是教材类图书在开学季会突然爆量,同一本书可能会有大量用户同时加购、下单。
我的方案是:库存扣减不用“先查再扣”的写法,而是用带条件的一次性UPDATE语句:
csharp复制var rows = await db.Database.ExecuteSqlRawAsync(
"UPDATE Books SET Stock = Stock - @quantity, Sales = Sales + @quantity WHERE Id = @bookId AND Stock >= @quantity",
new SqlParameter("@quantity", quantity),
new SqlParameter("@bookId", bookId));
if (rows == 0)
throw new BizException("库存不足");
这个写法的核心思路是让数据库来保证原子性,WHERE Stock >= @quantity这个条件利用数据库行锁来防止超卖,不需要在应用层加分布式锁。
但这里有个必须注意的点:如果同一事务里要扣多个甚至几十个图书的库存,需要考虑加锁顺序的问题。两个用户同时下单且都包含A、B两本书时,如果线程1先扣A再扣B,线程2先扣B再扣A,就可能出现死锁。所以我写了一个公共方法,先把订单里的图书按照BookId排序,再按排序后的顺序逐个扣库存,从根源上避免交叉锁——这个坑我是在压测的时候踩到的,加购多本书的订单并发执行时死锁率非常高。
2.3 订单状态机与超时自动关闭
订单状态是这个系统里“看似简单但最容易写乱”的部分。我一开始也想过用简单的枚举字段,直接在各处代码里改状态,后来发现涉及支付回调、后台发货、用户取消、超时关闭多个入口,一旦状态跳转逻辑散落在各个方法里,后台上架改需求时根本兜不住。
我把订单状态定义成一张明确的状态机表:
| 当前状态 | 可触发动作 | 目标状态 |
|---|---|---|
| Pending(待支付) | 用户支付成功 | Paid(已支付) |
| Pending(待支付) | 用户取消 / 超时关闭 | Cancelled(已取消) |
| Paid(已支付) | 后台发货 | Shipped(已发货) |
| Shipped(已发货) | 用户确认收货 / 自动收货 | Completed(已完成) |
| Paid / Shipped | 用户发起售后 | Refunding(退款中) |
状态机我用一个静态类封装,核心方法是“判断当前状态+动作是否合法”,非法跳转直接抛业务异常。这样写的好处是:所有状态流转规则集中在一个类里,新同事接手时看这个类就够了,也没有人能在订单已发货后直接把状态改成已取消。
超时自动关闭订单我用的是最简单的BackgroundService方案,没有引入Hangfire或者Quartz。后台每隔1分钟扫描一次超过30分钟未支付的订单,将其置为Cancelled,同时回滚库存。这里有个细节:回滚库存不能用Stock + 1这样简单加回去,因为用户下单后可能又发生了其他库存变化,正确做法是根据订单明细里锁定的数量做回滚,方向与扣减相反即可。
重要提示:千万不要用“扫表+改状态”的方式直接在业务高峰期做,要把扫描频率控制在合理区间,且每次只捞前几百条待处理的订单,避免大事务锁表。
2.4 支付回调、订单事件与通知解耦
支付回调是商城系统的另一个高压区。支付宝和微信的回调为了保证送达会重试多次,服务端接口必须做幂等处理。我给每个订单的支付回调处理函数加了一个“防重入”逻辑:先检查支付流水表中是否已经存在相同trade_no的记录,存在则直接返回成功;不存在则开启事务,更新支付流水、更新订单状态、扣减库存、加积分,全部完成后再提交事务。
系统里还有一些动作需要在订单支付成功后触发:发邮件、发短信、记录日志、积分变动。如果直接在支付回调里一串写下去,回调接口就会越来越长,而且任何一个通知服务超时都会拖累支付回调的响应,导致第三方支付平台反复重试。我用C#的event和委托机制做了一个轻量级事件发布:
csharp复制public static class OrderEvents
{
public static event Func<OrderPaidEventData, Task>? OnOrderPaid;
public static async Task PublishOrderPaid(OrderPaidEventData data)
{
if (OnOrderPaid != null)
await OnOrderPaid(data);
}
}
支付成功后调用OrderEvents.PublishOrderPaid(...),短信服务和积分服务在程序启动时各自注册自己的处理逻辑。这样支付回调只负责更新订单状态和发布事件,短信超时不影响回调主流程。
不过我得说句实在话:这种轻量事件方式适合单体应用内部解耦,项目复杂度上去后还是要替换成消息队列或者成熟的EventBus框架。我这么做是因为单体阶段引入消息队列反而增加运维成本。
3. 实操过程:从空解决方案到可运行的图书商城核心部分
3.1 分层解决方案结构(DDD与经典三层的平衡)
搭建解决方案的时候,我没有完全照搬DDD那套复杂的架构,而是采用了更务实的分层方式,让团队里每个成员都能快速理解:
code复制BookMall.sln
├── src
│ ├── BookMall.Domain // 领域实体、枚举、状态机
│ ├── BookMall.Application // 应用服务、DTO、接口
│ ├── BookMall.Infrastructure // EF Core DbContext、仓储、缓存、外部服务
│ └── BookMall.Web // Web API、控制器、中间件、JWT配置
└── tests
├── BookMall.UnitTests // 单测:ISBN校验、状态机、价格计算
└── BookMall.IntegrationTests // 集成测试:订单流程、支付回调
Domain层不引用任何基础设施,只有实体和纯逻辑。Infrastructure层引用Domain,实现仓储和DbContext。Application层只依赖抽象接口,不直接引用EF Core。Web层是最外层的壳,负责接收HTTP请求和返回响应。
这样的分层在单体应用里可能显得“重”,但对图书商城这种业务逻辑密集的项目很有价值。我见过太多Controller里直接写context.Orders.Where(...)的案例,刚开始写起来快,后面一改需求就到处是伤。分层的核心目的不是赶时髦,而是让业务规则有一个明确的归属地。
3.2 数据库表设计与EF Core Fluent配置示例
数据库表我用了EF Core的Fluent API来做配置,原因是不想在实体类上堆一大堆[Required]、[MaxLength]这种特性污染领域模型。这里给出Books表的配置片段:
csharp复制public class BookConfiguration : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
builder.ToTable("Books");
builder.HasKey(x => x.Id);
builder.Property(x => x.Isbn).HasMaxLength(20).IsRequired();
builder.HasIndex(x => x.Isbn).IsUnique();
builder.Property(x => x.Title).HasMaxLength(200).IsRequired();
builder.Property(x => x.Author).HasMaxLength(100).IsRequired();
builder.Property(x => x.Publisher).HasMaxLength(100).IsRequired();
builder.Property(x => x.Price).HasPrecision(10, 2);
builder.Property(x => x.Stock).HasDefaultValue(0);
builder.Property(x => x.Sales).HasDefaultValue(0);
}
}
HasIndex(x => x.Isbn).IsUnique()是必须的,这从数据库层面保证了ISBN不能重复,比单纯在应用层做校验靠谱得多。价格字段用HasPrecision(10, 2)而不是double,避免金额出现浮点误差。
数据库迁移命令我习惯写进项目文档里:
bash复制dotnet ef migrations add InitBookMall
dotnet ef database update
在开发环境可以这么快速迭代,但生产环境建议生成SQL脚本后由DBA审核执行:
bash复制dotnet ef migrations script -o migrate.sql
3.3 登录、JWT与权限控制的最小实现
后台管理员登录这里我单独讲一下,因为内部系统对安全性的要求和C端不一样。管理员密码存储用的是BCrypt,不是MD5加盐那种老方案。BCrypt自带盐且计算开销可控,暴库后暴力破解的成本要高很多。
签发JWT的代码大致是这样:
csharp复制public string GenerateToken(Admin admin)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, admin.Id.ToString()),
new Claim(ClaimTypes.Name, admin.UserName),
};
foreach (var permissionCode in admin.PermissionCodes)
{
claims.Add(new Claim("permission", permissionCode));
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SecretKey"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: config["Jwt:Issuer"],
audience: config["Jwt:Audience"],
claims: claims,
expires: DateTime.Now.AddHours(2),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
权限校验我用了一个自定义中间件或过滤器,在需要权限的接口上标注特性:
csharp复制[Permission("order:ship")]
[HttpPost("ship/{id}")]
public async Task<IActionResult> Ship(long id)
{
// 发货逻辑
}
进入接口前,中间件检查JWT里的permission声明是否包含order:ship,没有则返回403。这套RBAC实现虽然原始,但胜在直观好维护,后台十几个管理员的权限管理绰绰有余。
注意:JWT密钥一定要放到配置文件外,用环境变量或者Windows凭据库保存。开发时提交到Git仓库里无所谓,生产环境密钥泄露等同于后台权限全部沦陷。
3.4 验证码、防重复提交与服务端校验
用户登录、注册、找回密码这些接口要防机器人刷,我用的是纯后端验证码方案:生成一个随机数字/字母组合,存到Redis,key是captcha:{guid},过期时间5分钟。接口上同时返回captchaId和图片,用户提交时带上captchaId和用户输入的验证码,服务端从Redis取出比对,验证通过后立即删除key。用完即删这个细节很重要,不然同一个验证码可以反复提交。
接口防重复提交我用Redis的SET NX EX命令实现了一个简单的分布式锁:
csharp复制var lockKey = $"order:submit:{userId}:{bookIdListHash}";
var lockValue = Guid.NewGuid().ToString();
if (!redis.SetNx(lockKey, lockValue, TimeSpan.FromSeconds(5)))
throw new BizException("请勿重复提交订单");
try
{
// 创建订单逻辑
}
finally
{
redis.DelIfEquals(lockKey, lockValue);
}
这里用DelIfEquals而不是直接Del,是为了防止误删别人持有的锁。在长事务里,如果第一个线程的锁过期了,第二个线程拿到锁,然后第一个线程执行完释放锁时把第二个线程的锁误删,就会出问题。所以释放锁前先比较value值,只有是自己设置的value才删除。
服务端校验和前端校验的边界也要划分清楚。前端校验是为了用户体验,服务端校验是为了数据安全。所有关键参数(价格、数量、金额、库存)必须以服务端计算结果为准,前端传来的任何价格字段都不能直接信。
3.5 图书入库、库存、货架与拣货贴打印的落地
图书商城不同于普通服装店,它的库存管理很多时候不是“有几本”那么简单,还涉及到库位管理。真实的出版社渠道业务里,一个仓库里同样一本书可能在A货架和C货架都有存放。为了贴近实际业务,我在库存表里增加了WarehouseId和ShelfCode字段,支持同一本书在不同库位分别出入库。
图书入库流程我做了个简化版:
- 管理员扫描/录入ISBN,系统自动查询接口补全书名、作者、出版社、封面图。
- 填写入库数量、采购价、仓库和货架位置。
- 提交后系统自动更新Books表的库存总数,同时写入一条库存流水。
这里有个实际经验:库存流水表一定要设计好,要能追溯到每一次入库、出库、盘点、退货调整的操作人和操作时间。商城运营到后面,图书丢件、破损、退货的情况非常多,没有流水账根本查不清。
再来说说这个项目里非常有意思的一个需求:拣货贴打印。后台订单审核通过后,仓库需要打印拣货贴,贴到拣货筐或者打包箱上,方便拣货员对照拣货。我的实现是用一个专门打印接口返回HTML内容,后端通过一个支持HTML转图片的库(比如Core HTML to Image或者走前端打印路由)生成标签:
- 顶部左侧:订单号、下单时间。
- 顶部右侧:订单状态条码(Code128格式,用
ZXing.Net生成)。 - 中部:用户收货人姓名、电话、省市区、详细地址。
- 底部:商品明细列表(书名、ISBN、数量),按货架号排序。
打印排版我用的是固定宽度的CSS模板,A4纸一版打4张,仓库用普通喷墨打印机就能打。标签内容能从订单实时生成,也支持批量多订单连续打印。这个功能逻辑不复杂,但对仓库日拣货效率的提升是肉眼可见的。
4. 常见问题与排查技巧实录
4.1 连接池爆满和SQL慢查询的排查
上线一周后我接到运维电话,说数据库连接数快满了。查了一圈,发现根子出在几个写得很随意的代码点上:一些仓储方法没有及时DisposeDbContext、后台列表查询没有分页却把全表数据拉到了内存、N+1查询导致一次页面请求额外发了几百条SQL。
排查手段我直接上SQL Server的动态管理视图,把消耗最大的查询捞出来:
sql复制SELECT TOP 20
qs.total_elapsed_time / qs.execution_count AS avg_elapsed_time,
qs.execution_count,
SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset END - qs.statement_start_offset)/2)+1) AS query_text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
ORDER BY qs.total_elapsed_time DESC;
这条SQL能瞬间告诉你哪些语句是“吃时间的大户”,然后再看执行计划是缺索引还是扫描太宽。后来给Orders.CreateTime、OrderItems.OrderId、Books.CategoryId都补了索引,连接数立刻降下来了。
4.2 EF Core的N+1查询问题
EF Core的导航属性虽然好用,但用不好就是性能杀手。我踩过的具体场景是:订单列表要显示每个订单的图书明细,直接用Include可能生成一条大SQL,但如果在循环里访问订单的导航属性,就会逐个订单发起查询。
解决办法很简单:列表查询用AsNoTracking(),需要连表数据时一次性Include,或者干脆用Select投影到DTO。EF Core 8里AsSplitQuery()也可以解决多级Include时的笛卡尔积问题,但要注意并不是所有场景都适合,需要看生成的SQL执行计划来定。
4.3 数据库死锁与并发扣库存的实战处理
前面提到过死锁问题,这里说下具体现象和排查过程。压测时只要并发下单包含两本以上相同图书的订单,数据库就会时不时报死锁。我用SQL Server的sys.dm_tran_locks去看锁等待关系,发现两个事务都在申请对方持有的行锁。
解决方案就是我在2.2里提到的:所有事务内按BookId排序后再扣库存。这个方法很土但极其有效,死锁率直接降为0。另一个辅助手段是给扣库存的SQL加上WITH (UPDLOCK)滚动更新锁提示,但这属于优化技巧,不推荐默认使用,因为过度加锁反而降低并发度。
4.4 JSON循环引用与序列化性能
接口返回数据时遇到过一个经典问题:Order里包含User,User里又包含他的Orders,返回订单列表时JSON序列化直接报“检测到循环引用”。这个我在.NET 8里通过全局配置解决:
csharp复制builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
但我要说的是,IgnoreCycles只能说是兜底方案,治标不治本。正确的做法是接口返回时一律输出DTO,不直接把实体暴露给前端。这样既能避免循环引用,又能防止前端拿到不该拿的字段(比如密码哈希、内部备注)。我把这个规范定成了团队铁律:Controller层永远不允许直接返回实体类。
4.5 常见问题速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 接口偶发超时 | 连接池爆满 | 检查是不是没释放DbContext,列表查询是否没分页 |
| 订单状态莫名变回待支付 | 旧代码直接改状态绕过状态机 | 排查是否有接口没走状态机校验 |
| 数据库CPU飙高 | 缺索引或全表扫描 | 用sys.dm_exec_query_stats查慢SQL |
| 重复支付回调导致积分加两次 | 没有做幂等校验 | 支付回调里先查流水表,重复请求直接返回成功 |
| 后台权限无论怎么配都403 | JWT里的Claim没更新 | 权限变更后重新登录生成新Token |
| 打印拣货贴时条形码扫不出来 | 生成条形码内容有空格/换行 | 条形码数据清空空白字符再用ZXing生成 |
5. 上线后的性能优化与扩展方向
5.1 缓存分层:Redis + 内存缓存的组合
系统稳定跑起来后,我开始做性能优化。图书商城的首页、分类页、推荐位这些数据访问频率高但变更频率极低,非常适合缓存。我在Redis里缓存了分类树和热门图书列表,应用启动时加载一次,后台修改分类或调整推荐位时主动删除缓存。
对于单台服务器部署的小型系统,进程内缓存(IMemoryCache)也值得用,比Redis少一次网络IO。但要注意进程内缓存和多实例部署是互斥的,如果后续要横向扩展就要统一改Redis。我的实践是:热门书籍详情用内存缓存,分类树这种所有实例都要用的一致性强数据用Redis。
5.2 搜索与报表场景怎么办
图书商城的搜索和普通电商不一样,用户更习惯通过书名、作者、出版社、ISBN来搜。数据量在几万本以内时,SQL Server的全文索引配合LIKE查询完全够用。我当时只用了简单的EF.Functions.Like去匹配书名和作者,只有在数据量突破几十万本时再考虑Elasticsearch。
报表这块我单独建了一个只读副本库,后台的销售报表、库存报表都查副本库,不碰业务主库。这样做的好处是报表的复杂查询不会影响线上订单业务,缺点是数据有轻微延迟。对图书商城这种业务来说,分钟级的报表延迟完全可接受。
5.3 扩展方向:多商户、优惠券、秒杀场景
这套系统如果再往上扩展,有几个方向值得考虑:
- 多商户:把Books表增加
MerchantId,订单表增加MerchantId,购物车按商户拆分结算。后台权限模型从“角色权限”升级为“商户+角色权限”。 - 优惠券:要新增优惠券模板、用户领券、订单抵扣、券过期定时任务等。核心难点是“一批领取多并发”和“优惠金额分摊规则”。
- 秒杀场景:图书秒杀虽然不像数码产品那么激烈,但热门教材开售时也可能出现瞬间高并发。方案是Redis预扣库存 + 消息队列异步落库,用Redis的原子递减来判断是否还有库存,再通过队列把订单写进数据库。
5.4 日志、监控与健康检查
日志这块我用的Serilog + SQL Server/文件双重输出,结构化日志能直接查某一个订单ID完整链路的所有日志记录。生产环境我把日志级别调成Information,记录每个请求的耗时、用户ID、订单号,排查问题时非常给力。
健康检查用ASP.NET Core自带的AddHealthChecks,暴露一个/health接口给运维监控系统定时探测。这个接口只检查数据库连接、Redis连接、磁盘可用空间这几个关键指标,不检查业务逻辑,避免误报。
6. C#实践过程中的一点额外心得
这篇写下来,很多读者可能会觉得“代码量没有想象中那么多”。确实,图书商城系统最核心的东西不在代码量,而在于对业务状态和并发场景的拆解能力。
我在这个项目里最大的收获有这几条:
第一,库存扣减和订单状态流转是整个系统的生命线,任何时候都要优先保证这两块的正确性。功能可以慢慢加,但订单数据不能错。
第二,分层架构不是为了好看,而是为了让你在三个月后还能改得动代码。尤其是状态机和权限这种容易到处乱飞的逻辑,如果不定好边界,后期维护就是一场灾难。
第三,Redis、事件、缓存这些技术都只是工具,用不用、怎么用要看业务场景。图书商城不是高并发秒杀系统,堆太多中间件反而会让小团队为难。
最后分享一个开发期的小建议:开始编码之前,先把订单状态机和库存扣减逻辑写成单元测试。这两个模块逻辑最复杂也最容易改坏,有了测试保护,后面每一次重构心里都有底。
按照这个方案一步步来,一套能上线、能维护、能扩展的C#图书商城系统是完全可以自己做出来的。如果你正在做类似的项目,希望这篇能帮你少踩几个坑。
