《花100塊做個摸魚小網站! 》第九篇—我的小網站被攻擊了!

sum墨發表於2024-11-22

⭐️基礎連結導航⭐️

伺服器 → ☁️ 阿里雲活動地址

看樣例 → 🐟 摸魚小網站地址

學程式碼 → 💻 原始碼庫地址

一、前言

大家好呀,我是summo,最近不是被裁員了嘛,找工作又難找,老是心煩意亂的,也導致了一個多月斷更,不好意思哈。雖然工作沒了,但是小網站的故事卻一直在發生,比如阿里雲RDS到期導致我的小網站崩了一個月(已經修復);比如我重構了一下設計和程式碼(程式碼已上傳Gitee);比如有些“好心人”刷我的介面,給我提高訪問量(本篇會講的故事)... ...

還有一些更有意思的,比如濟南有位老闆看中了我的小網站希望我可以根據他的一些需求進行二次開發(一期已經做好上線了);比如透過這個小網站讓我賺到1千多的外快(意外之喜)等等。

工作是一時的,興趣是一輩子的,無論如何,從今天開始,我會繼續更新小網站的故事,這一篇主題:我的小網站被攻擊了!

二、被攻擊的過程

在我小網站的右側有一塊訪問統計的展示,顯示著今日PV,今天UV、總PV和總UV(PV:頁面瀏覽量,訪問一次我加一次;UV:獨立訪客數,一個IP我算一個),如下圖:

這是2024-11-22截的圖,總UV才2W多,但是PV已經8W多了,相當於每個使用者平均訪問了小網站4次,雖然看起來也不算很離譜,但實際上有幾個“好心人”單獨就貢獻了1.5W次的訪問,如下圖:

至於他們為什麼攻擊我,跟我也是有很大關係,我曾經在部落格園的快閃記憶體“大放厥詞”:

三、小網站的統計規則

之前我們在第七篇—誰訪問了我們的網站?中介紹了小網站的統計邏輯,核心邏輯就是加了一個@VisitLog註解,使用切面的方式記錄訪問的IP地址。然後將這個註解放在了IndexController.java的index方法上,如下:

@Controller
public class IndexController {

    @GetMapping("/")
    @VisitLog
    public String index(HttpServletRequest request) {
        if (isFromMobile(request)) {
            return "mp/index";
        } else {
            return "web/index";
        }
    }
}

也就是說,只要你們瘋狂呼叫https://sbmy.fun,就會不斷地增加訪問記錄。正如我前面所說的我並沒有使用CDN、OSS這種按按流量計費的元件,無論你們怎麼刷都是不會給我造成資損的,但是有一個問題我不得不處理一下:
前端的資源來自於我的應用,資源包括js、css、圖片、字型等檔案,這些資源還都不小,尤其是chunk-vendors.js,體積達到了1.2M,如下圖:

阿里雲的ECS公網IP頻寬預設只有3M,也就是說小網站同時有三個人訪問就會出現訪問效率問題,問題其實也不大,瀏覽器只要訪問一次這些資源就會將它們快取下來,後續再重新整理也不會很慢。但是如果被攻擊了呢,那就麻煩了,正常使用者想訪問都不行了... ... 所以我必須得想個法子了。

四、限流大法好

像我們這種小網站,流量也不大,被攻擊了好像也沒啥意義,有些攻擊純粹就是兄弟們開的玩笑,搞著玩的。但既然兄弟們出招了,我這邊也得想個法子解決不是?其實很簡單,搞個滑動視窗限流就可以了。

我先講一下什麼是限流,王者榮耀大家都玩過吧,裡面的英雄都有一個攻擊間隔,當我們連續的點選普通攻擊的時候,英雄的攻速並不會隨著我們點選的越快而更快的攻擊。這個就是限流,英雄會按照自身攻速的係數執行攻擊,我們點的再快也沒用。

1、滑動視窗限流

先上一張流程圖,幫助大家理解原理

2、原理說明

從圖上可以看到時間建立是一種滑動的方式前進, 滑動視窗限流策略能夠顯著減少臨界問題的影響,但並不能完全消除它。滑動視窗透過跟蹤和限制在一個連續的時間視窗內的請求來工作。與簡單的計數器方法不同,它不是在視窗結束時突然重置計數器,而是根據時間的推移逐漸地移除視窗中的舊請求,新增新的請求。
舉個例子:假設時間視窗為10s,請求限制為3,第一次請求在10:00:00發起,第二次在10:00:05發起,第三次10:00:11發起,那麼計數器策略的下一個視窗開始時間是10:00:11,而滑動視窗是10:00:05。所以這也是滑動視窗為什麼可以減少臨界問題的影響,但並不能完全消除它的原因。

如果大家想詳細瞭解一些常用的限流演算法,可以看我這篇文章:《最佳化介面設計的思路》系列:第七篇—介面限流策略

3、程式碼實現

SlidingWindowRateLimit.java

package com.summo.demo.config.limitstrategy.slidingwindow;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface SlidingWindowRateLimit {
    /**
     * 請求的數量
     *
     * @return
     */
    int requests();

    /**
     * 時間視窗,單位為秒
     *
     * @return
     */
    int timeWindow();
}

SlidingWindowRateLimitAspect.java

package com.summo.sbmy.common.limit.slidingwindow;

import com.summo.sbmy.common.util.HttpContextUtil;
import com.summo.sbmy.common.util.IpUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;

@Slf4j
@Aspect
@Component
@Order(5)
public class SlidingWindowRateLimitAspect {
    /**
     * 使用 ConcurrentHashMap 儲存每個方法的請求時間戳佇列
     */
    private final ConcurrentHashMap<String, ConcurrentHashMap<String, ConcurrentLinkedQueue<Long>>> METHOD_IP_REQUEST_TIMES_MAP = new ConcurrentHashMap<>();

    @Around("@annotation(com.summo.sbmy.common.limit.slidingwindow.SlidingWindowRateLimit)")
    public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        // 獲取客戶端IP地址
        String clientIp = IpUtil.getIpAddr(HttpContextUtil.getHttpServletRequest());


        SlidingWindowRateLimit rateLimit = method.getAnnotation(SlidingWindowRateLimit.class);
        // 允許的最大請求數
        int requests = rateLimit.requests();
        // 滑動視窗的大小(秒)
        int timeWindow = rateLimit.timeWindow();

        // 獲取方法名稱字串
        String methodName = method.toString();
        // 如果不存在當前方法和IP的請求時間戳佇列,則初始化一個新的佇列
        ConcurrentHashMap<String, ConcurrentLinkedQueue<Long>> ipRequestTimesMap = METHOD_IP_REQUEST_TIMES_MAP.computeIfAbsent(methodName, k -> new ConcurrentHashMap<>());
        ConcurrentLinkedQueue<Long> requestTimes = ipRequestTimesMap.computeIfAbsent(clientIp, k -> new ConcurrentLinkedQueue<>());

        // 當前時間
        long currentTime = System.currentTimeMillis();
        // 計算時間視窗的開始時間戳
        long thresholdTime = currentTime - TimeUnit.SECONDS.toMillis(timeWindow);

        // 這一段程式碼是滑動視窗限流演算法中的關鍵部分,其功能是移除當前滑動視窗之前的請求時間戳。這樣做是為了確保視窗內只保留最近時間段內的請求記錄。
        // requestTimes.isEmpty() 是檢查佇列是否為空的條件。如果佇列為空,則意味著沒有任何請求記錄,不需要進行移除操作。
        // requestTimes.peek() < thresholdTime 是檢查佇列頭部的時間戳是否早於滑動視窗的開始時間。如果是,說明這個時間戳已經不在當前的時間視窗內,應當被移除。
        while (!requestTimes.isEmpty() && requestTimes.peek() < thresholdTime) {
            // 移除佇列頭部的過期時間戳
            requestTimes.poll();
        }

        // 檢查當前時間視窗內的請求次數是否超過限制
        if (requestTimes.size() < requests) {
            // 未超過限制,記錄當前請求時間
            requestTimes.add(currentTime);
            return joinPoint.proceed();
        } else {
            // 超過限制,丟擲限流異常
            throw new RuntimeException("Too many requests, please try again later.");
        }
    }
}

使用方式


@Controller
public class IndexController {
    @GetMapping("/")
    @VisitLog
    @SlidingWindowRateLimit(requests = 2, timeWindow = 2)
    public String index(HttpServletRequest request) {
        if (isFromMobile(request)) {
            return "mp/index";
        } else {
            return "web/index";
        }
    }
}

五、小結一下

上面的限流使用的是ConcurrentHashMap來儲存每個方法的請求時間戳佇列,適用於單機,如果是分散式的環境則可以換成Redis。透過在資源介面上加一個限流的方式我們可以防止單個IP刷爆我們的index介面,防止頻寬打滿,我試了下應該是用的,就是不知道實戰如何。

相關文章