那些年那些神碼

2023-03-25 06:01:34

首先說明一下什麼是神碼?神碼就是神奇程式碼的意思(也是糟糕的意思),在這裡是為了表達引以為戒!

往事不堪回首!想當年(2017年)公司技術團隊新組建,系統新搭建。為了趕工期,一切以快速為目標,快速試錯,快速交付上線。專案管理規範被忽視和技術規範管控沒有及時跟上,工程師們交付的程式碼質量非常的糟糕。產生了不少嚴重的生產故障,後果比較嚴重,教訓慘痛!

當年雖是架構師崗位,但卻像是救火隊員。毫不誇張地說是,哪裡有生產故障問題,哪裡就得去救火!

原因有三:

  • 團隊新組建,成員水平參差不齊,有部分人還可以,但是有部分人基礎確實不夠硬,甚至不懂物件導向的人也來寫java程式碼。
  • 趕工期,專案管理沒有做好,沒有制定和執行統一的程式碼規範。
  • 關鍵時刻容不得有半點怠慢,頂著巨大的壓力,快速滅火救火才王道,使用問題影響最大化降低。

因此,在那些年救火過程中,填了不少的坑;事後覆盤做了一些總結記錄,針對問題進行深入分析,找出根因,希望避免再次出現,從而得到一些寶貴經驗總結。

今天就來聊聊那些年給我曾經留下深刻教訓的程式碼片斷。在此不做批判,僅做反思與學習總結,也想讓各位看官得到一點啟發。下面我們來詳細看看有哪些神碼,到底有多神的程式碼?

神碼片斷1:不正確的使用redis命令

 

上面程式碼片斷非常簡單,就是簡單封裝的jedis工具類。初看也沒有啥問題,但就因為這樣程式碼導致生產出現奇怪的問題。生產環境部署多個業務系統,使用了同一個redis叢集。某些業務系統的redis值被頻繁清除,莫名其妙的丟失資料,排查很久之後才找出來。

最後通過redis後面服務監控,檢查出來程式碼使用FlushALL命令,並通過全域性搜尋程式碼,找出來在某個業務系統退出登入的時候,呼叫了這個工具類。jedis工具類的init方法,init方法內部使用了flushAll命令,這個命令是會全庫刪除,非常坑的用法。當時是修改為flushdb(其實也有隱患的,如果多個應用或同個同個Redis db庫,就會被刷掉)。

其實在使用者退出的業務中,只需要清理相關對應的快取就行了,即刪除(del)對應的key值即可,完全沒有必重新整理動作。

 

神碼片斷2:不正確的使用@Transactional 事務註解

在這裡程式碼片斷中使用了Spring @Transactional 事務註解。

這一段程式碼裡有三個操作:

第一是寫入主業務表 save(customer);

第二是把相關資料寫入附件表記錄登入save(attachment);

第三遠端呼叫一外部介面sendCustomerToWeChat(customer);

第一和第二個使用事務可以實現事務一致性,但第三用了一個非同步執行緒,同時也跨服務的遠端呼叫

    CompletableFuture.supplyAsync(() -> {
            sendCustomerToWeChat(customer);
            return "OK";
        });

這裡事務是不能保證資料一致性的。

 

神碼片斷3:加了分散式鎖也出現重複編碼

看到函數加了 @Transactional 事務註解,同時函數內部加鎖了redis分散式鎖  RedisLocker.lock(lockName); 按理應該正常產生業務編碼,結果其實不然,已經加了redis全域性鎖,但還是出現重複編碼的情況

在高並行環境下可能會使用鎖失效。正常做法是要麼在事務外加鎖,要麼分解重寫需要控制事務程式碼塊。

鎖失效的原因是:由於Spring的AOP,會在update/save方法之前開啟事務,在這之後再加鎖,當鎖住的程式碼執行完成後,再提交事務,因此鎖程式碼塊執行是在事務之內執行的,可以推斷在程式碼塊執行完時事務還未提交;

其他執行緒進入鎖程式碼塊後,讀取的庫存資料不是最新的。

正確的做法要把最外層@Transactional 去掉。具體問題分析見《高並行環境下生成序列編碼重複問題分析》。

 

神碼片斷4:跨服務呼叫資料列表導致記憶體溢位

 

公告模組查詢邏輯非常簡單,通過查出公告列表,然後根據當前人所在區域、組織、品牌品類、崗位進行資料集合的過濾。

如果說所有業務資料和人員架構、許可權資料在同一個資料庫,幾個表join 一下結果很容易出來。

但現在的問題是,人員組織、許可權是獨立一個服務獨立一個資料庫;

業務資料公告又是獨立的服務和資料庫。也就是需要聚合兩個服務list集合資料匹配過濾之後再進行結果的展示。

在大回圈裡面去查詢部門、崗位、人員許可權判斷,然後通過遠端RPC介面去呼叫人員介面資料。

每個人登入就將產生近1000次介面呼叫和本地資料業務查詢組合,假定有1萬人在使用,那意味著有1千萬次遠端呼叫,10萬人存取,就有一億次呼叫,面對巨大的網路IO,誰能扛得住,巨坑呀!

在測試環境測試的時候,存取人數少,沒有測試出來,其實也是沒有進行大規模的壓測。

這一段程式碼上線後直接導致公告業務的服務應用記憶體溢位,服務死了好幾次。坑死人不償命!

階段性優化修改:

迴圈呼叫之前,先把一些資料準備好,而不是進入迴圈裡面去呼叫遠端查詢,減少跨機器的網路通訊時間和次數。優化改完之後,系統能正常執行,穩定下來了。

其實這種做法雖然階段優化解決了問題,勉強過關,但仍然有很多改進優化的空間。

跨多個服務間呼叫:聚合——>條件過濾——>展示

多個List之間的聚合、遍歷、拷貝,其實也消耗資源的,並行量高到一定程度,機器也承受不了。

優化方向轉向使用ES,在釋出公告即寫入的時候就做一些平鋪工作,把模板和許可權邏輯做一些對映處理,查詢的時候直接查詢ES,然後做一些簡單的標籤符號替換,改造之後實現10萬級別QPS,毫秒級響應。

ES改造後版本程式碼:

 

神碼片斷5:坑爹的型別判斷

這種程式碼本質上程式碼規範問題,也是開發人員的基本素質問題。雖然不是什麼致命問題,也產生正確的結果,但按照程式碼規範實在不應該這麼寫。

存在問題:

  • 字串比較不要用"=="而是用equals;
  • 既然是判斷是與否,就直接用boolean型別,增加程式碼可讀性和健壯性;

稍微修改一下,不然真的無法看。

 

引申知識點:

基本資料型別它們之間用「==」比較時,比較的是它們的值。
參照資料型別它們用「==」比較時,比較的是它們堆記憶體地址。
Object equals()初始預設行為是比較物件的記憶體地址值,不過在String、Integer、Date等這些類中,equals都被重寫以便用來比較物件的成員變數值是否相同,而不再是比較類的堆記憶體地址了。

看String equals JDK8原始碼

對於Integer var = ? 在-128至127範圍內的賦值,Integer物件是在IntegerCache.cache產生,會複用已有物件,物件參照地址是同一個,而這個區間之外的所有資料,都會在堆上直接產生新的物件。這是個大坑!!!

基本資料型別(如byte、short、char、int、long、float、double、boolean 等)的值比較,用 」==」 進行比較。
參照資料型別( 如String、Short、Char、Integer、Long、Float、Double、Boolean、Date等)的值比較,用equals進行比較。
推薦使用java.util.Objects#equals(JDK7引入的工具類)

 

神碼片斷6:萬惡的where空條件

這段程式碼很簡單,也很好理解,可是釋出到生產環境卻造成嚴重的災難,可稱得是史上最嚴重的BUG,下面詳細描述一下這過程發生細節。

一、問題產生過程描述:

  • 一個同手機號碼使用者(吳X兵)名下有多個賬號,使用者操作某些賬號失效;
  • 然後用失效賬號登入,能正常登入到系統,繼續做修改手機號碼的動作;
  • 修改手機號碼時,由於程式查詢邏輯不夠嚴謹,主使用者為空導致查詢全表資料;
  • 全部使用者資料更新為同一個手機號碼,問題暴發!
  • 10:35左右發現UC系統比較卡,UC資料庫有鎖表時間過長告警,開發開始排查問題,11:20答疑收到終端使用者(吳X兵)反饋收到很多(計審、價審)電話。
  • 通過查詢資料庫、紀錄檔和鏈路定位到問題,12:30左右釋出修復修補程式,並從備份資料恢復資料(前一天凌晨3點的資料),並刷數補齊上午產生的數差。
  • 1:30開始排查並修復各個業務產生的資料(服務單、設計軟體任務列表、工廠訂單、裂變活動、送貨安裝、MSCS訂單);
  • 其中影響比較嚴重的是工廠訂單,產生5萬多條生產傳單資料,其中2.5萬多條流傳到製造,準備到工廠車間排產。

二、詳細排查問題記錄

詳細分析阿里雲服務紀錄檔

2021-12-08 09:54:43.999

吳X兵一個正常B端使用者登入我們平臺,他在自己賬號管理模組進行了解綁賬號操作( 賬號:CZJR022@xx09243)本來就是一個很普通很正常的業務操作,他也如期正常的操作完了。

解除繫結操作正常成功之後,系統內部會進行呼叫清快取介面,系統紀錄檔顯示如下:

解綁成功能之後,主賬號MainUserId被清除掉了。

2021-12-08 10:38:08.528  

吳X兵,又進行操作修改本人的手機號操作

結果悲劇正常產生了,就是開頭那段程式碼,where條件為空,相當於查詢全表!從鏈路紀錄檔也可以抓到這個SQL

開始出現批次更新手機號這個主使用者手機號。庫裡所有其他的賬號全部被更新為這個吳X兵的手機號碼,嗚呼!!!!

 

手機號碼欄位資料全量被更新為同一個,問題暴發之後,對此服務進行緊急滅火行動,對終端使用者釋出緊急停服通告,服務暫時掛起1小時進行資料修復。

由於這個服務沒有做小時級別的資料增量備份,只能拿前一天資料凌晨3點的資料做資料庫恢復,今天增量資料(900多條),只能通過解析系統紀錄檔,一條條從紀錄檔中找出來去匹配修復。

三、遺留的問題

  • 部分設計檔案寫入PDF和XML的已經固化,設計檔案無法做更新,只能重新發起重新生成,真是悲慘!
  • 個別賬號出現狀態不一致情況,只能通過對比恢復前後資料進行更新刷數處理。

四、問題反思

  • 失效的賬號仍然能登入,這是程式的一個大BUG。
  • 條件為空時查全表,需要大家吸取血的教訓,舉一反三,要求大家寫程式時要嚴謹,加強自測,該加判斷的不能少。

五、強化解決

  • 框架層面解決無效當前使用者全域性攔截校驗,阻斷具體的業務操作;
  • 加強程式碼,判空,非空,必填等核心邏輯程式碼對引數進行必要校驗;
  • 切面AOP全域性攔截查詢、更新、刪除等全表操作的SQL,對無參進行攔截阻斷;
  • 重要資料質量安全監控,狀態一致,資料一致性非常重要;
  • 資料備份策略優化改進,重要資料按時段多幾個備份。

經過這一次慘痛教訓,決定在框架層做點功能,把不符合規則的SQL攔截掉,即不帶where條件引數SQL進攔截,具體程式碼如下:

@Intercepts(
        {
                @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        }
)
@Component
public class AllQueryInterceptor implements Interceptor {

    /**
     * 白名單:允許全表查詢的表名
     */
    @Value("${white.table.name:}")
    private String whiteTableName;

    /**
     * 允許不帶where條件,只帶limit,且limit的最大條數
     */
    @Value("${limit.size:10000}")
    private Long limitSize;

    /**
     * 全域性控制是否啟動該校驗的開關
     */
    @Value("${all.query.check:true}")
    private Boolean allQueryCheck;

    private static final Logger LOGGER = LoggerFactory.getLogger(AllQueryInterceptor.class);

    private static final Pattern p = Pattern.compile("\\s+");

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];
        BoundSql boundSql = mappedStatement.getBoundSql(parameter);

        if(!sqlHavingWhere(boundSql) && !sqlHavingLimit(boundSql) && allQueryCheck){
            LOGGER.debug(boundSql.getSql());
            throw new BusinessException("檢測到您有操作全表記錄的風險,請聯絡系統管理員!");
        }else{
            return invocation.proceed();
        }
    }

    private Statement getStatement(String sql){
        Statement statement = null;
        try {
            statement = CCJSqlParserUtil.parse(sql);
        } catch (JSQLParserException e) {
            LOGGER.error("轉換sql失敗,原sql={}",sql);
        }
        return statement;
    }

    /**
     * 判斷是否有limit
     * @param boundSql
     * @return
     */
    private Boolean sqlHavingLimit(BoundSql boundSql){
        try {
            IPage page = getPage(boundSql);
            if (null != page && page.getSize() >= 0L && page.getSize()<=limitSize){
                return true;
            }else {
                String originalSql = boundSql.getSql();
                return originalSql.contains(CommonConstants.SqlKeywords.LIMIT);
            }
        } catch (Exception e) {
            LOGGER.error("判斷sql是否涉及全表操作異常,原因{}",e);
        }
        return true;
    }

    /**
     * 判斷sql是否涉及全表操作
     * @param boundSql
     * @return
     */
    private Boolean sqlHavingWhere(BoundSql boundSql){
        try {
            String originalSql = boundSql.getSql();
            Statement stmt  = getStatement(originalSql);
            if(null != stmt){
                // 允許全量操作的表在白名單放開
                if(whiteTableName(getTableNames(stmt))){
                    return true;
                }
                // where沒有條件或者只有一個刪除標識條件,則認為是全表操作
                Set<String> where = getWhere(stmt);
                if(where == null){
                    LOGGER.debug("疑似操作全表的sql={}",originalSql);
                    return false;
                }else if(where!=null && where.size() == 1 && CommonConstants.SqlKeywords.DEL_FLAG.equals(where.iterator().next().toUpperCase())) {
                    LOGGER.debug("疑似操作全表的sql={}",originalSql);
                    return false;
                }
            }
        } catch (Exception e) {
            LOGGER.error("判斷sql是否涉及全表操作異常,原因{}",e);
        }

        return true;
    }

    /**
     * 獲取分頁資料
     * @param boundSql
     * @return
     */
    private IPage getPage(BoundSql boundSql){
        Object paramObj = boundSql.getParameterObject();
        IPage<?> page = null;
        if (paramObj instanceof IPage) {
            page = (IPage)paramObj;
        } else if (paramObj instanceof Map) {
            Iterator var8 = ((Map)paramObj).values().iterator();

            while(var8.hasNext()) {
                Object arg = var8.next();
                if (arg instanceof IPage) {
                    page = (IPage)arg;
                    break;
                }
            }
        }
        return page;
    }

    /**
     * 獲取表名
     * @param statement
     * @return
     */
    private List<String> getTableNames(Statement statement){
        List<String> tableNames = new ArrayList<>();
        if(statement != null){
            TablesNamesFinder tablesNamesFinder = new TablesNamesFinder();
            tableNames = tablesNamesFinder.getTableList(statement);
        }
        return tableNames;
    }

    /**
     * 判斷表名是否在允許查全表的白名單內
     * @param tableNames
     * @return
     */
    private boolean whiteTableName(List<String> tableNames){
        for(String tableName : tableNames){
            // 有些表名帶了``,把它去掉
            if(tableName.startsWith("`") && tableName.endsWith("`")){
                tableName = tableName.substring(1,tableName.length()-1);
            }
            if(whiteTableName.contains(tableName)){
                return true;
            }
        }
        return false;
    }

    private List<PlainSelect> getPlainSelect(Statement stmt){
        List<PlainSelect> plainSelectList = new ArrayList<>();
        Select select = (Select) stmt;
        SelectBody selectBody = select.getSelectBody();
        if(selectBody instanceof PlainSelect){
            PlainSelect plainSelect = (PlainSelect) selectBody;
            plainSelectList.add(plainSelect);
        }else{
            SetOperationList setOperationList = (SetOperationList)selectBody;
            for(SelectBody setOperation : setOperationList.getSelects()){
                PlainSelect plainSelect = (PlainSelect) setOperation;
                plainSelectList.add(plainSelect);
            }
        }
        return plainSelectList;
    }

    /**
     * 獲取where裡面的引數
     * @param
     * @return
     */
    private Set<String> getWhere(Statement stmt){
        Set<String> whereItemSet =new HashSet<>();
        List<PlainSelect> plainSelectList = getPlainSelect(stmt);
        for(PlainSelect plainSelect : plainSelectList){
            getWhereItem(plainSelect.getWhere(),whereItemSet);
        }
        return whereItemSet;
    }

    /**
     * 獲取where節點引數
     * @param rightExpression
     * @param leftExpression
     * @param tblNameSet
     */
    private void getWhereItem(Expression rightExpression,Expression leftExpression,Set<String> tblNameSet){
        if(rightExpression != null){
            if (rightExpression instanceof Column) {
                Column rightColumn = (Column) rightExpression;
                tblNameSet.add(rightColumn.getColumnName());
            }if (rightExpression instanceof Function) {
                getFunction((Function) rightExpression,tblNameSet);
            }else {
                getWhereItem(rightExpression,tblNameSet);
            }
        }
        if(leftExpression != null){
            if (leftExpression instanceof Column) {
                Column leftColumn = (Column) leftExpression;
                tblNameSet.add(leftColumn.getColumnName());
            } if (leftExpression instanceof Function) {
                getFunction((Function) leftExpression,tblNameSet);
            }else {
                getWhereItem(leftExpression,tblNameSet);
            }
        }
    }

    /**
     * 獲取where裡面的欄位
     * @param
     * @return
     */
    private void getWhereItem(Expression where, Set<String> tblNameSet){
        if(where instanceof BinaryExpression) {
            BinaryExpression binaryExpression = (BinaryExpression) where;
            Expression rightExpression = binaryExpression.getRightExpression() instanceof Parenthesis?((Parenthesis) binaryExpression.getRightExpression()).getExpression(): binaryExpression.getRightExpression();
            Expression leftExpression = binaryExpression.getLeftExpression() instanceof Parenthesis?((Parenthesis) binaryExpression.getLeftExpression()).getExpression(): binaryExpression.getLeftExpression();
            getWhereItem(rightExpression,leftExpression,tblNameSet);
        }else if(where instanceof Parenthesis){
            getWhereItem(((Parenthesis) where).getExpression(),tblNameSet);
        }else if(where instanceof InExpression){
            InExpression inExpression = (InExpression) where;
            Expression leftExpression = inExpression.getLeftExpression() instanceof Parenthesis?((Parenthesis) inExpression.getLeftExpression()).getExpression(): inExpression.getLeftExpression();
            getWhereItem(null,leftExpression,tblNameSet);
        }
    }

    /**
     * 獲取select裡面function裡面的欄位
     * @param function
     * @param selectItemSet
     * @return
     */
    private void getFunction(Function function, Set<String> selectItemSet){
        if(function.getParameters()==null || function.getParameters().getExpressions()==null){
            return;
        }
        List<Expression> list=function.getParameters().getExpressions();
        list.forEach(data->{
            if (data instanceof Function) {
                getFunction((Function)data,selectItemSet);
            }else if (data instanceof Column) {
                Column column = (Column) data;
                selectItemSet.add(column.getColumnName());
            }else{
                getWhereItem(data,selectItemSet);
            }
        });

    }

 

神碼片斷6:地獄式18層 if-else-for巢狀

 

像上面這種18層地獄式程式碼,看完是不是很想吐血!這裡篇幅限制問題僅展示其中一小段,這種神碼早些年我們舊專案中巨量存在。

這也是前人留下來寶貴的手筆,這種程式碼完全毫無設計,寫這程式碼的人不講武德,當時寫這些程式碼的作者因為一些原因離職了,我們當年系統上線後將近一年多的時間裡不敢去修改這神程式碼!自從接手那一天起,受盡各種艱難折磨,心中的苦只有自知,難受!

業務要增加需求吧,我們說這需求加不了,暫時搞不定,等系統重構版本出來之後再來提新需求。業務不理解天天詬病,天天叫罵,之前都可以的,怎麼現在就不行了。哈!哈!哈!

業務反映的BUG吧,我們硬得頭皮,只能再火坑裡面加點油,花大量時間去研讀作者的寫作意圖,然後小心奕奕做點區域性修改,大家每次改完BUG心裡,測試、釋出、上線心裡那個忐忑呀!

後面終止下大決心,對專案進行重構,經過兩次大版本重構之後,無數次的修正,終於把整個倉庫封存起當作紀念品!

確切地說我們是通過領域驅動設計方法,徹底解放了這種神碼,變廢為寶!具體怎麼做的可以參考另一篇《我是這麼玩領域驅動設計的DDD

 

總結

1、上面僅列了一小部分典型的神碼,還有很多沒貼出來;主要是經過多次重構設計之後,神碼慢慢消失在歷史長河之中。還希望各位看官們多總結多分享,並從中得一點啟示。

2、實際工作中神碼無處不在,在神碼世界的裡,你永遠有可能收穫意想不到的驚奇;為了減少工作中煩惱,為了美好的生活,寫程式碼時候多點思考和設計。

3、一個複雜的專案往往由團隊多人分工合作完的,團隊需要建一套嚴格的程式碼規範約束,老鳥們多做codeReview,並貫徹始終,否則團隊共同作業交付成果將大打折扣。

4、作為碼農自身需要不斷地加強武德修養,交付良品,拒絕交付廢品;最直接的目的就一條為了不讓後人鄙視和詬病就夠了。