黑馬點評的項目總結
主要就黑馬點評項目里面的一些比較重要部分的一次總結,方便以后做復習。
基于Session實現短信登錄
短信驗證碼登錄
這部分使用常規的session來存儲用戶的登錄狀態,其中短信發送采取邏輯形式,并不配置云服務驗證碼功能。
/*** 發送驗證碼*/@Overridepublic Result sendCode(String phone, HttpSession session) {// 1、判斷手機號是否合法if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手機號格式不正確");}// 2、手機號合法,生成驗證碼,并保存到Session中String code = RandomUtil.randomNumbers(6);session.setAttribute(SystemConstants.VERIFY_CODE, code);// 3、發送驗證碼log.info("驗證碼:{}", code);return Result.ok();}/*** 用戶登錄*/@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {String phone = loginForm.getPhone();String code = loginForm.getCode();// 1、判斷手機號是否合法if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手機號格式不正確");}// 2、判斷驗證碼是否正確String sessionCode = (String) session.getAttribute(LOGIN_CODE);if (code == null || !code.equals(sessionCode)) {return Result.fail("驗證碼不正確");}// 3、判斷手機號是否是已存在的用戶User user = this.getOne(new LambdaQueryWrapper<User>().eq(User::getPassword, phone));if (Objects.isNull(user)) {// 用戶不存在,需要注冊user = createUserWithPhone(phone);}// 4、保存用戶信息到Session中,便于后面邏輯的判斷(比如登錄判斷、隨時取用戶信息,減少對數據庫的查詢)session.setAttribute(LOGIN_USER, user); // userreturn Result.ok();}/*** 根據手機號創建用戶*/private User createUserWithPhone(String phone) {User user = new User();user.setPhone(phone);user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));this.save(user);return user;}
登錄攔截器配置
在本項目中,一些功能需要進行登錄才能夠使用,一些功能則可以直接訪問。本項目采用攔截器的形式對有無用戶登錄進行判斷,并及時將信息存儲到Threadlocal里面
public class LoginInterceptor implements HandlerInterceptor {/*** 前置攔截器,用于判斷用戶是否登錄*/@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {HttpSession session = request.getSession();// 1、判斷用戶是否存在User user = (User) session.getAttribute(LOGIN_USER);if (Objects.isNull(user)){// 用戶不存在,直接攔截response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);return false;}// 2、用戶存在,則將用戶信息保存到ThreadLocal中,方便后續邏輯處理// 比如:方便獲取和使用用戶信息,session獲取用戶信息是具有侵入性的UserHolder.saveUser(user);return true;}
}
同時需要配置相應攔截的url
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 添加登錄攔截器registry.addInterceptor(new LoginInterceptor())// 設置放行請求.excludePathPatterns("/user/code","/user/login","/blog/hot","/shop/**","/shop-type/**","/upload/**","/voucher/**");}
}
數據脫敏
對返回用戶信息中的敏感字段進行去除
@Data
public class UserDTO {private Long id;private String nickName;private String icon;
}
使用這個DTO替換上面的User實體類即可
Session引發的問題
- 由于不同的Tomcat并不共享session信息,當請求切換到不同的服務器時導致信息丟失問題(例子:nginx做復雜均衡)
- 同時存儲的在服務端session很多的時候導致需要的內存變多
常見解決方案(session共享)
這里采用redis來實現共享功能
/*** 發送驗證碼** @param phone* @param session* @return*/@Overridepublic Result sendCode(String phone, HttpSession session) {if(RegexUtils.isPhoneInvalid(phone)){//手機號格式錯誤return Result.fail("手機號格式錯誤");}String code = RandomUtil.randomNumbers(6);// 保存到session//session.setAttribute("code", code);stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);System.out.println("code = " + code);return Result.ok();}/*** 登錄功能*/@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {// 1. 校驗手機號return Result.fail("手機號格式錯誤");}// 校驗驗證碼String code = loginForm.getCode();String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);if(cacheCode == null || !cacheCode.equals(code)){return Result.fail("驗證碼錯誤");}// 查詢User user = this.query().eq("phone", phone).one();if(user == null){// 不存在則創建user = createUserWithPhone(phone);}// 保存用戶信息到session//session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));// 保存到redis中String token = UUID.randomUUID().toString();UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);// 將對象中字段全部轉成string類型,StringRedisTemplate只能存字符串類型的數據Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue) ->String.valueOf(fieldValue)));String tokenKey = LOGIN_USER_KEY + token;stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);return Result.ok(token);}
配置刷新token有效期的攔截器
用戶在訪問頁面的時候,就應該刷新token的有效期。這個時候攔截登錄信息的攔截器顯得不夠。這個時候需要設置另外一個攔截器,只要在登錄狀態(查redis的共享session查到)訪問頁面就及時刷新token的到期時間。
登錄攔截器
/*** 在執行Controller之前進行攔截,判斷用戶是否登錄* @param request* @param response* @param handler* @return* @throws Exception*/@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if(UserHolder.getUser() == null){response.setStatus(401);return false;}return true;}
刷新token攔截器
public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String token = request.getHeader("authorization");// 判斷請求頭是否為空if(StrUtil.isBlank(token)){return true;}Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);if(entries.isEmpty()){response.setStatus(401);return true;}// 將redis中的用戶信息轉換為UserDTOUserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false);// 將用戶信息保存到ThreadLocal中UserHolder.saveUser(userDTO);// 刷新stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}/*** 在請求處理完成后執行刪除* @param request* @param response* @param handler* @param ex* @throws Exception*/@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
同時在SpringMVC中注冊,配置相應的實行順序。
緩存應用
使用緩存能夠大大的提高讀寫性能,其中數據只做臨時存放。
緩存商鋪信息
****
/*** 根據id查詢商鋪數據** @param id* @return*/
@Override
public Result queryById(Long id) {String key = CACHE_SHOP_KEY + id;// 1、從Redis中查詢店鋪數據String shopJson = stringRedisTemplate.opsForValue().get(key);Shop shop = null;// 2、判斷緩存是否命中if (StrUtil.isNotBlank(shopJson)) {// 2.1 緩存命中,直接返回店鋪數據shop = JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}// 2.2 緩存未命中,從數據庫中查詢店鋪數據shop = this.getById(id);// 4、判斷數據庫是否存在店鋪數據if (Objects.isNull(shop)) {// 4.1 數據庫中不存在,返回失敗信息return Result.fail("店鋪不存在");}// 4.2 數據庫中存在,寫入Redis,并返回店鋪數據stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));return Result.ok(shop);
}
數據一致性問題
緩存的使用會帶來,redis中的數據與數據中的數據不同步,為了同步需要采用相應的更新策略。
- 常見策略
- 超時剔除:即手動設置TTL,到期后redis自動進行刪除緩存
- 主動更新:手動編碼的方式對緩存進行更新,修改數據庫的同時修改緩存。
- 雙寫方案:緩存調用者在更新完數據庫后在更新緩存
- 讀取(Read):當需要讀取數據時,首先檢查緩存是否存在該數據。如果緩存中存在,直接返回緩存中的數據。如果緩存中不存在,則從底層數據存儲(如數據庫)中獲取數據,并將數據存儲到緩存中,以便以后的讀取操作可以更快地訪問該數據。
- 寫入(Write):當進行數據寫入操作時,首先更新底層數據存儲中的數據。然后,根據具體情況,可以選擇直接更新緩存中的數據(使緩存與底層數據存儲保持同步),或者是簡單地將緩存中與修改數據相關的條目標記為無效狀態(緩存失效),以便下一次讀取時重新加載最新數據
- 使用雙寫方案注意事項
- 刪除緩存還是更新緩存
- 刪除緩存(√) :更新數據時更新數據庫并刪除緩存,查詢時在更新緩存,無效寫操作較少
- 先操作緩存還是先操作數據庫?
- 先操作緩存:先刪除緩存,在更新數據庫(不推薦)
- 原因:在更新數據庫時,由于數據庫寫操作耗時大,可能出現期間有其他線程來讀緩存,這個時候緩存實際被刪了,會出現緩存穿透 (發生的概率大)
- 先操作數據庫:先更新數據庫,在刪除緩存(推薦)
- 當一個線程查詢緩存未命中時,他在查詢數據后,要將數據寫入緩存。另一個線程去更新數據庫,并刪除緩存。這個時候寫入的緩存會出現臟數據的問題。但實際上查詢數據庫與寫入緩存的速度明顯大于數據庫更新加刪除緩存。這個事件發生的概率低。
- 刪除緩存(√) :更新數據時更新數據庫并刪除緩存,查詢時在更新緩存,無效寫操作較少
- 刪除緩存還是更新緩存
- 雙寫方案:緩存調用者在更新完數據庫后在更新緩存
緩存主動更新策略的實現
/*** 根據id查詢商鋪數據(查詢時,重建緩存)** @param id* @return*/
@Override
public Result queryById(Long id) {String key = CACHE_SHOP_KEY + id;// 1、從Redis中查詢店鋪數據String shopJson = stringRedisTemplate.opsForValue().get(key);Shop shop = null;// 2、判斷緩存是否命中if (StrUtil.isNotBlank(shopJson)) {// 2.1 緩存命中,直接返回店鋪數據shop = JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}// 2.2 緩存未命中,從數據庫中查詢店鋪數據shop = this.getById(id);// 4、判斷數據庫是否存在店鋪數據if (Objects.isNull(shop)) {// 4.1 數據庫中不存在,返回失敗信息return Result.fail("店鋪不存在");}// 4.2 數據庫中存在,重建緩存,并返回店鋪數據stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);
}/*** 更新商鋪數據(更新時,更新數據庫,刪除緩存)** @param shop* @return*/
@Transactional
@Override
public Result updateShop(Shop shop) {// 1、更新數據庫中的店鋪數據boolean flag = this.updateById(shop);if (!flag){// 緩存更新失敗,拋出異常,事務回滾throw new RuntimeException("數據庫更新失敗");}// 2、刪除緩存f = stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());if (!f){// 緩存刪除失敗,拋出異常,事務回滾throw new RuntimeException("緩存刪除失敗");}return Result.ok();
}
緩存穿透、緩存雪崩、緩存擊穿解決方案參考另一篇博客
優惠券秒殺
全局唯一ID
-
單純使用數據庫自增ID存在的問題
- ID規律性太明顯了
- 數據量大時,進行分表后ID不能相同,需要保證唯一性
-
分布式ID的實現方式:
-
UUID
-
Redis自增
-
數據庫自增
-
snowflake算法(雪花算法)
-
全局ID生成器(自定義)
ID的組成部分:符號位:1bit,永遠為0
時間戳:31bit,以秒為單位,可以使用69年
序列號:32bit,秒內的計數器,支持每秒產生2^32個不同ID
@Component
public class RedisIdWorker {/*** 開始時間戳*/private static final long BEGIN_TIMESTAMP = 1640995200L;/*** 序列號的位數*/private static final int COUNT_BITS = 32;private StringRedisTemplate stringRedisTemplate;public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}public long nextId(String keyPrefix) {// 1.生成時間戳LocalDateTime now = LocalDateTime.now();long nowSecond = now.toEpochSecond(ZoneOffset.UTC);long timestamp = nowSecond - BEGIN_TIMESTAMP;// 2.生成序列號,需要總體自增// 2.1.獲取當前日期,精確到天String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));// 2.2.自增長long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);// 3.拼接并返回return timestamp << COUNT_BITS | count;}
}
優惠券秒殺
解決庫存超賣問題
由于多線程情況下,會導致多個線程都查詢庫存充足,同時進行扣減造成超賣現象。
超賣常見解決方案
- 悲觀鎖 認為線程安全問題一定會發生,因此操作數據庫之前都需要先獲取鎖,確保線程串行執行。常見的悲觀鎖有:
synchronized
、lock
- 樂觀鎖,認為線程安全問題不一定發生,因此不加鎖,只會在更新數據庫的時候去判斷有沒有其它線程對數據進行修改,如果沒有修改則認為是安全的,直接更新數據庫中的數據即可,如果修改了則說明不安全,直接拋異常或者等待重試。常見的實現方式有:版本號法、CAS操作
- 應用場景
- 悲觀鎖:寫入操作較多和沖突頻發的場景適合
- 樂觀鎖:適合讀取操作多、沖突較少的場景
拓展CAS
樂觀鎖解決超賣問題
實現方式一:版本號法
這種方式需要為表中新增一個字段version,在執行庫存扣減操作時將版本號加一并對比當前此時的版本與之前查到的版本是否相同。
實現方式二:CAS法
這種方式與之前的版本號方式類似。
悲觀鎖解決超賣問題
實現細節:
- 鎖的范圍要縮小。盡量不要選擇鎖方法
- 鎖的值要不變。所以不能鎖引用對象,這里選擇轉化為String對象后,使用
intern()
方法從常量池中尋找與當前字符串一直的字符對象。 - 需要鎖住整個事務而不是事務的代碼。因為鎖事務內的代碼還是會導致其他線程進入事務,如果事務未提交,鎖釋放,仍然存在超賣問題。
- Spring的注解想要事務生效,必須使用動態代理。Service中一個方法中調用另一個方法,另一個方法使用了事務,此時會導致
@Transactional
失效,所以我們需要創建一個代理對象,使用代理對象來調用方法。
[事務失效參考文章](spring 事務失效的 12 種場景_spring 截獲duplicatekeyexception 不拋異常-CSDN博客)
集群下一人一單超賣問題
synchronized
是本地鎖,對應著每一個JVM,同時不能進行跨JVM進行上鎖。所以在分布式情況下這種鎖失效。
分布式鎖
本項目采用redis來實現分布式鎖
public class SimpleRedisLock implements Lock {/*** RedisTemplate*/private StringRedisTemplate stringRedisTemplate;/*** 鎖的名稱*/private String name;public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {this.stringRedisTemplate = stringRedisTemplate;this.name = name;}/*** 獲取鎖** @param timeoutSec 超時時間* @return*/@Overridepublic boolean tryLock(long timeoutSec) {String id = Thread.currentThread().getId() + "";// SET lock:name id EX timeoutSec NXBoolean result = stringRedisTemplate.opsForValue().setIfAbsent("lock:" + name, id, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(result);}/*** 釋放鎖*/@Overridepublic void unlock() {stringRedisTemplate.delete("lock:" + name);}
}
分布式鎖優化
問題:持有鎖的線程在鎖的內部出現了阻塞,導致他的鎖自動釋放,這時其他線程,線程2來嘗試獲得鎖,就拿到了這把鎖,然后線程2在持有鎖執行過程中,線程1反應過來,繼續執行,而線程1執行過程中,走到了刪除鎖邏輯,此時就會把本應該屬于線程2的鎖進行刪除,這就是誤刪別人鎖的情況。
解決方案
在釋放鎖時需要判斷鎖是否時自己的。
/*** 獲取鎖** @param timeoutSec 超時時間* @return*/@Overridepublic boolean tryLock(long timeoutSec) {String threadId = ID_PREFIX + Thread.currentThread().getId() + "";// SET lock:name id EX timeoutSec NXBoolean result = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(result);}/*** 釋放鎖*/@Overridepublic void unlock() {// 判斷 鎖的線程標識 是否與 當前線程一致String currentThreadFlag = ID_PREFIX + Thread.currentThread().getId();String redisThreadFlag = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);if (currentThreadFlag != null || currentThreadFlag.equals(redisThreadFlag)) {// 一致,說明當前的鎖就是當前線程的鎖,可以直接釋放stringRedisTemplate.delete(KEY_PREFIX + name);}// 不一致,不能釋放}
分布式鎖的原子性問題
線程1現在持有鎖之后,在執行業務邏輯過程中,他正準備刪除鎖,而且已經走到了條件判斷的過程中,比如他已經拿到了當前這把鎖確實是屬于他自己的,正準備刪除鎖,但是此時他的鎖到期了,那么此時線程2進來,但是線程1他會接著往后執行,當他卡頓結束后,他直接就會執行刪除鎖那行代碼,相當于條件判斷并沒有起到作用,這就是刪鎖時的原子性問題,之所以有這個問題,是因為線程1的拿鎖,比鎖,刪鎖,實際上并不是原子性的
Lua腳本解決多條命令原子性問題
因為redis是單線程執行的(早期是這樣的)
此時的執行釋放鎖的流程:
- 獲得鎖的線程標識
- 判斷當前前程與標識是否一致
- 如果一直則釋放鎖
- 如果不一致則刪除鎖
這樣就把這些操作變成一氣呵成的原子操作
-- 這里的 KEYS[1] 就是鎖的key,這里的ARGV[1] 就是當前線程標示
-- 獲取鎖中的標示,判斷是否與當前線程標示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then-- 一致,則刪除鎖return redis.call('DEL', KEYS[1])
end
-- 不一致,則直接返回
return 0
java中調用代碼
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;static {UNLOCK_SCRIPT = new DefaultRedisScript<>();UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));UNLOCK_SCRIPT.setResultType(Long.class);}public void unlock() {// 調用lua腳本stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(KEY_PREFIX + name),ID_PREFIX + Thread.currentThread().getId());
}
分布式鎖redission
基于setnx簡單實現的分布式鎖存在如下問題
為了進行優化,使用市面上成熟的框架redisson.
詳細了解參考其他的博客
異步秒殺
在這種流程下,各個流程之間是同步執行,在時間上面消耗較大
優化方案:
把耗時較短的邏輯判斷放在redis中實現,如判斷庫存、校驗一人一單操作。
現在整體的思路就是:在用戶下單后,只需要判斷庫存和是否一人一單即可,為了保證原子操作,同樣使用lua腳本實現這方面的邏輯。只要符合就可以下單,之后將一些優惠券id、用戶id、訂單id放入阻塞隊列中,然后交給異步線程實現數據庫中的操作。
lua代碼
-- 優惠券id
local voucherId = ARGV[1];
-- 用戶id
local userId = ARGV[2];-- 庫存的key
local stockKey = 'seckill:stock:' .. voucherId;
-- 訂單key
local orderKey = 'seckill:order:' .. voucherId;-- 判斷庫存是否充足 get stockKey > 0 ?
local stock = redis.call('GET', stockKey);
if (tonumber(stock) <= 0) then-- 庫存不足,返回1return 1;
end-- 庫存充足,判斷用戶是否已經下過單 SISMEMBER orderKey userId
if (redis.call('SISMEMBER', orderKey, userId) == 1) then-- 用戶已下單,返回2return 2;
end-- 庫存充足,沒有下過單,扣庫存、下單
redis.call('INCRBY', stockKey, -1);
redis.call('SADD', orderKey, userId);
-- 返回0,標識下單成功
return 0;
java代碼
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Resourceprivate StringRedisTemplate stringRedisTemplate;@Resourceprivate RedissonClient redissonClient;/*** 當前類初始化完畢就立馬執行該方法*/@PostConstructprivate void init() {// 執行線程任務SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());}/*** 存儲訂單的阻塞隊列*/private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);/*** 線程池*/private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();/*** 線程任務: 不斷從阻塞隊列中獲取訂單*/private class VoucherOrderHandler implements Runnable {@Overridepublic void run() {while (true) {// 從阻塞隊列中獲取訂單信息,并創建訂單try {VoucherOrder voucherOrder = orderTasks.take();handleVoucherOrder(voucherOrder);} catch (Exception e) {log.error("處理訂單異常", e);}}}}/*** 創建訂單** @param voucherOrder*/private void handleVoucherOrder(VoucherOrder voucherOrder) {Long userId = voucherOrder.getUserId();RLock lock = redissonClient.getLock(RedisConstants.LOCK_ORDER_KEY + userId);boolean isLock = lock.tryLock();if (!isLock) {// 索取鎖失敗,重試或者直接拋異常(這個業務是一人一單,所以直接返回失敗信息)log.error("一人只能下一單");return;}try {// 創建訂單(使用代理對象調用,是為了確保事務生效)proxy.createVoucherOrder(voucherOrder);} finally {lock.unlock();}}/*** 加載 判斷秒殺券庫存是否充足 并且 判斷用戶是否已下單 的Lua腳本*/private static final DefaultRedisScript<Long> SECKILL_SCRIPT;static {SECKILL_SCRIPT = new DefaultRedisScript<>();SECKILL_SCRIPT.setLocation(new ClassPathResource("lua/seckill.lua"));SECKILL_SCRIPT.setResultType(Long.class);}/*** VoucherOrderServiceImpl類的代理對象* 將代理對象的作用域進行提升,方面子線程取用*/private IVoucherOrderService proxy;/*** 搶購秒殺券** @param voucherId* @return*/@Transactional@Overridepublic Result seckillVoucher(Long voucherId) {// 1、執行Lua腳本,判斷用戶是否具有秒殺資格Long result = null;try {result = stringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(),voucherId.toString(),ThreadLocalUtls.getUser().getId().toString());} catch (Exception e) {log.error("Lua腳本執行失敗");throw new RuntimeException(e);}if (result != null && !result.equals(0L)) {// result為1表示庫存不足,result為2表示用戶已下單int r = result.intValue();return Result.fail(r == 2 ? "不能重復下單" : "庫存不足");}// 2、result為0,用戶具有秒殺資格,將訂單保存到阻塞隊列中,實現異步下單long orderId = redisIdWorker.nextId(SECKILL_VOUCHER_ORDER);// 創建訂單VoucherOrder voucherOrder = new VoucherOrder();voucherOrder.setId(orderId);voucherOrder.setUserId(ThreadLocalUtls.getUser().getId());voucherOrder.setVoucherId(voucherId);// 將訂單保存到阻塞隊列中orderTasks.add(voucherOrder);// 索取鎖成功,創建代理對象,使用代理對象調用第三方事務方法, 防止事務失效IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();this.proxy = proxy;return Result.ok();}/*** 創建訂單** @param voucherOrder* @return*/@Transactional@Overridepublic void createVoucherOrder(VoucherOrder voucherOrder) {Long userId = voucherOrder.getUserId();Long voucherId = voucherOrder.getVoucherId();// 1、判斷當前用戶是否是第一單int count = this.count(new LambdaQueryWrapper<VoucherOrder>().eq(VoucherOrder::getUserId, userId));if (count >= 1) {// 當前用戶不是第一單log.error("當前用戶不是第一單");return;}// 2、用戶是第一單,可以下單,秒殺券庫存數量減一boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>().eq(SeckillVoucher::getVoucherId, voucherId).gt(SeckillVoucher::getStock, 0).setSql("stock = stock -1"));if (!flag) {throw new RuntimeException("秒殺券扣減失敗");}// 3、將訂單保存到數據庫flag = this.save(voucherOrder);if (!flag) {throw new RuntimeException("創建秒殺券訂單失敗");}}
}
消息隊列優化
前面我們使用 Java 自帶的阻塞隊列 BlockingQueue 實現消息隊列,這種方式存在以下幾個嚴重的弊端:
- 信息可靠性沒有保障,BlockingQueue 的消息是存儲在內存中的,無法進行持久化,一旦程序宕機或者發生異常,會直接導致消息丟失
- 消息容量有限,BlockingQueue 的容量有限,無法進行有效擴容,一旦達到最大容量限制,就會拋出OOM異常
可以使用成熟的消息隊列,如RabbitMQ、Kafka等。
相關資料:
RabbitMQ超詳細學習筆記(章節清晰+通俗易懂
實現黑馬點評中將消息隊列由Redis實現換為RabbitMQ實現
SortedSet實現點贊排行榜
相較于Set集合,SortedList有以下不同之處:
- 對于Set集合我們可以使用 isMember方法判斷用戶是否存在,對于SortedList我們可以使用ZSCORE方法判斷用戶是否存在
- Set集合沒有提供范圍查詢,無法獲排行榜前幾名的數據,SortedList可以使用ZRANGE方法實現范圍查詢
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {@Resourceprivate IUserService userService;@Resourceprivate StringRedisTemplate stringRedisTemplate;/*** 根據id查詢博客** @param id* @return*/@Overridepublic Result queryBlogById(Long id) {// 查詢博客信息Blog blog = this.getById(id);if (Objects.isNull(blog)) {return Result.fail("筆記不存在");}// 查詢blog相關的用戶信息queryUserByBlog(blog);// 判斷當前用戶是否點贊該博客isBlogLiked(blog);return Result.ok(blog);}/*** 判斷當前用戶是否點贊該博客*/private void isBlogLiked(Blog blog) {UserDTO user = ThreadLocalUtls.getUser();if (Objects.isNull(user)){// 當前用戶未登錄,無需查詢點贊return;}Long userId = user.getId();String key = BLOG_LIKED_KEY + blog.getId();Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());blog.setIsLike(Objects.nonNull(score));}/*** 查詢熱門博客** @param current* @return*/@Overridepublic Result queryHotBlog(Integer current) {// 根據用戶查詢Page<Blog> page = this.query().orderByDesc("liked").page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));// 獲取當前頁數據List<Blog> records = page.getRecords();// 查詢用戶records.forEach(blog -> {this.queryUserByBlog(blog);this.isBlogLiked(blog);});return Result.ok(records);}/*** 點贊** @param id* @return*/@Overridepublic Result likeBlog(Long id) {// 1、判斷用戶是否點贊Long userId = ThreadLocalUtls.getUser().getId();String key = BLOG_LIKED_KEY + id;// zscore key valueDouble score = stringRedisTemplate.opsForZSet().score(key, userId.toString());boolean result;if (score == null) {// 1.1 用戶未點贊,點贊數+1result = this.update(new LambdaUpdateWrapper<Blog>().eq(Blog::getId, id).setSql("liked = liked + 1"));if (result) {// 數據庫更新成功,更新緩存 zadd key value scorestringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());}} else {// 1.2 用戶已點贊,點贊數-1result = this.update(new LambdaUpdateWrapper<Blog>().eq(Blog::getId, id).setSql("liked = liked - 1"));if (result) {// 數據更新成功,更新緩存 zrem key valuestringRedisTemplate.opsForZSet().remove(key, userId.toString());}}return Result.ok();}/*** 查詢所有點贊博客的用戶** @param id* @return*/@Overridepublic Result queryBlogLikes(Long id) {// 查詢Top5的點贊用戶 zrange key 0 4Long userId = ThreadLocalUtls.getUser().getId();String key = BLOG_LIKED_KEY + id;Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);if (top5 == null || top5.isEmpty()) {return Result.ok(Collections.emptyList());}List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());List<UserDTO> userDTOList = userService.luserService.list(new LambdaQueryWrapper<User>().in(User::getId, ids).last("order by field (id," + idStr + ")")).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());return Result.ok(userDTOList);}/*** 查詢博客相關用戶信息** @param blog*/private void queryUserByBlog(Blog blog) {Long userId = blog.getUserId();User user = userService.getById(userId);blog.setName(user.getNickName());blog.setIcon(user.getIcon());}
}
Feed流關注推送
關注推送也叫做Feed流,直譯為投喂。為用戶持續的提供“沉浸式”的體驗,通過無限下拉刷新獲取新的信息。Feed流是一種基于用戶個性化需求和興趣的信息流推送方式,常見于社交媒體、新聞應用、音樂應用等互聯網平臺。Feed流通過算法和用戶行為數據分析,動態地將用戶感興趣的內容以流式方式呈現在用戶的界面上。
Feed流產品有兩種常見模式:
Timeline:不做內容篩選,簡單的按照內容發布時間排序,常用于好友或關注。例如朋友圈
- 優點:信息全面,不會有缺失。并且實現也相對簡單
- 缺點:信息噪音較多,用戶不一定感興趣,內容獲取效率低
智能排序:利用智能算法屏蔽掉違規的、用戶不感興趣的內容。推送用戶感興趣信息來吸引用戶
- 優點:投喂用戶感興趣信息,用戶粘度很高,容易沉迷
- 缺點:如果算法不精準,可能起到反作用
本例中的個人頁面,是基于關注的好友來做Feed流,因此采用Timeline的模式。該模式的實現方案有三種:
我們本次針對好友的操作,采用的就是Timeline的方式,只需要拿到我們關注用戶的信息,然后按照時間排序即可
,因此采用Timeline的模式。該模式的實現方案有三種:
- 拉模式,也叫做讀擴散。在拉模式中,終端用戶或應用程序主動發送請求來獲取最新的數據流。
- 優點:節約空間,可以減少不必要的數據傳輸,只需要獲取自己感興趣的數據
- 缺點:延遲較高,當用戶讀取數據時才去關注的人里邊去讀取數據
- 推模式,也叫做寫擴散。在推模式中,數據提供方主動將最新的數據推送給終端用戶或應用程序
- 優點:數據延遲低,不用臨時拉取
- 缺點:內存耗費大,假設一個大V寫信息,很多人關注他, 就會寫很多份數據到粉絲那邊去
- 推拉結合,也叫做讀寫混合,兼具推和拉兩種模式的優點。在推拉結合模式中,數據提供方會主動將最新的數據推送給終端用戶或應用程序,同時也支持用戶通過拉取的方式來獲取數據。
基于模式實現關注推送功能
索引漂移現象,也就是查詢的時候數據也在更新,基于索引的方式,會出現這種問題導致,數據重復
滾動分頁
SortedSet
可以按照Score
排序,我們每次選擇上一次查到的分數,來進行滾動查詢
/*** 關注推送頁面的筆記分頁** @param max* @param offset* @return*/@Overridepublic Result queryBlogOfFollow(Long max, Integer offset) {// 1、查詢收件箱Long userId = ThreadLocalUtls.getUser().getId();String key = FEED_KEY + userId;// ZREVRANGEBYSCORE key Max Min LIMIT offset countSet<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);// 2、判斷收件箱中是否有數據if (typedTuples == null || typedTuples.isEmpty()) {return Result.ok();}// 3、收件箱中有數據,則解析數據: blogId、minTime(時間戳)、offsetList<Long> ids = new ArrayList<>(typedTuples.size());long minTime = 0; // 記錄當前最小值int os = 1; // 偏移量offset,用來計數for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { // 5 4 4 2 2// 獲取idids.add(Long.valueOf(tuple.getValue()));// 獲取分數(時間戳)long time = tuple.getScore().longValue();if (time == minTime) {// 當前時間等于最小時間,偏移量+1os++;} else {// 當前時間不等于最小時間,重置minTime = time;os = 1;}}// 4、根據id查詢blog(使用in查詢的數據是默認按照id升序排序的,這里需要使用我們自己指定的順序排序)String idStr = StrUtil.join(",", ids);List<Blog> blogs = this.list(new LambdaQueryWrapper<Blog>().in(Blog::getId, ids).last("ORDER BY FIELD(id," + idStr + ")"));// 設置blog相關的用戶數據,是否被點贊等屬性值for (Blog blog : blogs) {// 查詢blog有關的用戶queryUserByBlog(blog);// 查詢blog是否被點贊isBlogLiked(blog);}// 5、封裝并返回ScrollResult scrollResult = new ScrollResult();scrollResult.setList(blogs);scrollResult.setOffset(os);scrollResult.setMinTime(minTime);return Result.ok(scrollResult);}
用戶簽到
采用位圖的方式來記錄一個月中每一天的簽到情況,當天簽到了就標記為1,在redis中有BitMap來實現這個功能
BitMap的操作命令有:
-
SETBIT:向指定位置(offset)存入一個0或1
-
GETBIT :獲取指定位置(offset)的bit值
-
BITCOUNT :統計BitMap中值為1的bit位的數量
-
BITFIELD :操作(查詢、修改、自增)BitMap中bit數組中的指定位置(offset)的值
-
BITFIELD_RO :獲取BitMap中bit數組,并以十進制形式返回
-
BITOP :將多個BitMap的結果做位運算(與 、或、異或)
-
BITPOS :查找bit數組中指定范圍內第一個0或1出現的位置
/*** 用戶簽到** @return*/@Overridepublic Result sign() {// 獲取當前登錄用戶Long userId = ThreadLocalUtls.getUser().getId();// 獲取日期LocalDateTime now = LocalDateTime.now();// 拼接keyString keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));String key = USER_SIGN_KEY + userId + keySuffix;// 獲取今天是本月的第幾天int dayOfMonth = now.getDayOfMonth();// 寫入Redis SETBIT key offset 1stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);return Result.ok();}