2024年10月

一:背景

1. 讲故事

上篇聊到了如何对AOT程序进行轻量级的APM监控,有朋友问我如何获取AOT程序的CPU利用率,本来我觉得这是一个挺简单的问题,但一研究不是这么一回事,这篇我们简单的聊一聊。

二:如何获取CPU利用率

1. 认识cpuUtilization字段

熟悉.NET底层的朋友应该知道,.NET线程池中有一个
cpuUtilization
字段就记录了当前机器的CPU利用率,所以接下来的思路就是如何把这个字段给挖出来,在挖这个字段之前也要知道 .NET6 为界限出现过两个线程池。

1)win32threadpool.cpp

这是 .NET6 之前一直使用的 .NET线程池,它是由 clr 的
1)win32threadpool.cpp
实现的,参考代码如下:


SVAL_IMPL(LONG,ThreadpoolMgr,cpuUtilization);

  1. PortableThreadPool.cs

为了更好的跨平台以及高层统一, .NET团队用C#对原来的线程池进行了重构,所以这个字段自然也落到了C#中,参考如下:


internal sealed class PortableThreadPool
{
    private int _cpuUtilization;
}

  1. WindowsThreadPool.cs

我原以为线程池已经被这两种实现平分天下,看来我还是年轻了,不知道什么时候又塞入了一种线程池实现
WindowsThreadPool.cs
,无语了,它是简单的 WindowsThreadPool 的 C#封装,舍去了很多原来的方法实现,比如:


internal static class WindowsThreadPool
{
    public static bool SetMinThreads(int workerThreads, int completionPortThreads)
    {
        return false;
    }
    public static bool SetMaxThreads(int workerThreads, int completionPortThreads)
    {
        return false;
    }

    internal static void NotifyThreadUnblocked()
    {
    }

    internal unsafe static void RequestWorkerThread()
    {
        //todo...
        //提交到 windows线程池
        Interop.Kernel32.SubmitThreadpoolWork(s_work);
    }
}

而这个也是 Windows 版的AOT默认实现,因为 Windows线程池是由操作系统实现,没有源码公开,观察了reactos的开源实现,也未找到类似的
cpuUtilization
字段,这就比较尴尬了,常见的应对措施如下:

  1. 因为dump或者program中没有现成字段,只能在程序中使用代码获取。
  2. 修改windows上的 aot 默认线程池。

2. 如果修改AOT的默认线程池

在微软的官方文档:
https://learn.microsoft.com/zh-cn/dotnet/core/runtime-config/threading
上就记录了Windows线程池的一些概况以及如何切换线程池的方法,截图如下:

这里选择 MSBuild 的方式来配置。


<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFramework>net8.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<PublishAot>true</PublishAot>
		<UseWindowsThreadPool>false</UseWindowsThreadPool>
		<InvariantGlobalization>true</InvariantGlobalization>
	</PropertyGroup>
</Project>

接下来写一段简单的C#代码,故意让一个线程死循环。


    internal class Program
    {
        static void Main(string[] args)
        {
            Task.Run(() =>
            {
                Test();
            }).Wait();
        }

        static void Test()
        {
            var flag = true;
            while (true)
            {
                flag = !flag;
            }
        }
    }

这里要注意的一点是发布成AOT的程序不能以普通的带有元数据的C#程序来套。毕竟前者没有元数据了,那怎么办呢?这就考验你对AOT依赖树的理解,熟悉AOT的朋友都知道,依赖树的构建最终是以有向图的方式存储在 _dependencyGraph 字段中,每个节点由基类 NodeFactory 承载,参考代码如下:


public abstract class Compilation : ICompilation
{
    protected readonly DependencyAnalyzerBase<NodeFactory> _dependencyGraph;
}

public abstract partial class NodeFactory
{
    public virtual void AttachToDependencyGraph(DependencyAnalyzerBase<NodeFactory> graph)
    {
        ReadyToRunHeader = new ReadyToRunHeaderNode();

        graph.AddRoot(ReadyToRunHeader, "ReadyToRunHeader is always generated");
        graph.AddRoot(new ModulesSectionNode(), "ModulesSection is always generated");

        graph.AddRoot(GCStaticsRegion, "GC StaticsRegion is always generated");
        graph.AddRoot(ThreadStaticsRegion, "ThreadStaticsRegion is always generated");
        graph.AddRoot(EagerCctorTable, "EagerCctorTable is always generated");
        graph.AddRoot(TypeManagerIndirection, "TypeManagerIndirection is always generated");
        graph.AddRoot(FrozenSegmentRegion, "FrozenSegmentRegion is always generated");
        graph.AddRoot(InterfaceDispatchCellSection, "Interface dispatch cell section is always generated");
        graph.AddRoot(ModuleInitializerList, "Module initializer list is always generated");

        if (_inlinedThreadStatics.IsComputed())
        {
            graph.AddRoot(_inlinedThreadStatiscNode, "Inlined threadstatics are used if present");
            graph.AddRoot(TlsRoot, "Inlined threadstatics are used if present");
        }

        ReadyToRunHeader.Add(ReadyToRunSectionType.GCStaticRegion, GCStaticsRegion);
        ReadyToRunHeader.Add(ReadyToRunSectionType.ThreadStaticRegion, ThreadStaticsRegion);
        ReadyToRunHeader.Add(ReadyToRunSectionType.EagerCctor, EagerCctorTable);
        ReadyToRunHeader.Add(ReadyToRunSectionType.TypeManagerIndirection, TypeManagerIndirection);
        ReadyToRunHeader.Add(ReadyToRunSectionType.FrozenObjectRegion, FrozenSegmentRegion);
        ReadyToRunHeader.Add(ReadyToRunSectionType.ModuleInitializerList, ModuleInitializerList);

        var commonFixupsTableNode = new ExternalReferencesTableNode("CommonFixupsTable", this);
        InteropStubManager.AddToReadyToRunHeader(ReadyToRunHeader, this, commonFixupsTableNode);
        MetadataManager.AddToReadyToRunHeader(ReadyToRunHeader, this, commonFixupsTableNode);
        MetadataManager.AttachToDependencyGraph(graph);
        ReadyToRunHeader.Add(MetadataManager.BlobIdToReadyToRunSection(ReflectionMapBlob.CommonFixupsTable), commonFixupsTableNode);
    }
}

结合上面的代码,我们的 PortableThreadPool 静态类会记录到根区域的 GCStaticsRegion 中,有了这些知识,接下来就是开挖了。

3. 使用 windbg 开挖

用 windbg 启动生成好的 aot程序,接下来用
Example_21_8!S_P_CoreLib_System_Threading_PortableThreadPool::__GCSTATICS
找到类中的静态字段。


0:007> dp Example_21_8!S_P_CoreLib_System_Threading_PortableThreadPool::__GCSTATICS L1
00007ff6`e4b7c5d0  000002a5`a4000468
0:007> dp 000002a5`a4000468+0x8 L1
000002a5`a4000470  000002a5`a6809ca0
0:007> dd 000002a5`a6809ca0+0x50 L1
000002a5`a6809cf0  0000000a
0:007> ? a
Evaluate expression: 10 = 00000000`0000000a

从上面的卦中可以清晰的看到,当前的
CPU=10%
。这里稍微解释下
000002a5a4000468+0x8
是用来跳过vtable从而取到类实例,后面的
000002a5a6809ca0+0x50
是用来获取 PortableThreadPool._cpuUtilization 字段的,布局参考如下:


0:012> !dumpobj /d 27bc100b288
Name:        System.Threading.PortableThreadPool
MethodTable: 00007ffc6c1aa6f8
EEClass:     00007ffc6c186b38
Tracked Type: false
Size:        512(0x200) bytes
File:        C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.8\System.Private.CoreLib.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffc6c031188  4000d42       50         System.Int32  1 instance                10 _cpuUtilization
00007ffc6c0548b0  4000d43       5c         System.Int16  1 instance               12 _minThreads
00007ffc6c0548b0  4000d44       5e         System.Int16  1 instance            32767 _maxThreads

三:总结

总的来说如果你的AOT使用默认的 WindowsThreadPool,那想获取 cpu利用率基本上是无力回天,当然有达人知道的话可以告知下,如果切到默认的
.NET线程池
还是有的一拼,即使没有 pdb 符号也可以根据_minThreads和_maxThreads的内容反向搜索。
图片名称

前言

嗨,大家好!今天我们要聊一聊 .NET 中的内存管理。你知道吗?虽然 .NET 有一个很好的垃圾回收系统来自动清理不再使用的对象,但在某些情况下,我们还需要自己动手来释放一些特殊的资源,比如打开的文件或数据库连接。如果不这样做,可能会导致程序运行不畅甚至崩溃。在本文里,将介绍两种简单有效的方式来管理这些资源:使用 using 语句和显式调用 Dispose 方法。这两种方式可以我们更有效地控制资源的生命周期,避免内存泄漏等问题,确保应用程序的健壮性。不管是刚入门的小白还是技术大牛,希望你能从这篇文章中学有用的知识和技巧,让我们的程序运行的更稳、更靠谱。

正文

在 .NET 中内存管理主要依赖于垃圾回收机制,主要是指内存管理和非托管资源的释放。但是,有时候我们可能需要更细粒度地控制某些资源的释放。两种主要的方式进行处理

  • 垃圾回收(GC)
  • 确认性资源释放(DRD)

官网相关文档
https://learn.microsoft.com/zh-cn/dotnet/standard/managed-code

垃圾回收(Garbage Collection)

垃圾回收是 .NET 中一个非常重要的自动内存管理机制。它帮助我们自动清理不再使用的对象,并释放这些对象占用的内存,避免了手动管理内存的繁琐的工作,使我们能够更加专注于编写业务逻辑。

1、为什么需要垃圾回收?

  • 避免内存泄漏:垃圾回收自动检测不再使用的对象,并释放它们占用的内存空间。
  • 简化代码:无需手动释放内存,减少了代码中的错误和负担。

2、垃圾回收有哪些特点?

  • 自动运行,不需要开发者显性调用
  • 当内存不足时触发
  • 释放托管内存(即通过.NET内村分配的内存)
  • 不保证立即释放内存,而是根据内存压力情况周期性地进行

3、垃圾回收有什么局限性?

  • 无法处理非托管资源,如文件句柄、数据库链接、图形设备接口(GDI)对象等
  • 可能会导致应用程序出现短暂的暂停(GC暂停)

4、垃圾回收需要注意什么?

  • 尽量避免大对象堆:大对象会直接分配到大对象堆,可能会导致垃圾回收器更频繁地工作。
  • 适时调用 GC.Collect():虽然大多数情况下不需要手动触发垃圾回收,但在某些特殊场景下,如长时间运行的应用程序,可以考虑适时调用 GC.Collect() 来帮助回收内存。

确定性资源释放

对于非托管资源.NET提供了确定性的资源释放机制,通常通过IDisposable接口实现。

1、使用 using 语句

.NET 提供了 IDisposable 接口来帮助管理非托管资源(例如文件句柄、数据库连接等)。

使用using语句来自动释放实现IDsposable的对象所持有的资源,使用 using 语句可以确保即使在发生异常的情况下也能正确释放资源。

实例中StreamReader实现了IDsposable接口。

通过使用using语句,当StreamReader对象超出作用域时,Dispose方法会被自动调用,从而释放文件句柄。

usingSystem;usingSystem.IO;classProgram
{
static voidMain()
{
using (var stream = new FileStream("demo.txt", FileMode.Open))
{
byte[] buffer = new byte[1024];int bytesRead = stream.Read(buffer, 0, buffer.Length);//处理读取的数据 }//文件流会自动关闭 }
}

2、显式调用 Dispose 方法

如果不能使用 using 语句(例如在循环中或其他复杂情况下),可以手动调用 Dispose 方法来释放资源。当一个对象实现了IDsposable接口,意味着它持有需要手动释放的资源,实现IDsposable的对象必须重写Dispose方法来清理非托管缓存。

usingSystem;usingSystem.IO;classProgram
{
static voidMain()
{
FileStream stream
= new FileStream("demo.txt", FileMode.Open);try{byte[] buffer = new byte[1024];int bytesRead = stream.Read(buffer, 0, buffer.Length);//处理读取的数据 }finally{
stream.Dispose();
}
}
}

总结

好了,我们今天聊了聊 .NET 中的内存管理。通过使用 using 语句和显式调用 Dispose 方法,我们可以更好地控制那些特殊的资源,比如文件和数据库连接。这样不仅能避免程序出错,还能让我们的程序运行得更加顺畅。

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号 [
DotNet技术匠
] 社区,与其他热爱技术的同行一起交流心得,共同成长!

前言

随着Vulkan的普及,OpenGL已经在被慢慢淘汰,更轻的API调用可以节省不少性能,尤其是在移动平台上,可以减少CPU开销,进而减少功耗。看起来很完美,但是问题是目前移动平台Vulkan驱动存在很多兼容性问题,大家主流的做法都是通过白名单的方式去开Vulkan,所以目前我们还是要继续以OpenGL为主。此文的目的是笔者在优化OpenGL的时候积累的一些经验,因为使用的引擎是UE4,所以这里的优化是以UE4展开的,当然大部分优化都是通用的。

优化

在诸多API中,耗时比较高的有如下这些

  1. 设置texture
  2. 设置buffer
  3. 设置uniform、uniform buffer
  4. 设置program
  5. 更新texture
  6. 更新buffer
  7. 编译shader

其它API也有开销,但是不是特别明显或者尽量避免即可(比如设置render target),可以针对性做些优化,一般状态缓存就能比较好的解决问题。

因为移动平台目前主流机器都是TBDR构架,不同平台有自己的减少overdraw的策略,比如高通的LRZ、ARM的FPK以及PowerVR的HSR技术。所以我们排序可以以渲染状态为主来排序,当然老的机器上因为实现不好,可能还是按距离排序能减少更多overdraw。接下来我们针对上面提到的开销大的API针对性做优化。

设置texture

  1. 尽量Pack纹理通道,比如Normal使用两个通道
  2. 使用Atlas合并贴图
  3. 使用Texture2DArray合并贴图
  4. 将通用的纹理固定到特定slot上,比如shadow map,reflection texture,cluster shading 相关buffer等

SHADER_PARAMETER_TEXTURE_EX(Texture2D, DirectionalLightShadowTexture, 3)

  1. UE每个DC设置完后会把没用到的texture置成None,这样是为了解决某些驱动的问题,可以优化,太过于保守了。

设置Buffer

  1. 相关性比较强的buffer尽量放到一起,比如normal和tangent
  2. 使用大buffer+offset的方式管理buffer,这个在后面更新buffer会详细讲解

设置uniform、unform buffer

在4.21之前,ES31下面是完全使用uniform buffer,从4.21之后可以使用emulated uniform buffer,这个东西就是你上层设置更新还是使用的uniform buffer的接口,但是实际上底层用的是uniform。按官方的说法是可以节省大量的内存并且会提升性能

但是实际上我们测试下来开销还是很高,因为设置的uniform数量会变很多,那么有没有更好的优化方式呢?当然是有的,既然是想省内存和性能,那么我们可以使用混合的方式,让uniform和uniform buffer共存使用。哪些适合用uniform buffer呢,像View、DirectionalLight、Shadow这种per frame或者multi frame的就适合,因为数量少,但是像Primitive这种数量特别大的就不适合。

另外UE本身实现的emulated uniform buffer因为在使用的时候并没有把数据完全Pack起来,这个地方也可以在编译期将它们pack到一起并记录下来运行时拷贝到对应的offset处。

优化前

优化后

#define View_IndirectLightingCacheShowFlag (pc0_h[11].x)

#define View_ReflectionEnvironmentRoughnessMixingScaleBiasAndLargestWeight (pc0_h[10].xyz)

#define View_HighResolutionReflectionCubemapMaxMip (pc0_h[9].x)

#define View_ReflectionCubemapMaxMip (pc0_h[8].x)

#define View_SkyLightColor (pc0_h[7].xyzw)

#define View_NormalCurvatureToRoughnessScaleBias (pc0_h[6].xyz)

#define View_IndirectLightingColorScale (pc0_h[5].xyz)

#define View_CullingSign (pc0_h[4].x)

#define View_PreExposure (pc0_h[3].x)

#define View_ViewSizeAndInvSize (pc0_h[2].xyzw)

#define View_ViewRectMin (pc0_h[1].xyzw)

#define View_PreViewTranslation (pc0_h[0].xyz)

uniform highp vec4 pc0_h[12];

layout(std140) uniform pb0

{

vec4 Padding0[76];

highp vec3 View_PreViewTranslation;

float PaddingF1228_0;

vec4 Padding1228[63];

vec4 View_ViewRectMin;

highp vec4 View_ViewSizeAndInvSize;

vec4 Padding2272[4];

float PaddingB2272_0;

highp float View_PreExposure;

float PaddingF2344_0;

float PaddingF2344_1;

vec4 Padding2344[6];

float PaddingB2344_0;

float PaddingB2344_1;

float PaddingB2344_2;

highp float View_CullingSign;

vec4 Padding2464[13];

highp vec3 View_IndirectLightingColorScale;

float PaddingF2684_0;

vec4 Padding2684[54];

highp float View_IndirectLightingCacheShowFlag;

} View;

#define Primitive_LightingChannelMask (pc2_u[0].x)

#define Primitive_UseSingleSampleShadowFromStationaryLights (pc2_h[1].x)

#define Primitive_InvNonUniformScaleAndDeterminantSign (pc2_h[0].xyzw)

uniform uvec4 pc2_u[1];

uniform highp vec4 pc2_h[3];

#define Primitive_PrimaryPrecomputedShadowMaskValue (pc2_h[1].z)

#define Primitive_LightingChannelMask (floatBitsToUint(pc2_h[1].y))

#define Primitive_UseSingleSampleShadowFromStationaryLights (pc2_h[1].x)

#define Primitive_InvNonUniformScaleAndDeterminantSign (pc2_h[0].xyzw)

uniform highp vec4 pc2_h[2];

可以看到View使用了uniform buffer,而Primitve还是使用uniform,但是变量数量从4个vec4减少到了两个vec4。

设置Program

尽量减少program的数量,比如一些简单的宏可以通过?运算符之类来避免,另外是通过uniform的方式来代替宏,当然这个需要评估,因为可能会造成register spilling以及降低效率。

更新纹理

在开启了texture streaming之后并且纹理数量过多的情况下会导致纹理更新的消耗比较大,可以尝试以下优化:

  1. UE本身使用了PBO来做纹理更新,这个在移动平台上没必要的,还额外多了一次上传PBO的开销。
  2. 另外在开启RHI情况下会有一次额外的从Render到RHI的纹理数据拷贝,这个也可以优化掉。
  3. OpenGL本身支持multi context,可以单独起一个线程来做纹理的上传。

更新Buffer

如果你的buffer数量很多另外又需要频率的更新,这个时候在一些稍微老些的机器上(888及以下机器)很容易遇到更新buffer的过高耗时和卡顿,我们在之前的
文章
里面有写过。

只不过当时的文章比较久了,后面又有新的实现,现在是除了UAV之外的所有buffer都可以使用大buffer+offset方式访问内存,这个给RHI减少10%~20%的开销。

  1. glDrawRangeElements、glDrawElements 中有start index
  2. texture buffer glTexBufferRangeEXT 支持offset,这个主要是ISM、HISM中的instance数据会用到。

Shader编译

Shader编译是很耗时的操作,目前大家常见的做法就是提前收集好PSO并预热,但是很难覆盖完整,如果直接在RHI线程编译会导致卡顿,这个时候也可以复用GL的多context机制进行异步编译。但是这样会引入闪烁,需要去做平衡。

总结

上面列了一些OpengGL开销较大的函数并针对性做了优化,其它API也可以通过cache机器等来做优化,如果按照上面的思路都优化完成,相信你的GL性能一定会有不错的提升以及更低的功耗。

参考

  1. https://www.unrealengine.com/en-US/blog/unreal-engine-4-21-released
  2. https://registry.khronos.org/OpenGL/extensions/EXT/EXT_texture_buffer_object.txt

大家好,我是汤师爷~

近年来,越来越多的零售企业大力发展全渠道业务。在销售额增长上,通过线上的小程序、直播、平台渠道等方式,拓展流量变现渠道。在会员增长方面,通过多样的互动方式,全渠道触达消费者,扩大会员规模。而全渠道的库存管理,逐渐变成零售商在渠道运营方面的核心活动,也是提高库存周转率,保证利润的关键所在。

在全渠道模式下,各渠道必须有足量的商品来满足客户需求,同时需有效管理总库存,平衡各渠道库存,以减少缺货或者滞销的情况发生。

全渠道模式下,库存管理面临的挑战

在线上线下渠道融合的大背景下,零售企业如果没有管理好全渠道库存,会带来诸多问题:

  • 各渠道库存割裂,进行线上线下促销活动时,商品超卖,引起客诉。
  • 各渠道库存分配不合理,要么缺货,要么库存积压。
  • 各渠道库存数据更新不及时,有货却不能下单,销售机会大量流失。
  • 各地库存数据分散在各系统中,数据不通,无法知晓库存分布情况,无法统一采购/调拨。
  • 无法根据用户的下单信息,进行智能分仓、就近发货。

中央库存系统的定位

向下对接各地仓库/门店库存,将各地库存放在“一盘货”里,进行管理、统一调配。

向上打通所有销售渠道平台,实现全渠道库存共享、自动化运营。

中央库存系统的关键概念

中央库存整体业务框架

中央库存体系将库存管理分为三层,销售层、调度层、仓库层,实现库存利用最大化,支持多仓多渠道模式下的各种业务场景。

仓库层

仓库层的定位是管理仓库库存,一般使用仓库WMS、门店系统或ERP系统来管理仓库的进销存,通过出入库单据变更仓库库存数量。

仓库库存的关键属性包括:货主、仓库/门店、SKU、批号、生产日期、库存状态、库位等。

  • 货主:货物所有权的拥有者。
  • 仓库/门店:存储货物的逻辑单元,这里需要与物理世界的仓库区分开,可能一个物理仓库包含多个逻辑仓库单元。
  • 批号:用于区分每一批投料生产出来的产品,为了事后能追踪这批产品的责任,每一批产品都有相应的批号。
  • 生产日期:生产线包装出可销售的成品的日期与时间。
  • 库存状态:描述库存在不同业务场景下的不同状态,例如,可用、冻结、在途、不良品、废品等。
  • 库位:一般是指在工厂仓库中实际存在的库位,比如一个个的货架。同时也是SKU库存的最小粒度。

调度层

调度层的定位是汇总各仓库/门店的所有库存状态的库存总量,但不同于仓库库存,调度层的实物库存无需管理批号、库位等细粒度的库存维度,只需要管理每个库存状态下的实物库存总数即可,这是一种解耦的设计方式。

实物库存的关键属性包括:仓库/门店、SKU、库存状态等。关键概念包括:

  • 在途库存:指供应商发货但还未入库的库存,有时为了扩大销售机会,在途库存也会用于扩大销售库存数量。
  • 可用实物库存:仓库实际可用于销售的库存。
  • 不可用实物库存:即对应仓库中的不可用库存。
  • 销售预占库存:订单提交并分仓成功后,会预占对应仓库的库存,订单取消或发货后,会扣减预占库存。
  • 销售可用库存:销售可用库存=可用实物库存-销售预占库存。

销售层

销售层的定位是管理各个销售渠道的渠道库存,为销售平台提供库存计算与库存同步的服务,并通过各种渠道库存分配策略进行库存分配,防止超卖,保障利润最优。

销售库存的关键属性包括:

  • 销售渠道:包括自营的网店、门店线下渠道,天猫,京东,美团,饿了么等三方平台等。
  • 销售店铺:销售的店铺或门店。
  • 发货方式:快递、同城配送、自提。
  • 配送区域:由于各个仓库覆盖的配送区域不一样,所以SKU能支持的配送范围也不同。

销售库存的关键概念:

  • 销售可用库存:按照仓库/门店的供货关系、渠道库存分配策略进行计算汇总的可销售的库存数量。订单提交成功扣减销售可用库存,当调度层的实物库存更新,需要触发销售层重新计算销售可用库存。
  • 预售库存:如果商品未到货,可以开启预售模式,提前售卖。实物库存与预售库存是隔离开的,当实物到货后,预售库存统一推到实物库存进行履约。
  • 预占库存: 订单已提交但未支付之前,为给顾客预留商品,会先预占商品库存,待支付以后再删除预占库存、扣减可销售库存。若长时间未支付,则会取消订单,释放预占库存。
  • 活动库存:针对某些SKU做促销活动时,例如特价、秒杀活动,需要设置活动库存,可以从正常库存中预留部分库存,活动开始后释放预留库存。如果活动商品的下单数量等于活动计划的库存总数量,则活动终止。活动订单与普通订单,在库存处理逻辑上是一模一样的。如果没有特殊要求,没必要单独把活动库存单独分出来,作为独立的业务处理,这样会多出两套库存逻辑,三层库存架构需要都独立分开处理,极大地增加了复杂度。
  • 预留库存:若需要提前为某些促销活动预留库存,以免活动开始以后库存不足,可设置预留库存。
  • 可售库存 = 预售库存+销售可用库存 – 预占库存 - 预留库存。

销售渠道层

销售渠道层代表各个销售渠道平台,包括自营的网店、门店渠道,天猫,京东,美团,饿了么等三方平台等。

逻辑模型设计

库存的核心场景

调度层同步逻辑

调度层的实物库存来自各个仓库的库存,一般有两种同步模式:

  • 流水同步模式:适用于内部系统打通,能够获取仓库/门店的库存流水,通过回传流水,变更调度层的实物库存数量。这样做的好处是,有很清晰的实物库存流水变更记录,便于追查到每次库存变化的明细,需要注意做好幂等处理,避免重复同步导致库存数量变更出错。
  • 数量同步模式:适用于外部系统对接,一般获取不到详细的库存流水,通过商家后台或系统对接的方式同步库存实时数量。

销售库存计算逻辑

在新零售的多仓多渠道模式下,为了实现全渠道库存共享,库存汇总为“一盘货”管理,要充分考虑各个仓库/门店的特性,包括支持的发货方式,配送范围等,为了合理分配库存,需要考虑各个销售店铺的库存占比。下面针对几种常见的场景,说明销售库存的计算逻辑。

多仓供货场景

门店A、门店B为两个线下门店,门店A有100件iphone14,门店B有50件iphone14。

假设商家有1个天猫旗舰店,门店A、门店B均给天猫店供货。

天猫旗舰店仅支持快递发货方式,为了防止超卖,设置快递的最大分配比例为80%。

如图例所示,最终天猫渠道的iphone14的库存数量为120,并定期将数量同步到天猫平台。

单仓给多店供货场景

商家有1个电商仓,为商家的各个电商平台店铺提供仓储服务与发货服务,电商仓有100件iphone14。

电商仓同时为京东旗舰店、天猫旗舰店供货,两个店铺仅支持快递发货方式,最大分配比例分别为40%、60%。

如图例所示,最终京东渠道的iphone14的库存数量为40,天猫渠道的iphone14的库存数量为60。

门店全渠道库存共享场景

随着新零售线上线下渠道加速融合,门店线上线下全渠道销售,已经成为大部分零售商家的标配。

受益于微信生态和小程序电商的高速发展,越来越多的门店开启了云店模式,云店实际上就是门店的线上化交易渠道,或者称为门店的“线上货架”。

连锁企业把线下门店嫁接到微信生态中,将门店所有商品上架到云店小程序。借助云店,消费者无需到店,即可享受到门店的服务,同时,门店的导购可以向自己的会员推荐所有云店商品。

如图例所示,门店A有100份的草莓蛋糕,门店A为自己供货,并共享草莓蛋糕的库存到多个销售渠道(美团外卖、云店、门店线下渠道),实现门店“一盘货”全渠道销售。

渠道库存同步

销售库存计算完后,需要将渠道库存同步到各个平台渠道,这样,消费者才能完成交易流程。根据渠道类型不同,渠道库存同步有两种处理逻辑:

  • 自营系统:如果自营渠道与库存系统是一体的,即一套系统,那么不需要过于复杂的库存同步逻辑,自营渠道直接读取中央库存系统的渠道库存即可。
  • 三方平台系统:像天猫,京东,美团,饿了么等,这些三方平台系统属于外部系统,商家自身无法管控,就需要通过开发API,向三方平台同步渠道库存。一般而言不会实时同步渠道库存,即只要有库存变动,就计算渠道库存,同步至三方平台。这种方式对系统压力较大,而且三方平台的API大多会按调用量收费,因此,会设定好时间间隔,定期同步渠道库存,例如5分钟一次。

组合商品库存计算

组合商品一般指人为将几个单独售卖的商品组合在一起,进行合并售卖的商品,例如:下午茶套餐、七夕美妆组合等。

组合商品会先在调度层,根据组合比例计算好虚拟库存,不影响子商品的供货逻辑,下单时,会根据组合商品标识,进行子商品的实物库存预占、扣减。

如图所示,电商仓中,商品A有150件,商品B有200件,根据组合关系,可以算出组合商品C有100件。当下一单商品C时,会预占1件商品A+2件商品B的实物库存。

中央库存系统的应用架构设计

小结

本文介绍了在全渠道模式下,库存管理面临的挑战。

针对挑战,详细介绍了中央库存系统的整体业务框架,涉及的关键概念,以及库存核心业务场景的处理逻辑,最后简单介绍了中央库存系统的应用架构设计。

本文已收录于,我的技术网站:
tangshiye.cn
里面有,算法Leetcode详解,面试八股文、BAT面试真题、简历模版、架构设计,等经验分享。

二、概要说明

  1. 本文主要介绍登录登出业务流程,所以使用基于内存的用户名密码,暂不介绍授权相关内容,后续会详细介绍基于数据库的认证及授权
  2. 如何查看基于内存的默认用户名密码
  3. 如何配置基于内存的自定义用户名密码
  4. 本文与上文有强关联性,如果对过滤器链中登录相关的过滤器不熟悉的同学,请先查看
    三、Spring Boot集成Spring Security之securityFilterChain过滤器链详解

三、基于内存的用户名密码

1、默认用户名密码

  1. 一、Spring Boot集成Spring Security之自动装配
    中第六节已介绍当用户未自定义认证接口时,生成默认认证接口inMemoryUserDetailsManager,其中用户名为user,密码为随机生成的uuid,项目启动时会打印在控制台中

b57d15674a5e2bea040d35855b4b543

  1. 用户名密码源码

image-20241012095157650

2、自定义用户名密码

  1. 上一小节【用户名密码源码】中配置绑定可以配置自定义用户名、密码
  2. 通过配置文件配置用户名密码

image-20241012095242326

3、为方便测试添加测试接口TestController

package com.yu.demo.web;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/test")
public class TestController {

    @GetMapping("/hello")
    public String hello() {
        return "success";
    }

}

image-20241012095447823

四、登录登出重要概念介绍

  1. 安全上下文仓库(SecurityContextRepository):用于存储安全上下文,默认基于session实现(HttpSessionSecurityContextRepository)
  2. 安全上下文持有者(SecurityContextHolder):用于存储本次请求的安全上下文,默认基于ThreadLocal实现
  3. 安全上下文(SecurityContext):用于存储认证信息
  4. 认证信息(Authentication):用于存储用户及认证结果信息,主要实现类有
    • 用户名密码认证Token:UsernamePasswordAuthenticationToken
    • 匿名认证Token:AnonymousAuthenticationToken
  5. 登录页面请求:跳转到登录页面的请求
  6. 登录请求:在登录页面输入用户名密码后提交的请求
  7. 登出页面请求:跳转到登出页面的请求
  8. 登出请求:在登出页面确认登出提交的请求

五、登录业务逻辑

1、登录业务相关过滤器

  1. SecurityContextPersistenceFilter
  2. UsernamePasswordAuthenticationFilter
  3. DefaultLoginPageGeneratingFilter
  4. AnonymousAuthenticationFilter
  5. ExceptionTranslationFilter
  6. FilterSecurityInterceptor

2、访问业务请求处理流程

1)、访问业务请求地址被拦截,重定向到登录页面请求

  1. 浏览器访问业务请求地址:
    http://localhost:8080/test/hello

  2. SecurityContextPersistenceFilter处理请求:


    1. 从安全上下文仓库中获取安全上下文为空,创建没有认证信息的安全上下文(SecurityContextImpl)
    2. 将第1步中获取的安全上下文设置到安全上下文持有者中
    3. 执行后续过滤器链
    4. 源码

    image-20241012095700356

  3. AnonymousAuthenticationFilter处理请求:


    1. 获取安全上下文持有者中的安全上下文中的认证信息为空
    2. 创建匿名认证信息
    3. 创建新的没有认证信息的安全上下文
    4. 将第2步中的匿名认证信息设置到第3步中的安全上下文中
    5. 将第3步中的安全上下文中设置到安全上下文持有者中
    6. 执行后续过滤器链
    7. 源码

    image-20241012100012219

  4. FilterSecurityInterceptor处理请求:


    1. 验证安全上下文持有者中的安全上下文中的匿名认证信息通过
    2. 验证授权信息失败(业务请求地址未设置可以匿名访问时),抛出AccessDeniedException异常
    3. 源码

    image-20241012100452976

  5. ExceptionTranslationFilter处理请求:


    1. 捕获FilterSecurityInterceptor抛出的AccessDeniedException异常
    2. 判断是因为匿名访问导致的授权异常
    3. 创建新的没有认证信息的安全上下文
    4. 将第3步中的安全上下文中设置到安全上下文持有者中
    5. 重定向到登录页面:
      http://localhost:8080/login
    6. 源码

    image-20241012102138125

  6. SecurityContextPersistenceFilter处理请求:


    1. 执行chain.doFilter之后的代码
    2. 获取安全上下文持有者中的安全上下文
    3. 删除安全上下文持有者中的安全上下文
    4. 将第2步中获取的安全上下文保存到安全上下文仓库中
    5. 源码

    image-20241012103447873

  7. 重定向登录页面请求:
    http://localhost:8080/login(GET)

2)、重定向定页面请求,返回登录页面

  1. SecurityContextPersistenceFilter处理请求:


    1. 从安全上下文仓库中获取安全上下文为空,创建没有认证信息的安全上下文(SecurityContextImpl)
    2. 将第1步中获取的安全上下文设置到安全上下文持有者中
    3. 执行后续过滤器链
    4. 源码(同上)
  2. DefaultLoginPageGeneratingFilter处理请求:


    1. 判断是跳转到登录页面的请求
    2. 生成默认登录页面
    3. 返回并渲染生成的默认登录页面
    4. 源码

    image-20241012105043462

  3. SecurityContextPersistenceFilter处理请求:


    1. 执行chain.doFilter之后的代码
    2. 获取安全上下文持有者中的安全上下文
    3. 删除安全上下文持有者中的安全上下文
    4. 将第2步中获取的安全上下文保存到安全上下文仓库中
    5. 源码(同上)

3)、输入正确用户名密码,重定向到业务请求

  1. SecurityContextPersistenceFilter处理请求:


    1. 从安全上下文仓库中获取安全上下文为空,创建没有认证信息的安全上下文(SecurityContextImpl)
    2. 将第1步中获取的安全上下文设置到安全上下文持有者中
    3. 执行后续过滤器链
    4. 源码(同上)
  2. UsernamePasswordAuthenticationFilter处理请求:


    1. 判断需要认证(AbstractAuthenticationProcessingFilter.doFilter方法)
    2. 认证用户名密码成功,生成已认证的认证信息UsernamePasswordAuthenticationToken
    3. 创建新的没有认证信息的安全上下文
    4. 将第2步中的认证信息设置到第3步中的安全上下文中
    5. 将第3步中的安全上下文设置到安全上下文持有者中
    6. 将第3步中的安全上下文保存到局部变量安全上下文仓库中(空实现)
    7. 重定向到业务请求地址:
      http://localhost:8080/test/hello
    8. 源码

    image-20241012110540446

  3. SecurityContextPersistenceFilter处理请求:


    1. 执行chain.doFilter之后的代码
    2. 获取安全上下文持有者中的安全上下文
    3. 删除安全上下文持有者中的安全上下文
    4. 将第2步中获取的安全上下文保存到安全上下文仓库中
    5. 源码(同上)

4)、重定向到业务请求

  1. SecurityContextPersistenceFilter处理请求:


    1. 从安全上下文仓库中获取已认证的安全上下文
    2. 将第1步中获取的安全上下文设置到安全上下文持有者中
    3. 执行后续过滤器链
    4. 源码(同上)
  2. FilterSecurityInterceptor处理请求:


    1. 验证安全上下文持有者中的安全上下文中的认证信息通过
    2. 验证授权成功
    3. 调用接口返回数据
    4. 源码

    image-20241012110925382

  3. SecurityContextPersistenceFilter处理请求:


    1. 执行chain.doFilter之后的代码
    2. 获取安全上下文持有者中的安全上下文
    3. 删除安全上下文持有者中的安全上下文
    4. 将第2步中获取的安全上下文保存到安全上下文仓库中
    5. 源码(同上)

六、登出业务实现逻辑

1、登出业务相关过滤器

  1. SecurityContextPersistenceFilter
  2. LogoutFilter
  3. DefaultLogoutPageGeneratingFilter

2、访问登出页面请求处理流程

  1. 浏览器访问登出请求地址:
    http://localhost:8080/logout

  2. SecurityContextPersistenceFilter处理请求:


    1. 从安全上下文仓库中获取已认证的安全上下文
    2. 将第1步中获取的安全上下文设置到安全上下文持有者中
    3. 执行后续过滤器链
    4. 源码(同上)
  3. DefaultLogoutPageGeneratingFilter处理请求:


    1. 判断是跳转到登出页面的请求
    2. 生成默认登出页面
    3. 返回并渲染生成的默认登出页面
    4. 源码

    image-20241012111205378

  4. SecurityContextPersistenceFilter处理请求:


    1. 执行chain.doFilter之后的代码
    2. 获取安全上下文持有者中的安全上下文
    3. 删除安全上下文持有者中的安全上下文
    4. 将第2步中获取的安全上下文保存到安全上下文仓库中
    5. 源码(同上)

3、登出页面确认登出请求处理流程

1)、确认登出,重定向到登录页面请求

  1. SecurityContextPersistenceFilter处理请求:


    1. 从安全上下文仓库中获取已认证的安全上下文
    2. 将第1步中获取的安全上下文设置到安全上下文持有者中
    3. 执行后续过滤器链
    4. 源码(同上)
  2. LogoutFilter处理请求:


    1. 判断是登出请求
    2. 获取安全上下文持有者中的安全上下文
    3. 登出处理器处理登出业务
      1. 删除安全上下文持有者中的安全上下文
      2. 创建没有认证信息的安全上下文
      3. 将第2步中的安全上下文保存到安全上下文仓库中
    4. 重定向到登录页面
    5. 源码

    image-20241012111435585

  3. SecurityContextPersistenceFilter处理请求:


    1. 执行chain.doFilter之后的代码
    2. 获取安全上下文持有者中的安全上下文
    3. 删除安全上下文持有者中的安全上下文
    4. 将第2步中获取的安全上下文保存到安全上下文仓库中
    5. 源码(同上)

2)、登录页面请求

第五节登录业务逻辑已介绍,不再赘述。

七、说明

  1. Spring Boot集成Spring Security默认是非前后分离架构
  2. 本文介绍的流程是非前后分离版本的处理流程
  3. 前后分离处理流程较为简单
    1. 未认证时访问业务接口,返回未认证错误信息
    2. 调用登录接口成功后返回Token,此后请求头中携带此Token
    3. 调用登出接口成功后返回成功,后端将该Token失效
    4. 携带Token访问业务接口,后端验证Token成功后,调用业务接口并返回数据