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

2022 年 10 月 11 日 阿里技术


“我背上有个背篓,里面装了很多血泪换来的经验教训,我看着你们在台下嗷嗷待哺想要这个背篓里的东西,但事实上我给不了你们”,实践出真知。
这是阿里云块存储团队内部的一次新人培训材料,内容源自老同学们的踩坑经验,总结成案例和方法分享公示,实践和方法论不限于分布式系统,希望对读者有启发。本文主要包括以下三个方面:
  • 编码习惯(开发、测试、Review,Bad/Good Case)
  • 研发流程(源码控制、每日构建、缺陷管理)
  • 实践方法(效率工具、新人踩雷、学习推荐)


一、编码习惯


Ugly is easy to identify because the messes always have something in common, but not beauty.  
-- C++ 之父 Bjarne Stroustrup
代码质量与其整洁度成正比。
-- 《代码整洁之道》作者 Robert C. Martin


1.1 开发

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

图1 开发 理想与现实

要点1 :语义简单明确

这是块存储 SDK 的一段代码,判断限流目标值是否合法;写代码时考虑读者,优先采取易于读者理解的写法。
#define THROTL_UNSET      -2#define THROTL_NO_LIMIT   -1
bool throttle_is_quota_valid(int64_t value){ // 复杂的判断条件 // 请你在三秒内说出 value 如何取值是合法的? if (value < 0 && value != THROTL_UNSET && value != THROTL_NO_LIMIT) { return false; } return true;}
bool throttle_is_quota_valid(int64_t value){ // 这是修改后的代码,value 取值合法有三种情况,一目了然 return value >= 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())    {        return status;    }    else    {        status = Check2();        if (!status.IsOk())        {            return status;        }        else        {            status = Check3();            if (!status.IsOk())            {                return status;            }            else            {                DoSomeRealWork();                return OK;            // 四层潜套 if            }        }    }}
  
  
    

Good Case如下:
Status Foo(){   Status status = Check1();   if (!status.IsOk())   {       return status;   }
status = Check2(); if (!status.IsOk()) { return status; }
status = Check3(); if (!status.IsOk()) { return status; }
DoSomeRealWork(); return OK;}

要点4 :利用析构函数做清理工作
利用 C++ 析构函数做清理工作,在复杂冗长代码中不会漏掉。典型的清理工作有执行回调、关闭文件、释放内存等。
Bad Case如下:
void Foo(RpcController* ctrl,         const FooRequest* 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如下:
void Foo(RpcController* ctrl,         const FooRequest* 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 :用朴素直观的算法
这是块存储旁路系统的一段代码,它根据垃圾比对数据分片进行排序;在非关键路径上,优先使用朴素直观的算法,此时代码可维护性更重要。
void CompactTask::checkFileUtilizationRewrite(){    // 此处采取朴素的排序算法,并未采取更高效的 TopK 算法    std::sort(sealedFilesUsage.begin(), sealedFilesUsage.end(), GarbageCollectionCompare);        int64_t sealedFileMaxSize = INT64_FLAG(lsm_CompactionSealedMaxSize);    int32_t sealedFileMaxNum = INT32_FLAG(lsm_CompactionSealedMaxFileNum);    int64_t targetFileSize = 0;    int32_t sourceFileCnt = 0;                                                                                                                                          // 前者简单清淅,并在几十个 File 中选择前几个文件的场景并不算太慢    FOREACH(itr, sealedFilesUsage)    {        LogicalFileId fileId = itr->fileId;        const FileUsage* usage = baseMap->GetFileUsage(fileId);        const File* 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_t mutex;pthread_cond_t  nonEmptyCondition;std::list<Task*> queue;
void ConsumerLoop(){ pthread_mutex_lock(&mutex); while (true) { while (queue.empty()) { struct timespec ts; 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.cppStatus LoadTask::Execute(){    Status status;#define RUN_STEP(func) \    status = func();    if (!status.IsOk()) { ... }    // 串行执行下列步骤    RUN_STEP(doPrepareDirs);    ...... // 十几步    RUN_STEP(doTask);
#undef RUN_STEP ......}// files.cppStatus 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(new SyncClosureR1<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(); } } return status;}
  
  
    

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

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

2)异步操作的上下文结构,如用户IO Buffer请求;
// stream.hclass Stream{public:    Stream();    ~Stream();
void Read(ReadArgs* args); ......
private: // 增加 magic 字段 // 通常使用 uint32 或 uint64 uint64_t mObjectMagic; ......};// stream.cpp// 定义 magic 常量// 常量值选择 hexdump 时能识别的字符串,以便在 gdb 查看 coredump 时快速识别// 此处使用 “STREAM” 的 ASCII 串static uint64_t STREAM_OBJECT_MAGIC = 0x4e4d474553564544LL;
Stream::Stream() : mObjectMagic(STREAM_OBJECT_MAGIC) // 在构造函数中赋值{ ......}
Stream::~Stream(){ // 在析构函数中检查并破坏 magic 字段,预防 double-free 错误 easy_assert(mObjectMagic == STREAM_OBJECT_MAGIC); mObjectMagic = FREED_OBJECT_MAGIC; ......}
void DeviceSegment::Read(ReadArgs* args){ // 在重要的函数中检查 magic 字段,预防 use-after-free 错误 easy_assert(mObjectMagic == DEVICE_SEGMENT_OBJECT_MAGIC); ...... }
  
  
    

要点10 :SanityCheck() 合法性检查
这是块存储核心模块的一段代码 StreamWriter 负责管理正在写入的Stream,它为每个写请求选择合适的 Stream 写入,并处理文件满、写失败等异常情形;曾在线下测试发现由于未添加合法性检查,导致内存踩坏的meta错误数据持久化到磁盘中,在数据分片发生迁移时,从磁盘加载错误的meta数据持续夯死,不可恢复。在重要操作前后及定时器中检查数据结构中的重要的不变式假设,这样尽早发现代码 bug 在重要的操作前后或是在定时器中执行检查。
class StreamWriter{public:    ......private:    struct StreamGroup    {        WriteAttemptList failureQueue;        WriteAttemptList inflightQueue;        WriteAttemptList pendingQueue;        uint64_t         commitSeq;        uint64_t         lastSeq;    };
uint32_t mStreamGroupCount; StreamGroup mStreamGroups[STREAM_GROUP_COUNT]; ......};
void StreamWriter::sanityCheck(){#ifndef NDEBUG // expensive checks for (uint32_t i = 0; i < mStreamGroupCount; i++) { // Check that sequence in "failureQueue", "inflightQueue" and "pendingQueue" are ordered. const StreamGroup* group = &mStreamGroups[i]; uint64_t prevSeq = group->commitSeq; const WriteAttemptList* queues[] = { &group->failureQueue, &group->inflightQueue, &group->pendingQueue }; for (size_t k = 0; k < easy_count_of(queues); k++) { FOREACH(iter, *queues[k]) { const WriteRequest* 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)        {             const char* 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);  // 触发电话告警             return LSM_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.*/#define INITIAL_JIFFIES ((unsigned long)(unsigned int) (-300*HZ))/** These inlines deal with timer wrapping correctlyYou 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.*/#define time_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 **//int easy_timer_start_on_th(easy_baseth_t *th, easy_timer_t *timer);int easy_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(...); return SC_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(...);        return status;    }
mIsSealed = true; // 修复前无第14行代码,文件写入已完成,清空缓存,释放内存 mEasyPool.reset(); // Do something below}

要点4 :兼容性测试
兼容性包含:协议兼容性、API兼容性、版本升级兼容性、数据格式兼容性;对于所有依赖的兼容性假设需通过测试自动化覆盖,兼容性问题是很难测试覆盖并且问题高发的部分,兼容性问题应该在设计阶段、编码阶段提前预防,避免兼容性问题,而非寄希望于兼容性测试来兜底。
void ActiveManager::SubmitIO({     // 【版本兼容性】 SDK 和 Server线程不对齐,旧版本SDK不支持切线程     if (UNLIKELY(GetCurrentThread() != serverThread))     PGLOG_WARNING(...  "Server thread mismatch");     response->ErrorCode = SERVER_BUSY;     done->Run();}
void ChunkListAccessor::SetChunkInfoAndLocations(){ uint8_t flags = mFileNodePtr->fileFlags; bool isLogFile = 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.cppTEST(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 作用如下:
  • 提高团队代码标准,所有人共享同一套标准,阻止破窗效应
  • 推动团队合作 reviewer 和 submitter 可能有不同的视角,主观的观点经常发生碰撞,促进相互学习
  • 激励提交者,因为知道代码需要别人 review,所以提交者会倾向提升自己的代码质量。大部分程序员会因为同事对其代码显示出的专业性而感到自豪。
  • 分享知识 submitter 可能使用了一种新技术或者算法,使 reviewer 受益。reviewer 也可能掌握某些知识,帮助改进这次提交。

图7  Code Review 可以发现70-90%的 bug
对于Submitter和Reviewer的共同建议,开放的心态,良好的互动,Submitter给到 reviewer 更多的输入后,有益于问题的挖掘。

 图8  一个Review互动的优秀案例

4.1 For Submitter

要点1 :For Submitter - 一次提交不要超过400行代码
研究表明 :在一次 CR 中,Reviewer 应一次至多处理200至400行代码(lines of code)。若超过400行,人的大脑无法有效的处理,发现缺陷的能力将下降。在实践中,使用60-90分钟来 review 一个200-400行的 patch,应该能发现70-90%的缺陷。即如果这个 patch 里面存在10个 bug,那么在 CR 阶段应该能发现7至9个。

图9  缺陷密度 vs 代码行数
 要点2 :For Submitter - 做自己的第一个 reviewer
自我Review有以下几个注意事项:
  • 端正心态,reviewer 是帮你发现问题的人,而不是阻塞你提交的人
  • 认真对待 description,降低Reviewer的理解成本
  • 一次提交只解决一个问题,降低review的复杂度
  • 如果需要做重大修改,写找 reviewer 对齐大致的修改范围,再开始写代码,避免越行越远


图10 Code Review一个Description的优秀案例

4.1 For Reviewer

要点1 :For Reviewer - 控制 review 速度
研究表明 [4]:当 Reviewer 以超过500行/小时的速度 review 代码时,缺陷的发现率会有显著的下降。建议 Reviewer 控制好自己的速度,保障好 review 质量。建议一次 review 的时间不要超过一小时,当任务多时建议提高 review 的频率,避免持续过长时间。

图11  缺陷密度 vs 检查速度
要点2 :For Reviewer - Review 的重心
我们不可避免的需要 review 一些较大的 patch, Review的顺序:接口 >  测试 > 实现 ,reviewer 可以假设自己是该代码的使用方,该接口的定义及行为是否符合自己的预期?如果没有时间,至少也应该看一遍接口定义, 测试代码的质量与实现的质量同等重要,不可敷衍 ,理解提交者想通过测试测哪些东西比理解测试代码的含义更重要。
要点3 :For Reviewer -  Tips
  • reviewer 应该尽量合理的安排自己的时间,不让自己成为 blocker,推荐每天在开始自己的工作前先 review 别人提交的代码。
  • 给建议,更要给原因,帮助提交者进步
  • 如果看到写得好的代码,不要吝啬赞赏的语句,提交者真的会很受鼓舞
  • 对于看不明白的地方一定要提出问题,而不是轻易放过
  • 不要花过多力气去理解难以理解的代码,如果一眼看不明白,第二眼还看不明白,说明这块代码需要改,很大可能过一段时间提交者也会看不明白
  • 如果 patch 太大,应建议提交者分拆
  • 慎重审查 .h 以及协议的修改
  • 没有测试覆盖的代码没必要去看


五、分支管理

5.1 主干开发

列宁:帝国主义是资本主义的最高阶段
南门:主干开发(trunk based development)是持续集成(continuous integration)的最高阶段
 在开发模式上,块存储学习了微软、Google的大库主干开发方式,过去多分支开发初期并行迭代,开发周期宽松,但多分支开发很容易漏提交,块存储也曾因漏提交bugfix导致一起P1S1故障,多分支合并冲突多、迭代慢,需要长期占用多套测试环境,有限的测试资源回归频次相对少。块存储所有持续集成测试资源均集中在主干分支回归,“集中力量办大事”。
 主干开发对开发者提出了很高的要求,不仅要具备有功能特性拆分的能力,而且需要确保每一次代码提交,都能够达到准上线的质量标准,这也倒闭了测试左移的编码习惯,在提交代码入库之前进行测试编码。

 图12  块存储 主干开发

5.2 主干/分支发布

发布模式分两种,公有云是主干分支发布,专有云是LTS(Long Time Support)拉Release分支发布(专有云发布节奏需follow客户要求,公有云发布节奏自主可控)。主干发布,既不是目的,也不是手段。主干发布是结果,是测试能力不断提升后水到渠成的结果,块存储持续集成交付详见第八章。切勿为了主干发布而主干发布,严格控制发布变量和发布节奏,避免为了修复一个bug,引入了另一个更严重的bug,只有经过成熟的测试验证方可进行发布,欲速则不达,敬畏生产。

六、测试 & 环境

6.1 测试脚手架

除了代码门禁的功能Case覆盖之外,Feature Owner需要补充代码后置全链路测试E2E和BVT Case,大版本发布需要经过大规模故障演练的验证方可发布上线,UT/ST/FT每月回归三千多轮,对于信用分低的Case(即不稳定Case)运行轮数权重翻五倍,即每月运行上万轮,通过高频测试尽量多暴露各层面的不稳定因素,倒逼人肉环节的自动化。块存储按照不同的场景提供了多种测试脚手架进行测分级,越轻量的测试回归轮数越多,更多详细说明如下图所示。

图13  块存储 测试分级

6.2 环境标准化

对于测试环境的标准化,采用两个思路:云原生用完即抛、资源池化集中管理。
 云原生隔离用完即抛 ,分三种:Docker FT、门禁、K8S E2E。Docker FT在 2.1 章节有详细介绍;门禁Kubernetes集群对开发者无感知,在3.2章节有详细介绍; K8S E2E集群 在6.1章节的表格中有使用说明介绍,不同于Docker FT,支持用户态网络协议,支持运维和监控平台等,3台物理机即可模拟12~24台虚拟集群,如集群环境污染,可半小时销毁重建,而非人肉修环境。
 测试资源在测试平台集中管理,分两种:白屏测试只读环境、黑屏测试读写环境,尽量提升资源池化比例。第一种,白屏测试包含三部分:代码门禁CI集群、CD E2E每日回归环境、长时间稳定性预发环境;环境隔离只读,从而杜绝破坏宿主机、修改配置、残留进程等污染环境行为。第二种,黑屏测试即读写环境,用于开发测试联调,采用集群健康度检查工具提前发现环境污染,覆盖网络、磁盘、OS等几十个检查项,避免测试过程中反复踩坑带来的无效重复测试,如下图所示。


图14 块存储 集群健康度检查

七、缺陷管理


为什么要进行缺陷管理?由于Bug信息不对称,难追溯,开发和发布时间异步,fullmesh协同交互成本极高,且可靠性很难保证,在《人月神话》书中描述, y=x2 ,    x 组织规模人数,  y 沟通协同成本 ,指数增加。 5.1小节中描述的P1S1故障发生后,团队进行了hotfix重发,消除生产风险,但由于对Bug影响集群范围疏忽,导致一个月后同样的故障重复发生,造成二次伤害,吸取教训,团队通过开发git-poison,实现bug分布式源码管理,支持追溯、查询和反馈,自动化bug发布卡点、精准召回bug中毒版本,避免bugfix漏发,降低人因失误造成的质量风险。

7.1 git-poison投毒

基于go-git实现 git-poison:简单易用的bug/bugfix追溯工具,git作为分布式版本管理工具友好地支持了多人并发的一致性问题,简单可靠,开发同学只需要记住一件事:任何时候发现有bad bug,要block预发/生产发布的,抓紧!可复制性高,无论是“分支开发、主干发布“,还是”主干开发、分支发布“,均适用;不依赖于人和人之间的沟通协调,人的involve越少,出错的机会越小。

 图15  git-poison投毒/解药/银针

7.2 poison发布阻塞

开发机代码仓库是poison的唯一数据生产源,测试平台和发布平台消费poison数据,实现PoisonCommit 发布阻塞机制,测试平台在填写ReleaseNote一键导入Bug/Bugfix,评估本次版本发布的质量风险,在发布平台通过“集群-模版-Bug-版本”四维度画像实现Bug追溯和查询,精准定位Bug中毒域,召回Bug版本。

图16  “集群-模版-Bug-版本” 四维度生产画像

八、持续交付


8.1 从开发到上线

在代码从开发到上线的整个生命周期存在四阶段系统强制卡点,致力于“从不做错到做不错”。自动化的价值不仅仅是节约时间,更是提高交付质量和交付效率,降低交付成本(例:工作状态上下文切换、 完成一项任务的认知负荷、重复工作量),块存储从开发到上线 Code Velocity 即 Commit-to-Prod time of = 30~45 days (pipeline) 。
 质量防控的思路和疫情防控的思路很像,首先在在源头处,通过代码门禁及时发现,就像是疫情的入境检查;持续集成,每日主干回归,缩短反馈弧,就像是现在 “每3天一核酸,全员检测”,一有问题,马上定位修复。

图17  块存储 从开发到上线

8.2 分模块发布

代码库存越是积攒,就越得不到生产校验,积压越多,代码间交叉感染的概率越大,下个Release的复杂度和风险越高,流水不腐,户枢不蠹。那么,如何提高Code Velocity?   除了持续集成建设外,发布阶段也需要解除强绑定,学习微服务化的思路,实现分模块发布,通过将发布模版以模块粒度拆分,各自模块仅拥有自己模块的权限,大幅提升了权限安全管理和发布的灵活性;从发布方面解决了“大锅饭”的问题,真正的将组织层面质量责任落实到了每一个模块的Owner上。
通过微服务化,将单次版本百人协同的成本降低到十人以下,彻底告别“发大车”,各自聚焦,各行其道,互不干扰。

图18  块存储 分模块发布
 分模块发布后,由于各模块的发布频次和周期不同,你追我赶,超车频繁,也带来了版本兼容性的挑战,原本设计正交的功能常常出现意想不到的兼容性Bug,块存储在实行分模块发布后屡次踩坑兼容性生产问题,其中包括一次P1故障。面对分模块发布带来的兼容性挑战,一方面,单个服务内,线上版本分裂多,通过收敛治理,全网拉齐;另一方面,多个服务间,笛卡尔积的版本组合多,测试如何覆盖不同服务不同版本的组合,昂贵的测试成本和漫长的测试时间是无法接受的,如前文所述,采用云原生的思路,通过轻量化Docker集群降低测试成本,同时提高测试效率。

图19  分模块发布后兼容性测试

九、文化实践


9.1 效率工具和方法

开发工具:工欲善其事,必先利其器。工具没有好与坏,要看你如何使用,一个高效的工具事半功倍,推荐一些日常工具:
  • 开发机:窗口操作使用Screen、tmux 保持链接不中断、 SSH远端包括iTerm2、Bash、Zsh、Fish、 编辑器包括VSCode、Vim 、调查问题Debug使用gdb、pdb、内存泄漏: tcmalloc 、代码扫描静态cppcheck和动态代码扫描asan;
  • Li nux:Cpu/Mem资源查看使用tsar --mem/cpu/io/net -n 1 -i 1、top等、网络使用lsof、netstat、 磁盘使用iostat、block_dump、inotifywait、df/du、内核日志包括/var/log/messages、sudo dmesg、性能工具strace、perf,IO压测FIO;
  • 文档类:语雀的在线UML图/流程图/里程碑方便多人共同编辑等、Teambition的项目管理甘特图、 Aone的需求管理和缺陷管理、  离线工具诸如Xmind思维导图/draw.io 流程图/OneNote。

工作方法: 良好的工作方法,可以让自己的成长速度形成复利,推荐阅读两本书《直击本质》和《系统之美》,或许你可以从书中找到不同人成长速率差异的答案,这里推荐三个工作方法:
  • SMART原则,我的第一任主管飞山推荐,适用场景:OKR、KPI、绩效自评
  • S:Specific,具体的
  • M:Measurable,可以衡量的
  • A:Attainable,可以达到的
  • R:Relevant,具有一定的相关性
  • T:Time-bound,有明确的截止期限

  • 论文学习方法,推荐先阅读大数据经典系列(例如Google 新/老三驾马车),对于存储领域同学,推荐Fast论文
  • Motivation: 解决了一个什么样的问题?为什么要做这个问题?
  • Trade-off: 优势和劣势是什么?带来了哪些挑战?

  • 适用场景: 没有任何技术是普适的,业务场景,技术场景
  • 系统实现: 组成部分和关键实现,核心思想和核心组件,灵魂在哪里?

  • 底层原理: 其底层的关键基础技术,基于这个基础还有哪些工作?
  • Related Works: 这个问题上还有什么其他的工作?相关系统对比?不同的实现、不同的侧重、不同的思路?

  • TDD,Test-diven Development 测试驱动开发,团队石超推荐,“自从看了TDD这本书,我就爱上了写UT”,当时听完这句话驱动了我的好奇心,TDD到底一个什么神奇的方法?后来发现在《软件测试》《Google :Building Secure & Reliable Systems》《重构》 《重构与模式》《敏捷软件开发》《程序员的职业素养》……国外泰斗级程序员大叔的书里,全部都推荐了TDD。TDD不是万能药,主要思维模式是,先想清楚系统的行为表现,再下手编码,测试想清楚了,开发的API/系统表现就清晰了,API/函数/方法语义就明确了。


9.2 个人成长和踩坑

对于校招同学,职场童年最重要的是养成良好的工作习惯,身份从学生到工程师,必然伴随着成长的阵痛感,从学生思维转向职场思维,从学习驱动转向任务交付驱动,运用学校里基础知识,长期锻炼的学习思维,快速达到独立交付状态。不论任务多小,要能独立负责,交付好结果,事事有着落,承担任务,从小到中到大,从简单到复杂,从尝试型到突破型。
 在工程实践方面,不再是学生时代的实验课题或者编程比赛,而是从程序到编程系统产品的转变,程序员的工作不止是编程,是把想法变成产品能力的过程。从程序到编程系统,研发成本翻3倍,包括接口设计和集成测试;从程序到编程产品,研发成本翻3倍,包括通用化、测试、文档、维护;而从从程序到编程系统产品,研发成本翻9倍,日常编码时间占比20%~30%,如若你感觉编码时间占比过少,或许是学生思维的惯性,块存储团队践行DevOps,无专职测试岗和运维岗,对于每一位Feature Owner而言,需要独立负责该Feature的 “开发 → 测试→ 发布→巡检” 全生命周期。

图20  来源 《人月神话》 编程系统产品的演进
个人成长: 以前和与团队同学思潜讨论过身边很牛的同事与大家的差距在哪?持续优秀,自我迭代,再牛也只是一时的,很牛的人能把自己当代码迭代,一个版本一个版本演进,不停loop起来,有针对性、上升式的学习和实践。夯实最基础的底层知识,融会贯通的人都是有底层逻辑的,多阅读经典。分享三条Facebook研发效能负责人推荐的高效工作方法:
  • 抽象和分而治之
  • 抽象,明确模块之间的依赖关系,确定API接口
  • 分而治之,对子系统设计进行合理的注释,帮助代码阅读者对软件结构有更直观的了解
  • 代码提交尽量做到原子,即不可分割的特性、修复或者优化,测试代码同生产代码同一个patch提交

  • DRY(Don’t Repeat Yourself)
  • 寻找重复的逻辑和代码,对重复内容进行抽象和封装
  • 寻找流程的重复,使用脚本或者工具自动化,通过自动化提高交付质量和效率,降低交付成本
  • 沉淀踩坑经验到自动化工具和平台中,独乐乐不如众乐乐,避免不同人踩相同的坑,降低无效时间开销
  • 快速迭代
  • Done is better than prefect,不要过度设计
  • 尽快让代码运行和快速验证,不断迭代来完善
  • 为了能够快速验证,本地测试成本低,缩短反馈弧
  • 实现一个可以运行起来的脚手架,再持续添加内容

踩坑心得: 在职场初期经常感觉别人有问题,往往时间会证明,自己才是那个有问题的人,总结了以下三条过往工作中踩坑建议:
  • 忌“太心急”,慢即是快
  • 需求澄清:类似TCP三次握手,用自己理解的方式再给对方讲一遍,确认双方理解一致,对焦,避免重复返工
  • 自我提问:为什么做这件事?业务价值是什么?关键技术是什么?已有的系统和它对比有什么不同?兄弟团队是否做过类似的工作?是否有经验可供参考?业务/技术的适用场景是什么?预计耗时和进度风险?
  • 新人往往脚踏实地,忘记了仰望星空,只顾着埋头苦干,不思考背后的业务价值,这一锄头,那一铁锹,遍地都是坑,就是不开花,费时费力,成就感低。

  • 忌低效沟通,用数据说话
  • 精确地描述问题,上下文和范围,提供有效信息
  • 文档是提高沟通效率的最佳方式之一,Google有文档文化,推荐阅读 《Design Docs at Google》 [5]
  • Bad Case:「测试CX6网卡时,IOPS大幅下降」
  • Good Case:「在100g网络标卡CX6验证性能时,8 jobs 32 depth iosize 4K场景下,极限IOPS从120万下降至110万,与FIC卡相比性能存在8%差异」

  • 忌“蠢问题”,学会提问
  • 鼓励新人多提问,但提问的问题一定要有质量
  • 关于如何提出一个好问题推荐阅读 《提问的智慧》 [6]
  • Bad Case:「我在编译耗时很长,我怀疑是资源不够,这种情况怎么办?」
  • Good Case:「我的开发机编译耗时2小时,不符合预期,OS是centOS 7U、128GB内存、64Core,编译并发度是20核,未限制内存,编译过程使用Top查看确实20核并发,Cpu和Mem没有达到瓶颈,iostat看磁盘使用率每秒60%」


十、延伸阅读


文章的最后,推荐几本最近阅读过的书,好书读得越多越让人感到无知,“流水不争先,争得是滔滔不绝”,找到适合自己的节奏学习。
  •  《编写可读代码的艺术》 ,推荐理由:漂亮的代码长什么样、命名变量避免歧义、写出言简意赅的注释、抽取小函数让测试用例更易读等;
  •  《Software Engineer at Google》 ,推荐理由:介绍Google 软件工程文化、流程、工具, github 有中文电子版
  •  《人月神话》 ,推荐理由:被誉为软件工程圣经,与《人件》共同被称为双子星,两者是软件行业的神书,而其他书只能被称为经典,自1986年出版至今,每年销售量上万本,值得每隔1~2年反复阅读,初看不知剧中意,再看已是剧中人
  •  《数据密集型应用系统设计》 ,推荐理由:软件开发者的必读书籍,衔接理论与实践,包括数据密集型应用系统所需的若干基本原则、深入探索分布式系统内部机制和运用这些技术、解析一致性/扩展性/容错和复杂度之间的权衡利弊、介绍分布式系统研究的最新进展(数据库)、揭示主流在线服务的基本架构等。

日拱一卒,功不唐捐,共勉。

参考链接:

[1] 接近不可接受的负载边界

https://www.usenix.org/conference/srecon18americas/presentation/schwartz

[2] Software Engineering at Google

https://qiangmzsx.github.io/Software-Engineering-at-Google/#/zh-cn/Chapter-12_Unit_Testing/Chapter-12_Unit_Testing

[3] 测试左移在大型分布式系统中的工程实践

https://mp.weixin.qq.com/s/DSsscC_5ldOTCTbW6u-ubw

[4] Best Practices for Code Review

https://smartbear.com/learn/code-review/best-practices-for-peer-code-review/

[5] Design Docs at Google

https://www.industrialempathy.com/posts/design-docs-at-google/

[6] 提问的智慧

https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md


《阿里云存储白皮书》


随着阿里云的崛起,集团内部的各种技术开始以阿里云作为唯一出口,阿里云成为阿里巴巴经济体的技术底座,阿里云的“盘古”存储也成为阿里巴巴经济体的存储底盘。用“稳定安全高性能,普惠智能新存储”来形容这本白皮书的内涵最为恰当不过了。“不畏浮云遮望眼,自缘身在最高层。”基于盘古的阿里云存储必将继续引领全球产业进入未来的“新存储”大时代。


点击阅读原文查看详情。

登录查看更多
0

相关内容

【硬核书】稀疏多项式优化:理论与实践,220页pdf
专知会员服务
68+阅读 · 2022年9月30日
Neo4j知识图谱的技术解析及案例分享
专知会员服务
46+阅读 · 2022年7月15日
实时数据湖在字节跳动的实践
专知会员服务
29+阅读 · 2022年5月28日
军事知识图谱构建技术
专知会员服务
125+阅读 · 2022年4月8日
找工作实用书《LeetCode 题解》,262页pdf
专知会员服务
129+阅读 · 2021年12月2日
专知会员服务
155+阅读 · 2021年3月6日
【2020新书】懒人程序员专用书C++20,681页pdf
专知会员服务
43+阅读 · 2020年12月15日
【2020新书】实战R语言4,323页pdf
专知会员服务
100+阅读 · 2020年7月1日
【新书】Java企业微服务,Enterprise Java Microservices,272页pdf
卓越工程之单元测试在行权鉴权中的实践
阿里技术
0+阅读 · 2022年10月21日
Linux 内核不能进行软件工程?
CSDN
2+阅读 · 2022年8月30日
淘系用户平台技术团队单元测试建设
阿里技术
0+阅读 · 2022年5月12日
阿里云RemoteShuffleService新功能:AQE和流控
阿里技术
0+阅读 · 2022年4月22日
腾讯数据湖查询优化实践
专知
3+阅读 · 2022年3月24日
「Hello World」中的「bug」
机器之心
0+阅读 · 2022年3月22日
网易数帆云原生日志平台架构实践
专知
1+阅读 · 2022年3月12日
谈一谈单元测试
阿里技术
0+阅读 · 2022年2月14日
微服务下分布式事务模式的详细对比
InfoQ
0+阅读 · 2021年12月12日
WebAssembly在QQ邮箱中的一次实践
IMWeb前端社区
13+阅读 · 2018年12月19日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
2+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2010年12月31日
国家自然科学基金
0+阅读 · 2009年12月31日
国家自然科学基金
0+阅读 · 2008年12月31日
国家自然科学基金
1+阅读 · 2008年12月31日
国家自然科学基金
0+阅读 · 2008年12月31日
Arxiv
0+阅读 · 2022年11月28日
Arxiv
0+阅读 · 2022年11月24日
Arxiv
0+阅读 · 2022年11月23日
Arxiv
46+阅读 · 2021年10月4日
VIP会员
相关VIP内容
【硬核书】稀疏多项式优化:理论与实践,220页pdf
专知会员服务
68+阅读 · 2022年9月30日
Neo4j知识图谱的技术解析及案例分享
专知会员服务
46+阅读 · 2022年7月15日
实时数据湖在字节跳动的实践
专知会员服务
29+阅读 · 2022年5月28日
军事知识图谱构建技术
专知会员服务
125+阅读 · 2022年4月8日
找工作实用书《LeetCode 题解》,262页pdf
专知会员服务
129+阅读 · 2021年12月2日
专知会员服务
155+阅读 · 2021年3月6日
【2020新书】懒人程序员专用书C++20,681页pdf
专知会员服务
43+阅读 · 2020年12月15日
【2020新书】实战R语言4,323页pdf
专知会员服务
100+阅读 · 2020年7月1日
【新书】Java企业微服务,Enterprise Java Microservices,272页pdf
相关资讯
卓越工程之单元测试在行权鉴权中的实践
阿里技术
0+阅读 · 2022年10月21日
Linux 内核不能进行软件工程?
CSDN
2+阅读 · 2022年8月30日
淘系用户平台技术团队单元测试建设
阿里技术
0+阅读 · 2022年5月12日
阿里云RemoteShuffleService新功能:AQE和流控
阿里技术
0+阅读 · 2022年4月22日
腾讯数据湖查询优化实践
专知
3+阅读 · 2022年3月24日
「Hello World」中的「bug」
机器之心
0+阅读 · 2022年3月22日
网易数帆云原生日志平台架构实践
专知
1+阅读 · 2022年3月12日
谈一谈单元测试
阿里技术
0+阅读 · 2022年2月14日
微服务下分布式事务模式的详细对比
InfoQ
0+阅读 · 2021年12月12日
WebAssembly在QQ邮箱中的一次实践
IMWeb前端社区
13+阅读 · 2018年12月19日
相关基金
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
2+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2010年12月31日
国家自然科学基金
0+阅读 · 2009年12月31日
国家自然科学基金
0+阅读 · 2008年12月31日
国家自然科学基金
1+阅读 · 2008年12月31日
国家自然科学基金
0+阅读 · 2008年12月31日
Top
微信扫码咨询专知VIP会员