阿里云块存储团队软件工程实践

作 者 | 晴筱 石超 张小路

“我背上有个背篓,里面装了很多血泪换来的经验教训,我看着你们在台下嗷嗷待哺想要这个背篓里的东西,但事实上我给不了你们”,实践出真知。

  • 编码习惯(开发、测试、Review,Bad/Good Case)

  • 研发流程(源码控制、每日构建、缺陷管理)

  • 实践方法(效率工具、新人踩雷、学习推荐)

  • 一 编码习惯

    Ugly is easy to identify because the messes always have something in common, but not beauty.

    — C++ 之父 Bjarne Stroustrup

    代码质量与其整洁度成正比。

    1.1 开发

    别人眼中的软件系统犹如灯火辉煌的摩天大厦,维护者眼中的软件系统犹如私搭乱建的城中村,我们要在这座城中村里生存,一直维护这些代码,添加新功能等,要让大家生活得更好,我们写代码不仅追求正确性,还有健壮性 和 可维护性

    图1 开发 理想与现实

    ?要点1 :语义简单明确

    这是块存储 SDK 的一段代码,判断限流目标值是否合法;写代码时考虑读者,优先采取易于读者理解的写法

    # defineTHROTL_UNSET -2

    # defineTHROTL_NO_LIMIT -1

    boolthrottle_is_quota_valid( int64_t value)

    {

    // 复杂的判断条件

    // 请你在三秒内说出 value 如何取值是合法的?

    if( value< 0&& value!= THROTL_UNSET && value!= THROTL_NO_LIMIT)

    {

    returnfalse;

    }

    returntrue;

    }

    boolthrottle_is_quota_valid( int64_t value)

    {

    // 这是修改后的代码,value 取值合法有三种情况,一目了然

    returnvalue>= 0|| value== THROTL_UNSET || value== THROTL_NO_LIMIT;

    }

    ?要点2 :简洁 ≠ 代码短

    这是块存储的一段代码,它遍历回收站中的所有文件,统计每种介质上文件最早的时间戳;简洁≠代码短,复杂的问 表达式反而不如 if..else 方便理解

    void RecycleBin::Load(BindCallbackR1<Status>* done)

    {

    ……

    FOREACH(iter, fileStats)

    {

    RecycleFile item;

    Status status = ParseDeletedFileName(iter->path, &item.timestamp);

    if(!status.IsOk { …… }

    item.fileName = iter->path;

    item.size = iter->size;

    item.physicalSize = iter->refCount > 1? 0: iter->physicalSize;

    ……

    // 这是修改前的代码

    // earliestTimestamp[item.medium] =

    // item.timestamp != 0 && item.timestamp < earliestTimestamp[item.medium] ?

    // item.timestamp : earliestTimestamp[item.medium];

    // }

    // 这是修改后的代码

    if(item.timestamp != 0&&

    item.timestamp < earliestTimestamp[item.medium])

    {

    earliestTimestamp[item.medium] = item.timestamp;

    }

    }

    ……

    }

    ?要点3 :提前返错

    提前返错能减少主体逻辑的缩进数量,让主体代码逻辑显得更醒目。

    Bad Case如下:

    Status Foo

    {

    Status status= Check1;

    if(! status.IsOk)

    {

    returnstatus;

    }

    else

    {

    status= Check2;

    if(! status.IsOk)

    {

    returnstatus;

    }

    else

    {

    status= Check3;

    if(! status.IsOk)

    {

    returnstatus;

    }

    else

    {

    DoSomeRealWork;

    returnOK;

    // 四层潜套 if

    }

    }

    }

    }

    ?要点4 :利用析构函数做清理工作

    利用 C++ 析构函数做清理工作,在复杂冗长代码中不会漏掉。典型的清理工作有执行回调、关闭文件、释放内存等。

    Bad Case如下:

    void Foo(RpcController* ctrl,

    constFooRequest* request,

    FooResponse* response,

    Closure* done)

    {

    Status status = Check1(request);

    if(!status.IsOk)

    {

    response->set_errorcode(status.Code);

    // 第一处

    done->Run;

    return;

    }

    status = Check2(request);

    if(!status.IsOk)

    {

    response->set_errorcode(status.Code);

    // 第二处

    done->Run;

    return;

    }

    DoSomeRealWork(…);

    // 第三处

    done->Run;

    }

    Good Case如下:

    voidFoo(RpcController* ctrl,

    constFooRequest* request,

    FooResponse* response,

    Closure* _done)

    {

    // 仅一处,不遗漏

    erpc:: ScopedCallback done(_done);

    Status status = Check1(request);

    if(!status.IsOk)

    {

    response->set_errorcode(status.Code);

    return;

    }

    status = Check2(request);

    if(!status.IsOk)

    {

    response->set_errorcode(status.Code);

    return;

    }

    DoSomeRealWork(…);

    }

    ?要点5 :用朴素直观的算法

    这是块存储旁路系统的一段代码,它根据垃圾比对数据分片进行排序;在非关键路径上,优先使用朴素直观的算法,此时代码可维护性更重要

    voidCompactTask::checkFileUtilizationRewrite

    {

    // 此处采取朴素的排序算法,并未采取更高效的 TopK 算法

    std::sort(sealedFilesUsage.begin, sealedFilesUsage.end, GarbageCollectionCompare);

    int64_tsealedFileMaxSize = INT64_FLAG(lsm_CompactionSealedMaxSize);

    int32_tsealedFileMaxNum = INT32_FLAG(lsm_CompactionSealedMaxFileNum);

    int64_ttargetFileSize = 0;

    int32_tsourceFileCnt = 0;

    // 前者简单清淅,并在几十个 File 中选择前几个文件的场景并不算太慢

    FOREACH(itr, sealedFilesUsage)

    {

    LogicalFileId fileId = itr->fileId;

    constFileUsage* usage = baseMap->GetFileUsage(fileId);

    constFile* file = fileSet->GetFile(fileId);

    targetFileSize += usage->blocks * mBlockSize;

    sourceFileCnt++;

    if(targetFileSize > sealedFileMaxSize || sourceFileCnt > sealedFileMaxNum)

    {

    break;

    }

    mRewriteSealedFiles[fileId] = true;

    }

    ……

    }

    ?要点6 :用轮循代替条件变量

    这是块存储IO路径的一段代码,从内存中卸载数据分片时等待在途inflight的 IO 请求返回;在非关键路径上使用简单的轮循代替精巧的条件变量同步,代码简洁且不容易出 bug

    void UserRequestControl::WaitForPendingIOs

    {

    erpc::ExponentialBackoff delayTimeBackOff;

    delayTimeBackOff.Reset(

    INT64_FLAG(lsm_UnloadWaitingBackoffBaseUs),

    INT64_FLAG(lsm_UnloadWaitingBackoffLimitUs),

    INT64_FLAG(lsm_UnloadWaitingBackoffScaleFactor));

    // 轮循等待在途的请求返回

    // 请思考如何用条件变量实现精确的同步

    while(!mWriteQueue. empty|| !mReadQueue. empty)

    {

    uint64_t delayTime = delayTimeBackOff.Next;

    PGLOG_INFO(sLogger,

    ( __FUNCTION__, “Waiting for inflight requests during segment unload”)

    ( “Segment”, mSegment->GetName)

    ( “Write Requests”, mWriteQueue.size)

    ( “ReadRequests”, mReadQueue.size)

    ( “DelayTimeInUs”, delayTime));

    easy_coroutine_usleep(delayTime); // 退避等待

    }

    }

    ?要点7 :使用 timed_wait 代替 wait

    在典型的生产者消费者实现中,使用 timedwait 代替 wait,避免生产者未正确设置条件变量造成消费者卡死无法服务的窘境。

    pthread_mutex_tmutex;

    pthread_cond_tnonEmptyCondition;

    std:: list<Task*> queue;

    voidConsumerLoop

    {

    pthread_mutex_lock(&mutex);

    while( true)

    {

    while( queue.empty)

    {

    structtimespects;

    ts.tv_sec = 1;

    ts.tv_nsec = 0;

    // 使用timewait

    pthread_cond_timedwait(&nonEmptyCondition, &mutex, timespec);

    }

    Task* firstTask = queue.front;

    queue.pop_front;

    consume(firstTask);

    }

    pthread_mutex_unlock(&mutex);

    }

    要点8:用协程代替异步回调

    这是块存储 BlockServer 加载数据分片的代码;用异步回调方式难以实现这样的复杂控制逻辑,用协程却能轻松实现。

    // load.cpp

    Status LoadTask::Execute

    {

    Status status;

    # defineRUN_STEP(func)

    status = func;

    if(!status.IsOk) { … }

    // 串行执行下列步骤

    RUN_STEP(doPrepareDirs);

    …… // 十几步

    RUN_STEP(doTask);

    # undefRUN_STEP

    ……

    }

    // files.cpp

    Status FileMap::SealFilesForLiveDevice

    {

    Status status = OK;

    std:: vector<SyncClosureR1<Status>*> sealDones;

    STLDeleteElementsGuard< std:: vector<SyncClosureR1<Status>*> >

    donesDeleter(&sealDones);

    // 并行 seal 每个文件

    FOREACH(iter, mActiveFiles)

    {

    File* file = iter->second;

    sealDones.push_back( newSyncClosureR1<Status>);

    Closure* work = stone::NewClosure(

    this,

    &FileMap::doSealFileForLiveDevice,

    file,

    static_cast<BindCallbackR1<Status>*>(sealDones.back));

    InvokeCoroutineInCurrentThread(work);

    }

    // 收集结果

    FOREACH(done, sealDones)

    {

    (*done)->Wait;

    if(!(*done)->GetResult0.IsOk)

    {

    status = (*done)->GetResult0;

    }

    }

    returnstatus;

    }

    ?要点9 :在关键对象增加 magic 字段

    这是块存储核心主路径的一段代码;在关键数据结构中增加 magic 字段和断言检查,能及时发现内存错误(例:内存踩坏)

    ?通常在下列两类结构增加 magic :

    1)关键的数据结构,如 数据分片 结构 ;

    2)异步操作的上下文结构,如用户IO Buffer请求;

    // stream.h

    classStream

    {

    public:

    Stream;

    ~Stream;

    voidRead(ReadArgs* args);

    ……

    private:

    // 增加 magic 字段

    // 通常使用 uint32 或 uint64

    uint64_tmObjectMagic;

    ……

    };

    // stream.cpp

    // 定义 magic 常量

    // 常量值选择 hexdump 时能识别的字符串,以便在 gdb 查看 coredump 时快速识别

    // 此处使用 “STREAM” 的 ASCII 串

    staticuint64_tSTREAM_OBJECT_MAGIC = 0x4e4d474553564544LL;

    Stream::Stream

    : mObjectMagic(STREAM_OBJECT_MAGIC) // 在构造函数中赋值

    {

    ……

    }

    Stream::~Stream

    {

    // 在析构函数中检查并破坏 magic 字段,预防 double-free 错误

    easy_assert(mObjectMagic == STREAM_OBJECT_MAGIC);

    mObjectMagic = FREED_OBJECT_MAGIC;

    ……

    }

    voidDeviceSegment::Read(ReadArgs* args)

    {

    // 在重要的函数中检查 magic 字段,预防 use-after-free 错误

    easy_assert(mObjectMagic == DEVICE_SEGMENT_OBJECT_MAGIC);

    ……

    }

    ?要点10 :SanityCheck 合法性检查

    这是块存储核心模块的一段代码 StreamWriter 负责管理正在写入的Stream,它为每个写请求选择合适的 Stream 写入,并处理文件满、写失败等异常情形;曾在线下测试发现由于未添加合法性检查,导致内存踩坏的meta错误数据持久化到磁盘中,在数据分片发生迁移时,从磁盘加载错误的meta数据持续夯死,不可恢复。在重要操作前后及定时器中检查数据结构中的重要的不变式假设,这样尽早发现代码 bug 在重要的操作前后或是在定时器中执行检查。

    classStreamWriter

    {

    public:

    ……

    private:

    structStreamGroup

    {

    WriteAttemptList failureQueue;

    WriteAttemptList inflightQueue;

    WriteAttemptList pendingQueue;

    uint64_tcommitSeq;

    uint64_tlastSeq;

    };

    uint32_tmStreamGroupCount;

    StreamGroup mStreamGroups[STREAM_GROUP_COUNT];

    ……

    };

    voidStreamWriter::sanityCheck

    {

    # ifndefNDEBUG // expensive checks

    for( uint32_ti = 0; i < mStreamGroupCount; i++)

    {

    // Check that sequence in “failureQueue”, “inflightQueue” and “pendingQueue” are ordered.

    constStreamGroup* group = &mStreamGroups[i];

    uint64_tprevSeq = group->commitSeq;

    constWriteAttemptList* queues[] = {

    &group->failureQueue,

    &group->inflightQueue,

    &group->pendingQueue

    };

    for( size_tk = 0; k < easy_count_of(queues); k++)

    {

    FOREACH(iter, *queues[k])

    {

    constWriteRequest* write = iter->write;

    PANGU_ASSERT(prevSeq <= write->seq); // SanityCheck

    prevSeq = write->seq + write->lbaRange.rangeSize;

    }

    }

    ASSERT(prevSeq == group->lastSeq); // SanityCheck

    }

    ……

    # endif// NDEBUG

    }

    ?要点11 :用告警代替进程崩溃

    这是块存储核心路径的一段代码,在加载数据分片时通过交叉校验对数据正确性进行合法性检查,遇到严重错误时发起电话告警,以此代替 assert,避免生产集群大规模故障时,数据分片持续调度造成整个集群进程Crash的 级联故障 。

    在多租户系统中,单租户出现严重问题不应影响其他租户的服务。

    在块存储,我们仅允许检查对象 magic 和线程是否正确的断言。其它断言由告警代替。

    Status LoadTask::doTailScanFiles

    {

    ……

    for(id = FIRST_REAL_FILE_ID; id < mFileSet->GetTotalFileCount; id++)

    {

    File* file = mDiskFileSet->GetFile(id);

    if(file->GetLogicalLength < logicalLengthInIndex)

    {

    constchar* msg = “BUG!! Found a data on disk with shorter length ”

    “than in map. This is probably caused by length reduction of ”

    “that file.”; // 记录详细的日志,包括文件名、期望长度、实际长度等

    PGLOG_FATAL(sLogger, ( __FUNCTION__, msg)

    (“Stream”, mStream->GetName)

    (“File”, file->GetFileName)

    (“FileId”, file->GetFileId)

    (“FileLengthOnDisk”, file->GetFileLength)

    (“FileLengthInIndex”, physicalLengthInIndex)

    (“LogicalLengthOnDisk”, file->GetLogicalLength)

    (“LogicalLengthInIndex”, logicalLengthInIndex)

    (“MissingSize”, physicalLengthInIndex – file->GetFileLength));

    SERVICE_ADD_COUNTER(“LSM:CriticalIssueCount”, 1); // 触发电话告警

    returnLSM_FILE_CORRUPTED;

    }

    }

    }

    要点12 :时间溢出之一

    我们当前使用的内核配置 HZ=1000,jiffies 变量每49天溢出,Linux 将 jiffies 变量初始值设置为负数,使系统启动后5分钟发生第一次溢出;让这段容易出错的危险代码每天都被执行到,这些再也不用担心出现黑天鹅事件了

    linux/include/linux/jiffies.h

    /*?* Have the 32 bit jiffies value wrap 5 minutes after boot

    * so jiffies wrap bugs show up earlier.

    */

    # defineINITIAL_JIFFIES ((unsigned long)(unsigned int) (-300*HZ))

    /*

    * These inlines deal with timer wrapping correctly

    You are?* strongly encouraged to use them

    * 1. Because people otherwise forget

    * 2. Because if the timer wrap changes in future you won’t have to

    * alter your driver code.

    *

    * time_after(a,b) returns true if the time a is after time b.

    */

    # definetime_after(a,b)

    (typecheck(unsigned long, a) &&

    typecheck(unsigned long, b) &&

    (( long)((b) – (a)) < 0))

    ?要点13 :时间溢出之二

    习惯上代码中以微秒表示时间, int32 能表示的最大时间仅为 2147 秒,约 35 分钟,容易溢出;历史上块存储值班同学在处理另一个 P4 故障时,为缓解分布式集群中心管控节点压力,临时调整 flag 增加调用中心管控节点 RPC 的调用间隔。新的 flag 在运行时产生 int32 整型溢出,进程崩溃,引起整个集群级联故障,服务中断造成 P1 生产故障 ;血琳琳的教训:总是用 int64/uint64 表示时间

    ?要点14 :避免有歧义的函数名和参数表

    这是 libeasy 的一段代码,这是基于时间轮实现的定时器,用来代替 libev timer;函数名和参数表要符合直觉,大多数使用者没空读你的注释,小部分使用者读了你的注释也看不明白。

    // easy/src/io/easy_timer.h

    // ———————————————————————————-

    // following interface, use easy_timer_sched from th(io thread or worker thread),

    // ** DON NOT support async call **

    //

    inteasy_timer_start_on_th( easy_baseth_t*th, easy_timer_t*timer) ;

    inteasy_timer_stop_on_th( easy_baseth_t*th, easy_timer_t*timer) ;

    1.2 测试

    测试代码其实是产品代码的“用户”,写测试代码前考虑如何“使用”产品;好的测试是 what,包含 given when then;差的测试是 how,每次方法更改时都必须完全重写测试,或许需要重新考虑系统本身的体系结构。?

    图2 测试原则、可测性

    要点1 :边界测试

    以下是块存储两个历史生产Bug;关注上下限,边界条件最易出错

    TEST_F(…, SharedDisk_StopOneBs)(…)

    {

    BenchMarkStart(mOption);

    // for循环反复注入

    mCluster->StopServer( 0);

    mCluster->StartServer( 0);

    // 修复前无第12行无代码,无下限检查,全部失败时Case PASS

    // 共享盘开盘后线程死锁必IO Hang,有测试无断言遗漏Bug导致P1故障

    EXPECT_GT(mIoBench->GetLastPrintIops, 0);

    EXPECT_GT(mIoBench->GetMaxLatency, 0);

    // 断言检查,边界上限

    EXPECT_GT( 20* 1000000, mIoBench->GetMaxLatency);

    // Do something below

    }

    Status PRConfig::Register )(…)

    {

    assertIoThread;

    // 修复前缺少=,导致Sever Crash

    if(unlikely(mRegistrants.size >= MAX_REGISTRANT_NUM))

    {

    LOG_ERROR(…);

    returnSC_RESERVATION_CONFLICT;

    }

    // Do something below

    }

    ?要点2 :状态/分支测试

    以下是块存储两个历史生产Bug;状态流程图,影响数据正确性和服务可用性的关键路径、异常分支、状态组合需测试覆盖

    void WalStreamWriterPool::tryCreateWalWriter

    {

    AssertCoroutine;

    ASSERT_DEBUG(mIsCreating);

    Status status = OK;

    while(…)

    {

    WalStreamWriter *writer = mWalManager->CreateWalWriter;

    status = writer->Open;

    // 修复前无第14行代码部分,未处理Commit,失败导致丢掉WAL文件,进而丢数据

    if(status.IsOk)

    {

    status = mWalManager->Commit;

    }

    // Do something below

    }

    void RPCController::StartCancel

    {

    if(_session) {

    if(_pendingRpc != NULL) {

    // 修复前无第29行代码,线程Hang进而IOHang

    // 未测试覆盖call StartCancel before handshake

    _session->need_cancel = true;

    } else{

    easy_session_cancel(_session);

    }

    } else{

    easy_error_log(…);

    }

    }

    ?要点3 :重复/幂等性测试

    以下是块存储两个历史生产Bug;第一个如下图所示,未释放内存,长时间运行造成内存泄漏;第二个是19年遇到的一个问题,BM 在 处理 open device 时没处理好幂等问题,导致磁盘 open 成功后仍然 I/O hang ;有一些问题只有经过长时间的重复测试才能发现,关注代码中每一次重试,敏感接口的 API 幂等性需测试覆盖。

    Status CompressOffsetTable::Seal

    {

    // Do something before

    status = mTableFile->Seal;

    if(!status.IsOk)

    {

    PGLOG_ERROR(…);

    returnstatus;

    }

    mIsSealed = true;

    // 修复前无第14行代码,文件写入已完成,清空缓存,释放内存

    mEasyPool.reset;

    // Do something below

    }

    ?要点4 :兼容性测试

    兼容性包含:协议兼容性、API兼容性、版本升级兼容性、数据格式兼容性;对于所有依赖的兼容性假设需通过测试自动化覆盖,兼容性问题是很难测试覆盖并且问题高发的部分,兼容性问题应该在设计阶段、编码阶段提前预防,避免兼容性问题,而非寄希望于兼容性测试来兜底

    voidActiveManager::SubmitIO(

    {

    // 【版本兼容性】 SDK 和 Server线程不对齐,旧版本SDK不支持切线程

    if(UNLIKELY(GetCurrentThread != serverThread))

    PGLOG_WARNING(… “Server thread mismatch”);

    response->ErrorCode = SERVER_BUSY;

    done->Run;

    }

    voidChunkListAccessor::SetChunkInfoAndLocations

    {

    uint8_tflags = mFileNodePtr->fileFlags;

    boolisLogFile = IsFlatLogFile(flags);

    ASSERT(

    //【协议兼容性】Master 和 SDK异常场景定长误判

    (isLogFile && vecChunkInfoNode[ 0].version <= masterChunkInfo.version) ||

    !isLogFile);

    // Do something below

    }

    // 【API兼容性】 Server 和 Master的错误码不一致,数据分片反复加载/卸载

    // Master侧,device_load.cpp

    // if(status.Code == LSM_SEGMENT_EXIST_OTHER_VERSION))

    // Server侧,device_load.cpp

    // return LSM_NOT_OWN_SEGMENT;

    ?要点5 :防御性测试

    系统服务上限的边界是多少?客户端无退避重试、突发大流量等造成的故障数不胜数,关注系统在最差情况下的表现,明确系统的能力边界,对系统服务边界的数学模型进行理论分析和实验验证,通过极限压测验证系统最大可服务能力符合设计预期,推荐参考 接近不可接受的负载边界[1]。

    ?要点6 :避免写出不稳定Case

    Case不稳定真是一个让人头大问题,总结了一些不稳定的测试常见原因,希望大家记住并知行合一。

  • 测试不聚焦,无脑复制粘贴,等价类测试爆炸

  • 异步等待,基于时间假设,sleep 并发,未能在预期的窗口期交互

  • 有顺序依赖的测试,共享某个状态

  • 资源溢出,数据库链接满、内存 OOM 析构随机 core

  • 析构未严格保序或者未构造

  • 多线程共享资源的错误用法导致概率 crash

  • 有未处理完的任务就退出

  • TEST_F(FastPathSmokeTestFixture, Resize)

    {

    // … Do something

    ResizeVolume(uri, DEVICE_SIZE * 2);

    Status status = OK;

    do{

    // 状态依赖,未检查resize 是否成功,导致错误的认为是越界io处理

    status = Write(handle, wbuf. get, 0, 4096);

    if(status.Code == OK)

    {

    break;

    }

    easy_coroutine_usleep( 100* 1000);

    } while( 1);

    // … Do something

    }

    // volume_iov_split_test.cpp

    TEST(VolumeIovSplitTest, Iovsplit_Random)

    {

    // … Do something

    size_t totalLength = 0;

    // 修改前无+1,0是非法随机值,造成Case低概率失败

    totalLength = rand % ( 10* 1024* 1024) + 1

    // … Do something

    }

    二 本地工具

    2.1 Docker单机集群

    对于分布式系统,能够在开发机上自测端到端的跨模块/跨集群的功能测试,极大的提高测试效率和开发幸福感。在开发调试期间,Docker集群用完即抛,拥有属于自己的无污染的“一手”功能测试集群,代码主路径必现的进程Crash均可在开发阶段发现。Docker使用极少的系统资源,有效地将不同容器互相隔离,快速创建分布式应用程序,非常适合集群测试使用。

    ?块存储在没有 Docker 单机集群之前,测试集群级功能测试至少需要12台物理机,我们通过将块存储、盘古和女娲的服务装进容器中,实现单机 OneBox,在开发机上(物理机 /虚拟机/Docker/Mac 均可,无OS依赖),一键秒级部署和销毁一个集群,基于Docker单机集群实现Docker Funtion Test,沿用单元测试的 gtest,上手门槛低,极大的提高了测试效率和开发幸福感,Docker Funtion Test 是在代码门禁中运行,即代码提交入库之前自动触发测试,在代码入库之前,百分之百拦截必现的进程Crash问题。

    图3 块存储 Docker单机集群

    2.2 本地出包自助E2E

    研发效能低下的团队的一个典型表现,质量强依赖全链路端到端(End-To-End,简称E2E),测试环境维护成本极高,常常因为环境污染导致无效测试,是否能够将全链路E2E测试实现白屏化,告别环境修复?

    ?在开发期间调试,不可避免有大Size的Patch修改,代码门禁Unit Test / Smoke Test / Funition Test仅覆盖功能测试,涉及到IO性能、运维、用户态文件系统、用户态 络协议的代码逻辑修改,无法在代码门禁覆盖。面对这个问题,块存储开发者可以在开发机编译出包,测试平台白屏自助验证E2E测试,操作共3个步骤:编译上传包 → 提交测试任务 → 查看测试结果。降低测试门槛可以有效的提高测试的主观能动性,进而提高测试运行频次,当测试不再是负担的时候,大家更愿意做测试,谁会拒绝投资少收益高的事呢?

    ??图4 测试平台自助E2E

    三 单元测试

    3.1 编写测试样例

    对于块存储主仓库,增量代码覆盖率当前强制卡点85%,生产代码与测试代码需要同步原子提交,否则将因覆盖率不足阻塞提交,这也倒逼了大家养成测试左移的工作习惯,编写 Unit Test / Smoke Test / Funition Test 是块存储每位开发者提交代码的必备技能,团队同学柏亿曾讲过一句话,“如果我写了代码而没有加自动化Case,就相当于我做了一件华丽的衣服,又丢到了垃圾桶里,弃如敝履 ”,通过自动化门禁测试来保证系统设计、代码实现不被后续修改Break,降低系统质量风险,依赖假设足以保证,更多Good/Bad Case参考《Software Engineering at Google》的 “单元测试”章节[2]。

    ??图5 EBS UT/ST/FT

    3.2 代码门禁说明

    代码门禁(简称CI)即代码提交之前自动运行的测试,测试全量通过后方可提交,类似于函数计算,Test as a Service。代码门禁是 测试左移[3] 的必备单品。块存储 CI 门禁基于Google 开源云原生CI框架Tekton实现,支持分布式编译和分布式测试,Kubernetes 门禁集群中的Cpu、Mem、Disk资源限制Limit,每个Case独占容器, 仅Cpu超卖,相当于模拟主频降频,增加了发现bug的概率,曾多次触发大量低概率时序bug;代码门禁包含编译构建、单元测试、冒烟测试、功能测试、代码风格检查、静态代码扫描、增量代码覆盖率卡点等检查项。在推广测试左移的近三年,块存储门禁Case数量逐年翻倍。

    图6 块存储代码门禁

    四 Code Review

    预防胜于治疗,研究表明高效的 Code Review 可以发现70-90%的 bug,Review 作用如下:

  • 提高团队代码

    声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

  • 上一篇 2022年9月11日
    下一篇 2022年9月11日

    相关推荐