那些年那些神碼

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

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

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

原因有三:

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

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

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

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

 

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

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

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

 

神碼片斷2:不正確的使用Redis Key

各種條件組合在一起,作一個Redis Key,結果產生非常長的Key。

若這種大KEY太多,會比較佔用較多的內存資源,查詢效率也會變低,曾經這種又長又臭的串在我們系統出現過300多萬個,導致查詢效率下降,還曾經把服務搞死。

建議使用KEY Redis儘可能簡短,如果實在太長怎麼辦?需要進行加工處理,如用hashCode 或md5處理一下。

另外通常不建議用"_"連接而是用“:”,冒號有一個好處有可以自動分目錄結構,查詢定位比較方便。重新改造一下代碼。

下面還有一些曾經出現過不好正確的redis使用方法

(1)沒有過期時間

看下面代碼,是通用工具類方法!這樣調用者寫入redis之後就,key和value一直存在,久而久之redis就跑不動,畢竟空間靠內存撐着,空間是非常有限的。況且實際業務當中99%以上是需要有有效期,也即需要有設置過期時間的。

普通緩存放入,沒有過期時間,是一種不好的實踐! 

(2)使用“*”星號模糊匹配

這種Key出現太多,會嚴重拖跨redis服務,redis查詢這種key時候需要進行全表掃描,性能急速下降,所這種KEY方式慎用,最好不要用。

(3)把太多內容存入Redis

Redis存儲容量有限的,在實際使用過程中,建議不要存入太大內容塊,要控制一下。像這個KEY:DELIVERYEXCEPTIONMSG_LIST 內容有1.5GB,節點分片2GB內存,就喫掉了80%。

以上這些不正確使用方式,都曾經讓我們喫過苦頭,讓redis崩過幾次!所以請正確使用Redis,否則將會給你帶來麻煩。

歸根到底主要還是代碼規範與質量控制不到位,開發人員不夠自覺,寫代碼時隨意性比強。

對於上述這些問題,我們可以統一封裝一個Redis操作工具類,讓開發人員直接調用。免得亂用帶來不必要的問題。

如下面調用示例所示,RedisOpsProvider工具類已經封了Redis所有的基本操作,如果調用者不帶過期時間,則默認一個相對經驗過期時間如1天。

 

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

在這裏代碼片斷中使用了Spring @Transactional 事務註解。

這一段代碼裏有三個操作:

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

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

第三遠程調用一外部接口sendCustomerToWeChat(customer);

第一和第二個使用事務可以實現事務一致性,但第三用了一個異步線程,同時也跨服務的遠程調用

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

這裏事務是不能保證數據一致性的。

 

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

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

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

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

其他線程進入鎖代碼塊後,讀取的庫存數據不是最新的。

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

 

神碼片斷5:跨服務調用數據列表導致內存溢出

 

公告列表查詢邏輯非常簡單,通過查出公告數據列表,再根據當前人所在的區域、組織、品牌品類、崗位進行數據集合的過濾。早期兩個數據在同一個數據庫上,用同一個服務,通過SQL 條件進行查詢過濾,並不會有什麼問題。

但後面微服務拆分之後,公告業務數據與人員架構分離成兩個獨立的應用服務,兩個數據庫。人員組織、權限是獨立一個數據庫,獨立一個應用服務;公告業務數據又是獨立的服務和數據庫。

現在查詢查詢也跨多個服務間聚合才能展示最後的結果,也就是需要聚合兩個服務list集合數據匹配過濾之後再進行結果的展示。

在大循環裏面去查詢部門、崗位、人員權限判斷,然後通過遠程RPC接口去調用人員接口數據。

每個人登錄就將產生近1000次接口調用和本地數據業務查詢組合,假定有1萬人在使用,那意味着有1千萬次遠程調用,10萬人訪問,就有一億次調用,面對巨大的網絡IO,誰能扛得住,巨坑呀!

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

這一段代碼上線後直接導致公告業務的服務應用內存溢出,服務死了好幾次。坑死人不償命!

階段性優化修改:

循環調用之前,先把一些數據準備好,而不是進入循環裏面去調用遠程查詢,減少跨機器的網絡通訊時間和次數。優化改完之後,系統能正常運行,穩定下來了。

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

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

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

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

ES改造後版本代碼:

 

神碼片斷6:坑爹的類型判斷

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

存在問題:

  • 字符串比較不要用"=="而是用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引入的工具類)

 

神碼片斷7:萬惡的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);
            }
        });

    }

 

神碼片斷7:地獄式18層 if-else-for嵌套

 

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

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

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

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

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

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

 

總結

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

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

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

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

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章