首先說明一下什麼是神碼?神碼就是神奇程式碼的意思(也是糟糕的意思),在這裡是為了表達引以為戒!
往事不堪回首!想當年(2017年)公司技術團隊新組建,系統新搭建。為了趕工期,一切以快速為目標,快速試錯,快速交付上線。專案管理規範被忽視和技術規範管控沒有及時跟上,工程師們交付的程式碼質量非常的糟糕。產生了不少嚴重的生產故障,後果比較嚴重,教訓慘痛!
當年雖是架構師崗位,但卻像是救火隊員。毫不誇張地說是,哪裡有生產故障問題,哪裡就得去救火!
原因有三:
因此,在那些年救火過程中,填了不少的坑;事後覆盤做了一些總結記錄,針對問題進行深入分析,找出根因,希望避免再次出現,從而得到一些寶貴經驗總結。
今天就來聊聊那些年給我曾經留下深刻教訓的程式碼片斷。在此不做批判,僅做反思與學習總結,也想讓各位看官得到一點啟發。下面我們來詳細看看有哪些神碼,到底有多神的程式碼?
神碼片斷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); 按理應該正常產生業務編碼,結果其實不然, 。
在高並行環境下可能會使用鎖失效。正常做法是要麼在事務外加鎖,要麼分解重寫需要控制事務程式碼塊。
鎖失效的原因是:由於Spring的AOP,會在update/save方法之前開啟事務,在這之後再加鎖,當鎖住的程式碼執行完成後,再提交事務,因此鎖程式碼塊執行是在事務之內執行的,可以推斷在程式碼塊執行完時事務還未提交;
其他執行緒進入鎖程式碼塊後,讀取的庫存資料不是最新的。
正確的做法要把最外層@Transactional 去掉。具體問題分析見《高並行環境下生成序列編碼重複問題分析》。
神碼片斷4:跨服務呼叫資料列表導致記憶體溢位
公告模組查詢邏輯非常簡單,通過查出公告列表,然後根據當前人所在區域、組織、品牌品類、崗位進行資料集合的過濾。
如果說所有業務資料和人員架構、許可權資料在同一個資料庫,幾個表join 一下結果很容易出來。
但現在的問題是,人員組織、許可權是獨立一個服務獨立一個資料庫;
業務資料公告又是獨立的服務和資料庫。也就是需要聚合兩個服務list集合資料匹配過濾之後再進行結果的展示。
在大回圈裡面去查詢部門、崗位、人員許可權判斷,然後通過遠端RPC介面去呼叫人員介面資料。
每個人登入就將產生近1000次介面呼叫和本地資料業務查詢組合,假定有1萬人在使用,那意味著有1千萬次遠端呼叫,10萬人存取,就有一億次呼叫,面對巨大的網路IO,誰能扛得住,巨坑呀!
在測試環境測試的時候,存取人數少,沒有測試出來,其實也是沒有進行大規模的壓測。
這一段程式碼上線後直接導致公告業務的服務應用記憶體溢位,服務死了好幾次。坑死人不償命!
階段性優化修改:
迴圈呼叫之前,先把一些資料準備好,而不是進入迴圈裡面去呼叫遠端查詢,減少跨機器的網路通訊時間和次數。優化改完之後,系統能正常執行,穩定下來了。
其實這種做法雖然階段優化解決了問題,勉強過關,但仍然有很多改進優化的空間。
跨多個服務間呼叫:聚合——>條件過濾——>展示
多個List之間的聚合、遍歷、拷貝,其實也消耗資源的,並行量高到一定程度,機器也承受不了。
優化方向轉向使用ES,在釋出公告即寫入的時候就做一些平鋪工作,把模板和許可權邏輯做一些對映處理,查詢的時候直接查詢ES,然後做一些簡單的標籤符號替換,改造之後實現10萬級別QPS,毫秒級響應。
ES改造後版本程式碼:
神碼片斷5:坑爹的型別判斷
這種程式碼本質上程式碼規範問題,也是開發人員的基本素質問題。雖然不是什麼致命問題,也產生正確的結果,但按照程式碼規範實在不應該這麼寫。
存在問題:
稍微修改一下,不然真的無法看。
引申知識點:
基本資料型別它們之間用「==」比較時,比較的是它們的值。
參照資料型別它們用「==」比較時,比較的是它們堆記憶體地址。
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,下面詳細描述一下這過程發生細節。
一、問題產生過程描述:
二、詳細排查問題記錄
詳細分析阿里雲服務紀錄檔
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多條),只能通過解析系統紀錄檔,一條條從紀錄檔中找出來去匹配修復。
三、遺留的問題
四、問題反思
五、強化解決
經過這一次慘痛教訓,決定在框架層做點功能,把不符合規則的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、作為碼農自身需要不斷地加強武德修養,交付良品,拒絕交付廢品;最直接的目的就一條為了不讓後人鄙視和詬病就夠了。