返回文章列表
开发日志

开发日志

这是在搭建好基础框架后进行的项目优化。文档记录了每次代码优化的详细过程。🎉🎉🎉

KKiana
2025年5月25日132 次阅读

2025.05.26

优化内容

Fixed

  1. 修复了从缓存获取不到 article_views,直接 return 的问题; max() 保证了在 cacheViews 比 article.Views 小的情况下,至少返回 DB 中的 Views
  2. 修复了当Cache中article消失,重新加载时会把 Cache 中 article_views 覆盖为旧值的问题
  3. 使用 HIncrBy(key, field, 1) 代替 num:=Get() ---> num++ ---> Set(num)

代码部分

service/article.go ---> ArticleService.ArticleInfoByID()

// Before
views, err := ArticleFieldCache.Get("views", id)
if err == nil {
	return article, errors.New("failed to get views")
}
article.Views = views

// Fixeds
if cacheViews, ok := ArticleFieldCache.Get("views", id); ok == nil {
		article.Views = max(cacheViews, article.Views)
	} else {
		ArticleFieldCache.Set("views", id, article.Views)
	}

service/article_helpers.go ---> ArticleService.Get()

// Before
ArticleFieldCache.Set("views", id, a.Views)

// Fixed
if cacheViews, ok := ArticleFieldCache.Get("views", id); ok != nil {
  ArticleFieldCache.Set("views", id, max(a.Views, cacheViews))
}

2025.05.25

优化内容

Add

  1. 增加了结合文章 create_time, views, comments, likes 4个字段的综合排序评分算法,具体如下:
  • 时间衰减因子:[ \text{timeDecay} = e^{-\frac{\text{hoursSinceCreation}}{2 \times 24 \times 7}} ],[hoursSinceCreation]为文章从创建时间到当前的小时数,[2 \times 24 \times 7]表示半衰期(14天)。

  • 浏览数因子:[ \text{viewFactor} = \frac{\ln(1 + \text{views})}{10} ],[views]为文章的浏览次数,[ln(1 + x)]表示对数函数,用来平滑处理浏览数。

  • 互动因子:[ \text{interactionFactor} = \frac{\ln(1 + 0.3 \times \text{comments} + 0.7 \times \text{likes})}{5} ],[comments]为文章的评论数,[likes]为文章的点赞数,[0.3]和[0.7]分别是评论和点赞的权重。[ln(1 + x)]对互动因子进行对数处理。

  • 综合评分:[ \text{score} = \text{timeDecay} \times 0.4 + \text{viewFactor} \times 0.3 + \text{interactionFactor} \times 0.3 ]

  • 确保评分在0到1之间:[ \text{finalScore} = \max(0, \min(1, \text{score})) ]

Fixed

  1. 修复了 Cache 中文章浏览量会被旧数据覆盖的问题

2025.05.24

优化内容

Add

  1. articleService.Get() 方法:加入了互斥锁,应对缓存击穿问题

Fixed

  1. 修复了了前端 文章浏览 页面样式

代码部分

service/article_helpers.go ---> ArticleService.Get()

// Get 用于通过ID从 Elasticsearch 获取文章 旁路缓存 缓存穿透
func (articleService *ArticleService) Get(id string) (elasticsearch.Article, error) {
	var a elasticsearch.Article
	// 先从缓存获取
	if err := articleCacheDB.Get(id, &a); err == nil {
		return a, nil // 缓存命中,直接返回
	}
	// 尝试获取或创建锁
	lockInterface, _ := articleLocks.LoadOrStore(id, &sync.Mutex{})
	lock := lockInterface.(*sync.Mutex)
	lock.Lock()
	defer lock.Unlock()
	// 再次检查缓存,防止其他请求已经加载数据
	if err := articleCacheDB.Get(id, &a); err == nil {
		return a, nil // 缓存命中,直接返回
	}
	// 从 Elasticsearch 获取文章
	res, err := global.ESClient.Get(elasticsearch.ArticleIndex(), id).Do(context.TODO())
	if err != nil {
		return elasticsearch.Article{}, err
	}
	if !res.Found {
		return elasticsearch.Article{}, errors.New("Article not found")
	}
	// 将返回的源数据反序列化为 Article 对象
	err = json.Unmarshal(res.Source_, &a)
	if err != nil {
		return elasticsearch.Article{}, err
	}
	// 新获取的文章缓存
	articleCacheDB.Set(id, &a)
	ArticleFieldCache.Set("views", id, a.Views)

	return a, nil
}

2025.05.23

优化内容

Add

  1. articleService.Get() 方法:加入了旁路缓存策略 + 延迟双删,应对高并发带来的 DB 负载增加和 DB-Cache 数据不一致问题

代码部分

service/article_helper.go

package service

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"server/global"
	"server/model/database"
	"server/model/elasticsearch"
	"server/utils"
	"sync"
	"time"

	"github.com/elastic/go-elasticsearch/v8/typedapi/core/bulk"
	"github.com/elastic/go-elasticsearch/v8/typedapi/core/search"
	"github.com/elastic/go-elasticsearch/v8/typedapi/core/update"
	"github.com/elastic/go-elasticsearch/v8/typedapi/types"
	"github.com/elastic/go-elasticsearch/v8/typedapi/types/enums/refresh"
	"gorm.io/gorm"
)

var (
	// 用于同步加载文章的锁
	articleLocks sync.Map
)

// Create 用于将文章创建到 Elasticsearch
func (articleService *ArticleService) Create(a *elasticsearch.Article) error {
	// 将文章索引到Elasticsearch中,并设置刷新操作为 true
	res, err := global.ESClient.Index(elasticsearch.ArticleIndex()).Request(a).Refresh(refresh.True).Do(context.TODO())
	articleCacheDB.Set(res.Id_, a)
	return err
}

// Delete 用于删除 Elasticsearch 中的文章,并实现延迟双删策略
func (articleService *ArticleService) Delete(ids []string) error {
	// 第一次删除缓存,构建批量删除请求
	var request bulk.Request
	for _, id := range ids {
		articleCacheDB.Delete(id)
		request = append(request, types.OperationContainer{Delete: &types.DeleteOperation{Id_: &id}})
	}

	// 执行批量删除请求,并设置刷新操作为 true
	_, err := global.ESClient.Bulk().Request(&request).Index(elasticsearch.ArticleIndex()).Refresh(refresh.True).Do(context.TODO())
	if err != nil {
		return err
	}

	// 延迟第二次删除缓存
	go func() {
		// 延迟一段时间(如1秒),等待可能的数据库主从同步完成
		time.Sleep(200 * time.Millisecond)
		for _, id := range ids {
			articleCacheDB.Delete(id)
			global.Log.Info(fmt.Sprintf("Delete article id:%s from cache", id))
		}
	}()

	return nil
}

// Get 用于通过ID从 Elasticsearch 获取文章 旁路缓存策略
func (articleService *ArticleService) Get(id string) (elasticsearch.Article, error) {
	var a elasticsearch.Article
	// 先从缓存获取
	if err := articleCacheDB.Get(id, &a); err == nil {
		return a, nil // 缓存命中,直接返回
	}
	// 缓存未命中,从Elasticsearch获取文章
	res, err := global.ESClient.Get(elasticsearch.ArticleIndex(), id).Do(context.TODO())
	if err != nil {
		return elasticsearch.Article{}, err
	}
	// 如果找不到该文档,则返回错误
	if !res.Found {
		return elasticsearch.Article{}, errors.New("document not found")
	}
	// 将返回的源数据反序列化为 Article 对象
	err = json.Unmarshal(res.Source_, &a)
	if err != nil {
		return elasticsearch.Article{}, err
	}
	// 缓存新获取的文章(可选,根据业务需求)
	articleCacheDB.Set(id, &a)

	return a, nil
}

func (articleService *ArticleService) Update(articleID string, v any) error {
	// 1. 先删除缓存(第一次删除)
	articleCacheDB.Delete(articleID)

	// 2. 将待更新的值转换为 JSON
	bytes, err := json.Marshal(v)
	if err != nil {
		return err
	}

	// 3. 执行 Elasticsearch 更新操作
	_, err = global.ESClient.Update(elasticsearch.ArticleIndex(), articleID).
		Request(&update.Request{Doc: bytes}).
		Refresh(refresh.True).
		Do(context.TODO())
	if err != nil {
		return err
	}

	// 4. 延迟第二次删除缓存
	go func(id string) {
		// 延迟一段时间(如1秒),等待可能的数据库主从同步完成
		time.Sleep(200 * time.Millisecond)
		articleCacheDB.Delete(id)
		global.Log.Info(fmt.Sprintf("Delete article id:%s from cache", id))
	}(articleID)

	return nil
}

// Exits 用于检查文章标题是否存在
func (articleService *ArticleService) Exits(title string) (bool, error) {
	// 创建查询请求,匹配标题字段
	req := &search.Request{
		Query: &types.Query{
			Match: map[string]types.MatchQuery{"keyword": {Query: title}},
		},
	}
	// 执行搜索查询,查找是否存在该标题的文章
	res, err := global.ESClient.Search().Index(elasticsearch.ArticleIndex()).Request(req).Size(1).Do(context.TODO())
	if err != nil {
		return false, err
	}
	// 如果存在该标题,返回 true
	return res.Hits.Total.Value > 0, nil
}

// UpdateCategoryCount 更新文章类别的计数(增加或减少)
func (articleService *ArticleService) UpdateCategoryCount(tx *gorm.DB, oldCategory, newCategory string) error {
	// 如果新类别和旧类别相同,直接返回,不进行更新
	if newCategory == oldCategory {
		return nil
	}

	// 如果新类别不为空,更新新类别的文章计数
	if newCategory != "" {
		var newArticleCategory database.ArticleCategory
		// 如果新类别不存在,则创建新类别并设置计数为1
		if errors.Is(tx.Where("category = ?", newCategory).First(&newArticleCategory).Error, gorm.ErrRecordNotFound) {
			if err := tx.Create(&database.ArticleCategory{Category: newCategory, Number: 1}).Error; err != nil {
				return err
			}
		} else {
			// 如果类别已存在,更新该类别的计数
			if err := tx.Model(&newArticleCategory).Update("number", gorm.Expr("number + ?", 1)).Error; err != nil {
				return err
			}
		}
	}

	// 如果旧类别不为空,更新旧类别的文章计数
	if oldCategory != "" {
		var oldArticleCategory database.ArticleCategory
		// 更新旧类别的文章计数,减少 1
		if err := tx.Where("category = ?", oldCategory).First(&oldArticleCategory).Update("number", gorm.Expr("number - ?", 1)).Error; err != nil {
			return err
		}
		// 如果旧类别的计数为 1(减少 1 之前),则删除该类别
		if oldArticleCategory.Number == 1 {
			if err := tx.Delete(&oldArticleCategory).Error; err != nil {
				return err
			}
		}
	}

	return nil
}

// UpdateTagsCount 更新文章标签的计数(增加或减少)
func (articleService *ArticleService) UpdateTagsCount(tx *gorm.DB, oldTags, newTags []string) error {
	// 比较旧标签和新标签,获取新增和移除的标签
	addedTags, removedTags := utils.DiffArrays(oldTags, newTags)

	// 处理新增的标签
	for _, addedTag := range addedTags {
		var t database.ArticleTag
		// 如果标签不存在,则创建该标签并设置计数为1
		if errors.Is(tx.Where("tag = ?", addedTag).First(&t).Error, gorm.ErrRecordNotFound) {
			if err := tx.Create(&database.ArticleTag{Tag: addedTag, Number: 1}).Error; err != nil {
				return err
			}
		} else {
			// 如果标签已存在,更新标签的计数
			if err := tx.Model(&t).Update("number", gorm.Expr("number + ?", 1)).Error; err != nil {
				return err
			}
		}
	}

	// 处理移除的标签
	for _, removedTag := range removedTags {
		var t database.ArticleTag
		// 更新标签计数,减少 1
		if err := tx.Where("tag = ?", removedTag).First(&t).Update("number", gorm.Expr("number - ?", 1)).Error; err != nil {
			return err
		}
		// 如果标签的计数为 1(减少 1 之前),则删除该标签
		if t.Number == 1 {
			if err := tx.Delete(&t).Error; err != nil {
				return err
			}
		}
	}
	return nil
}

改进效果

没有缓存,耗时 7.93ms;加入缓存,耗时 0.63ms。效果显著!

没有缓存(尾部 "cost" 字段为耗时)
{"level":"INFO","time":"2025-05-23T19:47:55.645+0800","caller":"middleware/logger.go:35","msg":"/api/article/t-o5B5cBaaB3PF7pQFhr","status":200,"method":"GET","path":"/api/article/t-o5B5cBaaB3PF7pQFhr","query":"","ip":"120.85.135.78","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","errors":"","cost":0.007933367}
加入缓存
{"level":"INFO","time":"2025-05-23T19:48:06.689+0800","caller":"middleware/logger.go:35","msg":"/api/article/t-o5B5cBaaB3PF7pQFhr","status":200,"method":"GET","path":"/api/article/t-o5B5cBaaB3PF7pQFhr","query":"","ip":"120.85.135.78","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","errors":"","cost":0.000635325}
开发日志

评论 (2)

后参与评论

E
EastBrook2025年12月25日

:emoji:e40:

不要面包2025年6月6日

:emoji:e2::emoji:e2:

开发日志 | 博客