@@ -43,57 +43,22 @@ public class ClickRecorderService {
4343 private final java .util .concurrent .ConcurrentHashMap <String , java .util .concurrent .atomic .AtomicLong > localDay = new java .util .concurrent .ConcurrentHashMap <>();
4444
4545 /**
46- * 记录点击事件(使用消息队列)
47- * 优先级:MQ > Local > Redis
46+ * 记录点击事件
47+ * 优先级:Local > Redis Snapshot > MQ
48+ *
49+ * 方案 C:本地内存 + Redis 快照 + 定期持久化
50+ * ├─ 第一阶段(10s):本地计数 → Redis 快照(冷备份)
51+ * └─ 第二阶段(60s):Redis 快照 → 数据库(正式记账)
4852 */
4953 @ Async
5054 public void recordClick (String shortCode ) {
51- // 模式1:消息队列模式(推荐,高可靠)
52- if ("mq" .equalsIgnoreCase (counterMode )) {
53- ClickMessage message = ClickMessage .builder ()
54- .shortCode (shortCode )
55- .timestamp (System .currentTimeMillis ())
56- .date (LocalDate .now ().toString ())
57- .build ();
58- try {
59- rabbitTemplate .convertAndSend (
60- RabbitMQConfig .CLICK_EXCHANGE ,
61- RabbitMQConfig .CLICK_ROUTING_KEY ,
62- message
63- );
64- log .debug ("[MQ] Click message sent: {}" , shortCode );
65- } catch (Exception e ) {
66- log .error ("[MQ ERROR] Failed to send click message for {}: {}" , shortCode , e .getMessage ());
67- // 降级到本地模式
68- localTotal .computeIfAbsent (shortCode , k -> new java .util .concurrent .atomic .AtomicLong ()).incrementAndGet ();
69- localDay .computeIfAbsent (shortCode , k -> new java .util .concurrent .atomic .AtomicLong ()).incrementAndGet ();
70- }
71- return ;
72- }
73-
74- // 模式2:本地内存模式
75- if ("local" .equalsIgnoreCase (counterMode )) {
76- localTotal .computeIfAbsent (shortCode , k -> new java .util .concurrent .atomic .AtomicLong ()).incrementAndGet ();
77- localDay .computeIfAbsent (shortCode , k -> new java .util .concurrent .atomic .AtomicLong ()).incrementAndGet ();
78- return ;
79- }
55+ // 所有模式都先写本地内存(最快)
56+ localTotal .computeIfAbsent (shortCode , k -> new java .util .concurrent .atomic .AtomicLong ())
57+ .incrementAndGet ();
58+ localDay .computeIfAbsent (shortCode , k -> new java .util .concurrent .atomic .AtomicLong ())
59+ .incrementAndGet ();
8060
81- // 模式3:Redis Pipeline 模式
82- String totalKey = "tf:clicks:total" ;
83- String dayKey = "tf:clicks:day:" + LocalDate .now ();
84- try {
85- // 使用Pipeline减少网络往返
86- redisTemplate .executePipelined (new org .springframework .data .redis .core .SessionCallback <Object >() {
87- @ Override
88- public Object execute (org .springframework .data .redis .core .RedisOperations operations ) {
89- operations .opsForHash ().increment (dayKey , shortCode , 1 );
90- operations .opsForHash ().increment (totalKey , shortCode , 1 );
91- return null ;
92- }
93- });
94- } catch (org .springframework .data .redis .RedisConnectionFailureException ex ) {
95- log .error ("redis connect failed: {}" , ex .getMessage ());
96- }
61+ log .debug ("[LOCAL] Click recorded: {}" , shortCode );
9762 }
9863
9964 @ Async
@@ -119,66 +84,93 @@ public void recordClickEvent(String shortCode, String referer, String ua, String
11984 }
12085
12186
122- @ Scheduled (fixedDelay = 2000 )
123- @ Transactional
124- public void flushCounters () {
125- if ("local" .equalsIgnoreCase (counterMode )) {
126- for (var e : localTotal .entrySet ()) {
127- String code = e .getKey ();
128- long delta = e .getValue ().getAndSet (0 );
129- if (delta > 0 ) { shortUrlRepository .incrementClickCountBy (code , delta ); }
87+ /**
88+ * 第一阶段:定期快照到 Redis(10 秒一次)
89+ * 作用:冷备份,防止服务宕机时丢失数据
90+ */
91+ @ Scheduled (fixedDelay = 10000 ) // 每 10 秒
92+ public void snapshotToRedis () {
93+ try {
94+ long startTime = System .currentTimeMillis ();
95+ int snapshotCount = 0 ;
96+
97+ // 快照总点击数
98+ for (var entry : localTotal .entrySet ()) {
99+ String code = entry .getKey ();
100+ long count = entry .getValue ().get (); // 只读,不重置
101+ if (count > 0 ) {
102+ redisTemplate .opsForHash ().put ("tf:click:snapshot:total" , code , String .valueOf (count ));
103+ snapshotCount ++;
104+ }
130105 }
131- for (var e : localDay .entrySet ()) {
132- String code = e .getKey ();
133- long delta = e .getValue ().getAndSet (0 );
134- if (delta > 0 ) { dailyClickRepo .incrementClickBy (code , delta ); }
106+
107+ // 快照每日点击数
108+ for (var entry : localDay .entrySet ()) {
109+ String code = entry .getKey ();
110+ long count = entry .getValue ().get (); // 只读,不重置
111+ if (count > 0 ) {
112+ String dayKey = "tf:click:snapshot:day:" + LocalDate .now ();
113+ redisTemplate .opsForHash ().put (dayKey , code , String .valueOf (count ));
114+ snapshotCount ++;
115+ }
116+ }
117+
118+ long duration = System .currentTimeMillis () - startTime ;
119+ if (snapshotCount > 0 ) {
120+ log .info ("[SNAPSHOT] Saved {} entries to Redis, duration={}ms" , snapshotCount , duration );
135121 }
136- return ;
122+ } catch (Exception e ) {
123+ log .error ("[SNAPSHOT ERROR] Failed to snapshot to Redis: {}" , e .getMessage (), e );
137124 }
138- String totalKey = "tf:clicks:total" ;
139- String dayKey = "tf:clicks:day:" + LocalDate .now ();
140- String tmpTotal = totalKey + ":flush:" + System .currentTimeMillis ();
141- String tmpDay = dayKey + ":flush:" + System .currentTimeMillis ();
142-
143- // 使用Pipeline批量重命名和读取,减少网络往返
125+ }
126+
127+ /**
128+ * 第二阶段:定期持久化到数据库(60 秒一次)
129+ * 作用:正式记账,将快照中的数据写入数据库
130+ */
131+ @ Scheduled (fixedDelay = 60000 ) // 每 60 秒
132+ @ Transactional
133+ public void syncFromRedisToDB () {
144134 try {
145- redisTemplate .executePipelined (new org .springframework .data .redis .core .SessionCallback <Object >() {
146- @ Override
147- public Object execute (org .springframework .data .redis .core .RedisOperations operations ) {
148- try { operations .rename (totalKey , tmpTotal ); } catch (Exception ignored ) {}
149- try { operations .rename (dayKey , tmpDay ); } catch (Exception ignored ) {}
150- return null ;
135+ long startTime = System .currentTimeMillis ();
136+ int totalUpdates = 0 ;
137+ int dailyUpdates = 0 ;
138+
139+ // 读取总点击快照并刷库
140+ java .util .Map <Object , Object > totalSnapshot = redisTemplate .opsForHash ()
141+ .entries ("tf:click:snapshot:total" );
142+ for (var entry : totalSnapshot .entrySet ()) {
143+ String code = String .valueOf (entry .getKey ());
144+ long count = toLong (entry .getValue ());
145+ if (count > 0 ) {
146+ shortUrlRepository .incrementClickCountBy (code , count );
147+ totalUpdates ++;
151148 }
152- });
153- } catch (Exception ignored ) {}
154-
155- java .util .Map <Object ,Object > total = java .util .Collections .emptyMap ();
156- java .util .Map <Object ,Object > day = java .util .Collections .emptyMap ();
157- try { total = redisTemplate .<Object ,Object >opsForHash ().entries (tmpTotal ); } catch (Exception ignored ) {}
158- try { day = redisTemplate .<Object ,Object >opsForHash ().entries (tmpDay ); } catch (Exception ignored ) {}
159-
160- for (var e : total .entrySet ()) {
161- String code = String .valueOf (e .getKey ());
162- long delta = toLong (e .getValue ());
163- if (delta > 0 ) { shortUrlRepository .incrementClickCountBy (code , delta ); }
164- }
165- for (var e : day .entrySet ()) {
166- String code = String .valueOf (e .getKey ());
167- long delta = toLong (e .getValue ());
168- if (delta > 0 ) { dailyClickRepo .incrementClickBy (code , delta ); }
169- }
170-
171- // 使用Pipeline批量删除临时key
172- try {
173- redisTemplate .executePipelined (new org .springframework .data .redis .core .SessionCallback <Object >() {
174- @ Override
175- public Object execute (org .springframework .data .redis .core .RedisOperations operations ) {
176- try { operations .delete (tmpTotal ); } catch (Exception ignored ) {}
177- try { operations .delete (tmpDay ); } catch (Exception ignored ) {}
178- return null ;
149+ }
150+
151+ // 读取每日点击快照并刷库
152+ String daySnapshotKey = "tf:click:snapshot:day:" + LocalDate .now ();
153+ java .util .Map <Object , Object > daySnapshot = redisTemplate .opsForHash ()
154+ .entries (daySnapshotKey );
155+ for (var entry : daySnapshot .entrySet ()) {
156+ String code = String .valueOf (entry .getKey ());
157+ long count = toLong (entry .getValue ());
158+ if (count > 0 ) {
159+ dailyClickRepo .incrementClickBy (code , count );
160+ dailyUpdates ++;
179161 }
180- });
181- } catch (Exception ignored ) {}
162+ }
163+
164+ // 刷库成功后,清空 Redis 快照(只清快照,本地内存继续累加)
165+ redisTemplate .delete ("tf:click:snapshot:total" );
166+ redisTemplate .delete (daySnapshotKey );
167+
168+ long duration = System .currentTimeMillis () - startTime ;
169+ log .info ("[SYNC] Persisted {} total clicks and {} daily clicks to DB, duration={}ms" ,
170+ totalUpdates , dailyUpdates , duration );
171+ } catch (Exception e ) {
172+ log .error ("[SYNC ERROR] Failed to sync from Redis to DB: {}" , e .getMessage (), e );
173+ }
182174 }
183175
184176 private long toLong (Object value ) {
0 commit comments