2024年8月


本章我们来完成文本编辑器的文件打开和查看功能,最后成品如上图。我们将分4步,逐渐完成本章所需功能。内容比较多,会分为两个部分,第一部分主要关注于“View视图”和“buffer及文本读取”。

如上图最终效果所示,我们希望在终端的最下方增加一个状态栏,能够展示当前被打开的文件和当前的光标位置。

同时我们打算对整个终端视图做一些划分和重构。

首先先介绍一下编辑器各个组件的定位:

  • 编辑器
    editor
    :此组件主要在不同的用户界面组件(当前仅
    View
    )之间进行协调。
  • 视图
    View
    :此组件接收来自编辑器的每个与文本相关的事件,例如字符的按键输入、换行等。视图使用此信息来进行渲染,并将文本修改事件转化并传递给缓冲区
    buffer
  • 缓冲区
    buffer
    (一会儿会介绍到):此组件将保存我们的文本。本项目我们只会从处理ascii码的字符,
    不会针对全角字符、emoji符号进行处理
    ,所以对这类特殊字符的展示和编辑可能会有异常。

    具体来说,红框内部分是编辑器的主要交互部分,我们把它用一个名为view的结构体来表示;下方橙黄色框内的部分我们把它用status_bar的结构体来表示,它会显示编辑器的一些状态信息,如当前打开的文件名和当前光标在第几行第几列。

View结构

首先我们来重构项目部分代码,来把一些现有功能放入view结构体以及它的方法中,因此我们的
editor
结构体应该持有一个
View

type editor struct {
	view *view.View

	needQuit bool
}

而对于
View
来说,因为光标只能在
View
内移动,所以指示光标位置的
TextLocX

TextLocY
应当被移动到
View
结构的成员中。

type View struct {
	TextLocX int
	TextLocY int
}

其余和
View
相关的函数页应被重构为View的方法,包括

  • CursorPos
  • Render
  • MoveCursor

作业1

按“View结构”的要求,重构代码。
Code Review
我的实现

简单的Buffer

缓冲区是一种常见的结构,它保存了文本编辑器修改和显示文本文件所需的一切。视图与缓冲区交互以在屏幕上呈现文本。在许多文本编辑器中,您可以轻松地从一个缓冲区切换到下一个缓冲区,从而允许您并行打开多个文件。

缓冲区的实现细节因文本编辑器而异。例如,Nano 使用一种简单直接的缓冲区结构:保存文件时,缓冲区的内容(不包括语法高亮显示的颜色等数据)会保存到磁盘。而Vim 具有复杂的内部结构,即使在处理大型文件时也能高效地处理操作,并且包括一个单独的屏幕缓冲区,用于表示当前屏幕上可见的内容。这些设计选择反映了每个编辑器的愿景:Nano 旨在成为一个小型、轻量级的编辑器,适用于快速更新配置文件。而 Vim 则被设计为一个功能齐全、功能丰富的文本编辑器,非常适合处理冗长的文件。

作业2 简单的buffer

  • 创建一个名为 Buffer 的新结构体,其中包含一个
    string
    切片
  • 更改View结构以容纳一个Buffer
  • buffer提供load_file方法:该方法读取文本,并将文本内容存到buffer中,一行文本对应buffer中的一个string元素
    • 你可以使用
      bufio.NewScanner

      scan
      方法来逐行读取文本
    • 逐行在终端上渲染Buffer中的每个string元素。对于屏幕上的每个空行,仍然渲染一个
      ~

Code Review
我的实现

一晃24年已经过了一半,我们来重新看下大模型应用中最脆弱的一环Prompt Engineering有了哪些新的解决方案。这一章我们先看看大火的DSPy框架,会先梳理DSPy相关的几篇核心论文了解下框架背后的设计思想和原理,然后以FinEval的单选题作为任务,从简单指令,COT指令,到采样Few-shot和优化指令给出代码示例和效果评估。

论文串烧

  • DEMONSTRATE–SEARCH–PREDICT:
    Composing retrieval and language models for knowledge-intensive NLP
  • DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines
  • In-Context Learning for Extreme Multi-Label Classification
  • Optimizing Instructions and Demonstrations for Multi-Stage Language Model Programs
  • DSPy Assertions:
    Computational Constraints for Self-Refining Language Model Pipelines

DSPY这个prompt框架着实火了一阵了,项目也发了上面的许多论文,每篇论文都对应了项目中的一个或几个模块。我们来串一遍以上论文的核心思想。

DEMONSTRATE–SEARCH–PREDICT
是DSPy的第一篇,核心思想和现在coze这类流程控制软件很相似(哈哈现在大模型领域的名词异常的多,这个workflow其实和agent,chain,pipeline,bot啥的意思也很相似)。核心在固定流程,模块化推理过程和指令(few-shot)生成过程。

虽然是在RAG任务上提出的DSP框架,但我们抛开RAG的search,predict的流程,论文的核心其实是把任务拆分成多个原子节点,每个原子节点是一个不可再分割的function,通过整体的control flow来串联原子节点。同时这种流程化的框架,使得每个节点都可以基于训练数据生成demonstation,并且可以通过不同的召回逻辑,在推理时进行few-shot的动态选择,来优化每一个节点的效果。

以下是OpenQA任务上的一个workflow的示例,整个流程有三个部分,生成Demonstration,并基于示例,进行检索和推理。

Clipboard_2024-07-05-08-09-14

其中示例的生成,会使用训练数据在相同的流程上,取运行过程中每一步的结果作为实例,这里选择了k=3的3-shot示例

Clipboard_2024-07-05-08-09-02

在上面DSP的基础上,
DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines
对流程进一步做了抽象和重构,提出了以三个核心模块为基础的prompt生成和优化框架

  • Signature
    : 继承了pydantic的BaseModel,定义任务的范式,例如Context,Question -> Answer
  • Module
    :类似pytorch对模型定义的callable function,定义了任务的workflow,例如上面retrieve-then-read的RAG流程
  • Teleprompter
    : 基于任务指标对流程进行优化的编译器,这里的优化集中在few-shot的选择和动态流程选择,这里流程选择主要是Ensemble,而非直接对workflow进行修改。论文说还提供了微调能力,其实也是基于流程生成的Demonstration作为样本来微调。

其中Telepropmter提供了BootstrapFewShot, BootstrapFewShotWithRandomSearch,Ensemble等prompt优化器。
这篇论文的重心是放在prompt中few-shot选择和优化

以BootstrapFewShotWithRandomSearch为例,Teleprompter会先基于训练集生成一批Demonstration,再基于指定任务的metric,从中采样得到在验证集上效果表现最优的few-shot来构建最终的prompt。

In-Context Learning for Extreme Multi-Label Classification
其实是利用了DSPy的框架,在标签超大(≥10,000)的分类任务上构建了一个新的workflow,叫做Infer-Retrieve-Rank。也就是想让模型猜测N个可能的分类标签,然后召回这些标签。

Optimizing Instructions and Demonstrations for Multi-Stage Language Model Programs
把重心放在了端到端的对任务prompt进行整体优化,除了few-shot之外,还包括
对任务指令和任务条件的优化
。这里有两个技术难点,一个是prompt空间的搜索范围极大,另一个是整体优化时指标的归因问题。

为解决prompt搜索空间问题,论文借鉴APE,也就是大模型基于input-output样本来分析任务本质,生成任务指令的思路。并且在描述任务时除了必须的训练样本,还提供了多个可选择的任务信息,包括

  • 是否使用大模型生成的2~3句话的任务描述
  • 是否使用任务的流程代码
  • 是否使用历史已经尝试过的指令
  • 是否使用不同的指令风格(tips), 例如更有创意,更简洁

模型生成指令的代码如下


TIPS = {
        "none": "",
        "creative": "Don't be afraid to be creative when creating the new instruction!",
        "simple": "Keep the instruction clear and concise.",
        "description": "Make sure your instruction is very informative and descriptive.",
        "high_stakes": "The instruction should include a high stakes scenario in which the LM must solve the task!",
        "persona": 'Include a persona that is relevant to the task in the instruction (ie. "You are a ...")',
    }
class GenerateSingleModuleInstruction(dspy.Signature):
    (
        """Use the information below to learn about a task that we are trying to solve using calls to an LM, then generate a new instruction that will be used to prompt a Language Model to better solve the task."""
    )
    if use_dataset_summary:
        dataset_description = dspy.InputField(
            desc="A description of the dataset that we are using.",
            prefix="DATASET SUMMARY:",
        )
    if program_aware:
        program_code = dspy.InputField(
            format=str,
            desc="Language model program designed to solve a particular task.",
            prefix="PROGRAM CODE:",
        )
        program_description = dspy.InputField(
            desc="Summary of the task the program is designed to solve, and how it goes about solving it.",
            prefix="PROGRAM DESCRIPTION:",
        )
        module = dspy.InputField(
            desc="The module to create an instruction for.", prefix="MODULE:",
        )
    task_demos = dspy.InputField(
        format=str,
        desc="Example inputs/outputs of our module.",
        prefix="TASK DEMO(S):",
    )
    if use_instruct_history:
        previous_instructions = dspy.InputField(
            format=str,
            desc="Previous instructions we've attempted, along with their associated scores.",
            prefix="PREVIOUS INSTRUCTIONS:",
        )
    basic_instruction = dspy.InputField(
        format=str, desc="Basic instruction.", prefix="BASIC INSTRUCTION:",
    )
    if use_tip:
        tip = dspy.InputField(
            format=str,
            desc="A suggestion for how to go about generating the new instruction.",
            prefix="TIP:",
        )
    proposed_instruction = dspy.OutputField(
        desc="Propose an instruction that will be used to prompt a Language Model to perform this task.",
        prefix="PROPOSED INSTRUCTION:",
    )

基于以上大模型生成的指令候选,以及前面BootstrapFewShot生成的众多示例,下一步就是基于训练集选择最优的指令。这里论文构建了包括randomSearch等多个优化器,论文主推的MIPRO(Multi-prompt Instruction Proposal Optimizer)使用常见的超参优化的TPE算法来拟合以上指令中的多个超参对最终prompt效果的正负面影响,基于训练集上的评估指标最终选出最优的指令。所以是生成指令的超参(包括采样生成的指令)选择,并非直接去迭代更新指令本身。

其实这里也可以使用各类模型可解释算法。核心难点是整个任务中有多个节点,每个节点prompt有多个超参,彼此间互相影响,并最终影响任务的完成效果,但我们只能拿到最终任务完成的标签,无法获得中间节点的输出反馈。和决策树之类的归因算法难点相似。

DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines
是DSPy新出的一个子功能-条件判断。考虑常见的prompt构成基本就包括三个部分,任务描述,few-shot示例,还有针对任务完成细节的requirement,前面两篇论文分别给出了生成优化任务描述和采样筛选few-shot的方案,那Assert就是面向requirements的优化方案。

而现实任务中requirement往往是最琐碎的部分,例如像Query改写任务,我们可能需要要求改写query和原始query相似度不能太高,但又不能丢失核心主体,不能丢失或者改写时间实体,query不能太长,不能对query中无关细节等等

论文给出了硬性要求(Assert)和软性建议(Suggest)两种方法,直接加入到前面编写的任务module中,这样任务推理的过程会根据Assert和Suggest直接生成建议,而使用也有两种,一种是模型在推理时命中assert和suggest后,对应建议会直接加入到prompt中用于模型self-refine,另一种是assert可以直接打断模型施法进行重试。

论文就简单说这么多,下面我们以金融单选题任务为示例尝试对prompt进行优化。

代码示例

下面我们以FinEval的单选题作为任务,尝试使用DSPy进行Prompt生成,Prompt优化,和效果评估。考虑成本这里只使用了100条样本。

基础Prompt

首先定义LLM模型,这里我使用的Azure的GPT4

import dspy
model = dspy.AzureOpenAI(**kwargs)
dspy.settings.configure(lm=model)

使用DSPy定义prompt,就是定义一个Pydantic模型,包括任务描述,输入描述和输出描述,称之为Signature,如下

class SingleChoiceQA(dspy.Signature):
    """单项选择题,给定题目和ABCD四个选项,输出正确的选项"""
    question = dspy.InputField(desc='问题和选项')
    answer = dspy.OutputField(desc="[ABCD]之一的正确选项")

然后我们把数据导入并转化成DSPy规定的样本格式,同样只要注明输入,Example会自动把剩余字段都当做输出

def format_example(line):
    input = line['question']
    for choice in ['A', 'B', 'C', 'D']:
        input += f'\n{choice}. {line[f"{choice}"]}'
    output = line["answer"]
    example = dspy.Example(question=input, answer=output).with_inputs('question')
    return example

image

下一步我们来定义任务,DSPy称之为Module,也就是定义任务流,像RAG就是retrieve-read,multihop QA就是多轮的QA,而这里因为是简单的单选题,因此Module定义非常简单。

class SQA(dspy.Module):
    def __init__(self):
        self.generate_answer = dspy.Predict(SingleChoiceQA)

    def forward(self, question):
        return self.generate_answer(question=question)
singlechoice_qa = SQA()
pred = singlechoice_qa(question=examples[0]['question'])
print('以下为DSPy生成Prompt')
print(model.inspect_history(n=1))

同时我们可以使用inspect_history很方便的查看,DSPy生成的具体Prompt,和对应模型的推理效果,如下
image

COT优化和评估

在上面基础任务定义的基础上,下一步我们看看能否简单使用COT就能优化任务效果。

加入COT定义的Module如下,DSPy提供了常见的ReACT,POT等思考Prompt。

class SQACOT(dspy.Module):
    def __init__(self):
        self.generate_answer = dspy.ChainOfThought(SingleChoiceQA)

    def forward(self, question):
        return self.generate_answer(question=question)
singlechoice_qa_cot = SQACOT()

加入COT后的prompt和推理效果如下
image

接下来我们来定义下任务的评估指标,然后批量评估下使用基础Prompt和COT Prompt的效果差异。

DSPy提供了一些Exact Match,Passage Match之类的指标,但其实自己定义指标最方便。只要和DSPy Metric的输入输出对齐即可。这里我们简单抽取答案中的ABCD和标准答案计算Accuracy。评估代码如下,DSPy支持在返回打分的同时,返回每一条预测的具体结果

from dspy.evaluate.evaluate import Evaluate
def choice_match(example, pred, trace=None):
    def extract_choice(gen_ans):
        m = re.findall(r'[ABCD]', gen_ans, re.M)
        if len(m) >= 1:
            answer = m[0]
            return answer
        return random.choice('ABCD')

    return extract_choice(pred.answer) == example.answer

evaluate_on_qa = Evaluate(devset=test, num_threads=1,
                          display_progress=True, display_table=True)
output1 = evaluate_on_qa(singlechoice_qa, metric=choice_match,return_outputs=True,return_all_scores=True)

output2 = evaluate_on_qa(singlechoice_qa_cot, metric=choice_match,return_outputs=True,return_all_scores=True)

以上评估,基础Prompt的准确率在50%,而COT Prompt的准确率在60%
image

FewShot优化

再进一步我们使用DSPy最重点打造的FewShot采样优化,看下能否进一步提升效果。这里DSPy提供了多种优化器,适配不同的样本量级,这里考虑成本我们使用了基础的BootstrapFewShot,样本更多可以尝试BootstrapFewShotWithRandomSearch,或者BootstrapFewShotWithOptuna。

BootstrapFewShot可以指定few-shot中包含几个模型预测生成的样本(max_bootstrapped_demos),和几个真实标签的训练样本(max_labeld_demos)

teleprompter = BootstrapFewShot(metric=choice_match,
                                max_bootstrapped_demos=4,
                                max_labeled_demos=16)
compiled_qa = teleprompter.compile(singlechoice_qa_cot, trainset=train)

score3, results3, output3, df3 = evaluate_on_qa(compiled_qa, metric=choice_match,
                                                return_outputs=True,
                                                return_all_scores=True)

加入采样Few-Shot后的推理准确率提升到了75%
,使用2个推理Demos,和4个真实标签的Demos拼接而成的模型Prompt如下
image

指令优化

再看下指令优化,这里使用的是COPRO优化器,也就是使用prompt让大模型基于原有prompt优化生成新的prompt,优化指令和第一轮优化生成的prompt如下

image

第二轮之后指令优化会给出之前尝试过的所有指令,和每个指令在验证集上的打分,并让模型进行有针对性的优化,称之为GenerateInstructionGivenAttempts,指令如下

image

在0.7的temperature下,每一轮模型会基于上一轮的最优prompt生成breadth=5个新的prompt,并重复以上过程depth=3轮,最后选取在验证集上效果最优的prompt。

COPRO_teleprompter = COPRO(prompt_model=model ,
                          metric=choice_match,
                          breadth=5,
                          depth=3,
                          init_temperature=0.7,
                          track_stats=True)
kwargs = dict(num_threads=1, display_progress=True, display_table=5)
COPRO_compiled_qa = COPRO_teleprompter.compile(singlechoice_qa_cot,
                                                trainset=train,
                                                eval_kwargs=kwargs)
print(model.inspect_history(n=1))

最终优化后的最优的prompt指令如下

image

但在测试集上最终并没有提升准确率还是60%,其中一个主要原因也是单选QA任务本身比较基础,在指令上可以优化的空间不算太大,指令优化再更复杂,非常规任务上的效果会更显著些。

整体上DSPy确实提供了模块化,标准化设计Prompt的方案,但是在任务描述上的优化方案现在还比较有限,上面GenerateInstructionGivenAttempts虽然提供了历史尝试过的指令和验证集打分,但缺少了模型从历史prompt的回答中总结模型当前指令存在什么问题的步骤,后面可能可以考虑更多reflection相关的指令优化方案像TextGrad,这个等我玩过再来总结吧~

想看更全的大模型相关论文梳理·微调及预训练数据和框架·AIGC应用,移步Github >>
DecryPrompt

前言

书接上回,消息通知系统(notification-system)作为一个独立的微服务,完整地负责了 App 端内所有消息通知相关的后端功能实现。该系统既需要与文章系统、订单系统、会员系统等相关联,也需要和其它业务系统相关联,是一个偏底层的通用服务系统。

App 端内的消息通知类型常见有这几项:评论通知、点赞通知、收藏通知、订单通知、活动通知、个人中心相关通知等。该系统在可拓展性、高性能、较高可用性、数据一致性等方面有较高要求,最终目的是提升用户粘性、加强 App 与用户的互动、支撑核心业务的发展。

文章的(上)篇将从需求分析、数据模型设计、关键流程设计这 3 部分来说明,(下)篇将从技术选型、后端接口设计、关键逻辑实现这 3 部分来进行说明。


四、技术选型

我将该系统需要使用到的关键技术选型做成表格,方便梳理:

说明:

  • 可以用 Spirng Cloud 或者 Spirng Cloud Alibaba,哪个习惯用哪个,只要是能打包成一个可运行的微服务即可;
  • 也可以用非关系型数据库如 MongoDB 来代替 MySQL,表与表之间的关系不密切的前提下,性能会更高;
  • Redis 拿来做缓存中间件去存储非结构化的一些数据是非常合适的,很多场景下,突出的性能和便捷的 API 是它的优势;
  • MQ 其实是选用的,适合较为复杂的项目拿来异步/解耦,既可以 kafka 也可以 RabbitMQ,RocketMQ 是阿里亲生的,控制台用起来也方便;
  • 其它开源依赖最好使用 apache 的顶级项目或者 Spring 官方的,像 hutool 这种第三方的包其实不太推荐,安全风险可能会比较高。


五、后端接口设计

作为一个偏底层的公共服务,基本上都会先由上游的业务系统进行调用,再服务于用户(即 App 端)。下面设计两个 Controller 分别针对业务端和 App 端,大家可以先参考一下接口规范,也写了总体的思路注释,关键逻辑会在下一节再展开讲。

5.1业务系统接口

暴露给业务系统的有 3 个接口:

  1. 获取通知配置
  2. 发送通知
  3. 撤回通知
@RestController
@RequestMapping("notice/api")
public class NoticeApiController {

    @Resource
    private NotificationService notificationService;

    /**
     * 新增通知,业务系统用
     * @param dto
     * @return 消息系统唯一 id
     */
    @PostMapping("/add")
    public Response<Long> addNotice(@Valid @RequestBody AddNoticeDTO dto){
        //业务方调用该接口前需要先根据 sourceId 确认来源,实现就是先入数据库,再入 Redis
        return ResponseBuilder.buildSuccess(this.notificationService.addNotice(dto));
    }

    /**
     * 撤回通知(同批量撤回),业务系统用
     * @param idList,需要撤回的消息主键 id 集合
     * @return 是否成功:true-成功,false-失败
     */
    @PostMapping("/recall")
    public Response<Boolean> recallNotice(@RequestBody List<Long> idList){
        //撤回只需要考虑先更新数据库,后更新 Redis
        return ResponseBuilder.buildSuccess(this.notificationService.recallNotice(idList));
    }

    /**
     * 获取通知配置
     * @param sourceId 业务系统标识
     * @return 配置详情信息
     */
    @GetMapping("/getNoticeConfig")
    public Response<NotificationConfig> getNoticeConfig(@RequestParam(value = "noticeId") String sourceId){
        //每个业务系统调用前需要校验通知配置,以防非法调用
        return ResponseBuilder.buildSuccess(this.notificationService.getNoticeConfig(sourceId));
    }
    
}

5.2App 端接口

开放给 App 端使用的有 2 个接口:

  1. 获取用户未读消息总数
  2. 获取用户消息列表
@RestController
@RequestMapping("notice/app")
public class NoticeAppController {

    @Resource
    private NotificationService notificationService;

    /**
     * 获取用户未读消息总数
     */
    @Auth
    @GetMapping("/num")
    public Response<NoticeNumVO> getMsgNum() {
        //App 端的用户唯一 uuid
        String userUuid = "";
        return ResponseBuilder.buildSuccess(this.notificationService.getMsgNum(userUuid));
    }

    /**
     * 获取用户消息列表
     *
     * @param queryDate:查询时间 queryDate
     * @param pageIndex:页码,1开始
     * @param pageSize:每页大小
     * @param superType:消息父类型,1-评论、点赞、系统消息,2-通知,3-私信,4-客服消息
     */
    @Auth
    @GetMapping("/list/{queryDate}/{pageIndex}/{pageSize}/{superType}")
    public Response<List<Notification>> getNoticeList(@PathVariable String queryDate, @PathVariable Integer pageIndex,
                                                      @PathVariable Integer pageSize, @PathVariable Integer superType) throws ParseException {
        //App 端的用户唯一 uuid
        String userUuid = "";
        Date dateStr = DateUtils.parseDate(queryDate, new String[]{"yyyyMMddHHmmss"});
        return ResponseBuilder.buildSuccess(this.notificationService.getNoticeList(userUuid, dateStr, pageIndex, pageSize, superType));
    }

}


六、关键逻辑实现

本小节会针对 APP 端的两个接口进行详细讲解,未读消息数和消息列表的实现需要 Redis + MySQL 的紧密配合。

6.1Redis存储结构

下面先着重介绍一下本系统的 Redis 缓存结构设计,全局只使用 Hash 结构,新增消息时+1,撤回消息时-1,已读消息时做算术更新:

Redis-Hash 结构

说明:

  • Redis-key 是固定 String 常量 "sysName.notice.num.key";

  • Hash-key 为 App 端用户唯一的 userUuid;

  • Hash-value 为该用户接收的消息总数,新增 +1,撤回 -1。

如果大家对于 Redis 的基本结构还不太了解,参考下我的这篇博客:
https://www.cnblogs.com/CodeBlogMan/p/17816699.html

下面是关键实现步骤的代码示例:

  1. 新增消息

        //先入 MySQL
        Notification notification = this.insertNotice(dto);
        //再入 Redis
        redisTemplate.opsForHash().increment(RedisKey, dto.getTargetUserUuid(), 1);
    
  2. 撤回消息

        //先更新 MySQL
        this.updateById(notification);
        //再更新 Redis
        redisTemplate.opsForHash().increment(RedisKey, userUuid, -1);
    

注意:

写操作和更新操作都是先操作数据库,然后再同步入 Redis。原因:
数据库里的数据是源头,且存的是结构化的持久性数据;Redis 只是作为缓存,发挥 Redis 读取速度快的优点,存储的是一些 size 不大的热点数据。

6.2已读消息处理

已读和未读其实就是两种状态,Redis 里一开始存储的都是未读数,当用户点击查看列表时,前端会调用后端的消息列表接口,消息列表直接查数据库(记录了已读和未读状态),此时同步更新 Redis 里的未读消息数,那么此时:未读消息数 = Redis总数 - MySQL已读消息数。

下面的代码说得比较清楚了:

  1. 查询未读消息数

        Integer num;
        //先读 redis,没有再读数据库,最后再把数据库读出的放回 redis
        num = (Integer) redisTemplate.opsForHash().get(RedisKey, userUuid);
        //防止一开始新增通知的时候没放进 redis 里,null 表示什么都没有,而不是 0
        if (Objects.nonNull(num)) {
            msgNumVO.setMsgNum(num);
        }else {
            num = this.getNoticeNum(userUuid, queryDate);
            log.info("缓存中没有未读消息总数,查数据库:{}", num);
            msgNumVO.setMsgNum(num);
            //放入缓存,取出什么放什么
            redisTemplate.opsForHash().put(RedisKey, userUuid, num);
        }
    return num;
    
  2. 查询消息列表

     wrapper.eq(Notification::getTargetUserUuid, userUuid)
            .eq(Notification::getSuperType, superType)
            .eq(Notification::getMsgStatus, StatusEnum.TRUE.getType())
            .le(Notification::getCreateTime, dateTime)
            .orderByDesc(Notification::getCreateTime);
        List<Notification> queryList = pageInfo.getResult();
        //查询后即要同步去更新数据库中该类型下的消息为已读
        this.updateListBySuperType(wrapper);
        long isReadNum;
        isReadNum = queryList.stream().filter(val -> NumberUtils.INTEGER_ZERO.equals(val.getIsRead())).count();
        //关键的一步,同步更新 redis 里的未读消息数
        Integer redisNum = (Integer) redisTemplate.opsForHash().get(RedisKey.INITIAL_NOTICE_NUM_PERFIX, userUuid);
        //要先判断 redis 里是否为 null,和 0 不一样
        int hv = Objects.isNull(redisNum) ? 0 : (int) (redisNum - isReadNum);
        redisTemplate.opsForHash().put(RedisKey, userUuid, Math.max(hv, 0));
    return queryList;
    


6.3缓存定时清除

由于在上述的 redis-hash 结构中并没有加入 expire 过期时间,那么显而易见的是这个结构随着时间增加会越来越大,最终导致形成一个大key,给 redis 的读/写性能带来影响。
所以这里需要给出一个方案来解决这个问题,我的核心思路是:

  • 每当写redis计数的时候同时用另一个 key 记操作时间,每10分钟执行一次定时任务;
  • 逐一将时间 key 的 value (即操作时间)根据 uuid 拿出来,如果当前系统时间 - 该uuid的操作时间>3600ms(即一个小时)那么就将该 uuid 的数据删除;
  • 下次调接口先读数据库,再写进 redis 里面,具体看代码。
@Component
@Slf4j
public class HandleNoticeCache {
    private static final Long FLAG_TIME = 3600L;
    @Resource
    private RedisTemplate redisTemplate;
    @Scheduled(cron = " * 0/10 * * * ? ")
    public void deleteNoticeCache(){
        HashOperations<String, String, Integer> hashOperations = redisTemplate.opsForHash();
        //通知操作的全部 uuid,数据量一大可能导致 OOM
        Set<String> uuidList = hashOperations.keys(RedisKey.NOTICE_NUM_TIME);
        if (CollectionUtils.isNotEmpty(uuidList)){
            uuidList.forEach(val -> {
                Integer operateTime = hashOperations.get(RedisKey.NOTICE_NUM_TIME, val);
                if (Objects.nonNull(operateTime)){
                    //当前系统时间-操作的记录时间
                   long resultTime =  System.currentTimeMillis() - operateTime;
                   if (resultTime > FLAG_TIME){
                       hashOperations.delete(RedisKey.NOTICE_NUM_PERFIX, val);
                       log.info("删除通知的 uuid 为:{}", val);
                       hashOperations.delete(RedisKey.COMMENT_NUM_PERFIX, val);
                       log.info("删除评论通知的 uuid 为:{}", val);
               }
                }
            });
        }
    }

}

本篇小结

到这里关于互联网消息通知系统的设计与实现就分享完了,至于源码我看在周末或者假期有没有时间发出来,之后自己的个人 git 开源仓库应该已经建设好了。

文章如有错误和不足,还望指正,同时也欢迎大家在评论区说出自己的想法!

一、前言

大家好呀,我是summo,前面我已经写了我为啥要做这个
摸鱼小网站
的原因,从这篇文章开始我会一步步跟大家聊聊我是怎么搭起这个网站的。我知道对很多新手来说,建网站可能挺头大的,不知道从哪里开始,所以我会尽量写得简单明了,让大家一看就懂,少走弯路。

咱们先从买服务器开始说起。现在阿里云好像还有免费试用,对新手来说挺划算的。不过不管你是免费用还是掏钱买,拿到服务器后的第一件事,就是得把环境给整利索了。这就跟新房子装修一样,基础打好了,后面的事儿才能顺顺利利。对于我们这个
摸鱼小网站
来说,必须要的环境是JDK、Redis、MySQL,怎么搭建这些环境网上文章一大堆,但总是这有问题那有问题,所以我也写一份,出问题我们评论区交流。

二、购买云服务器(又称Elastic Compute Service, ECS)

点这个
链接
可以进入阿里云的官方网站,我买的是
2核2G,3M固定带宽
这种,一年99,持续3年。这里有一个点要注意下,我选的镜像是
CentOS 7.9 64位
,不同镜像安装环境的方式也有点不同,比如Centos一般使用yum下载安装包而Ubuntu使用apt-get,虽然大体上都差不多但是我只熟悉Centos。如果不懂这些的话,最好跟我选一样的吧。

如果你想先试用的话,滑到底部可以找到

买好之后,进入ECS控制台,可以找到你刚买的那台实例。我们可以看到,它有两个IP,
私有IP

公网IP
,公网IP是可以ping通的,如果到时候你的网站做好,就可以把这个公网IP分享给你的朋友,他们就可以直接访问你的网站啦。

点击
远程连接
可以远程登录到服务器上,进入控制界面后就可以开始部署你的环境啦!

第一次登录的时候需要设置一下密码,后面如果密码忘记了,也可以在这边重置密码。

三、安装jdk

JDK的话呢我就不装最近流行的17和21了,还是使用我们的老朋友JDK8吧,在Windows上安装JDK是比较麻烦的,还有环境变量什么的,但是在Centos7上安装是很方便的,不用担心,别看那些野文档就行。

1. 查看JDK版本

yum list |grep java-1.8.0

可以看到可选版本还是很多的,我们下载
java-1.8.0-openjdk-devel.x86_64
这个。

2. 下载JDK

yum install java-1.8.0-openjdk-devel.x86_64 -y

下载下来之后它会自动安装,环境变量都不用管,不用配这配那的,很方便。

3. 查看JDK环境

java -version

四、安装Redis

Redis的安装也很简单,几行命令就搞定了,跟着我的步骤来,大概率不会出现什么问题。

1. 下载Redis

sudo yum install epel-release -y
sudo yum update -y
sudo yum install redis -y

2. 启动Redis服务并设置开机自启

sudo systemctl start redis
sudo systemctl enable redis

3. 验证Redis是否正在运行

redis-cli ping

4. 远程连接Redis

我们都知道Redis的端口是6379,虽然服务器上Redis启动了,但是本地目前还是无法连接的,想要本地连接Redis,还需要一些设置。

(1)修改Redis配置支持公网访问

## 打开Redis配置文件
vim /etc/redis.conf

找到下面这三个参数:

## 绑定允许访问的ip
bind 127.0.0.1
## 保护模式开
protected-mode yes
## 请求访问的密码
#requirepass yourpassword 

一二项是组合项,尤为重要,第三项就是设置密码,可以起到权限认证的作用。修改为如下:

## 直接将这行注释掉
#bind 127.0.0.1
## 关闭保护模式
protected-mode no
## 设置你的密码
requirepass xxx

最后重启Redis

systemctl restart redis.service

(2)服务器防火墙上开放6379端口

## 查看防火墙状态
sudo systemctl status firewalld
## 打开防火墙
sudo systemctl start firewalld
## 开启6379对外访问
sudo firewall-cmd --zone=public --add-port=6379/tcp --permanent
## 重启防火墙
sudo firewall-cmd --reload
## 查看当前对外访问的端口
sudo firewall-cmd --zone=public --list-ports

只在服务器上开放6379端口还是不行的,还需要在阿里云ECS平台修改一下
安全组规则
,这个东西很多同学都不太清楚,我简单说一下:ECS安全组就是防止大家乱开放端口导致自己的服务被攻击,给大家做的一个兜底保护。

(3)阿里云安全组配置6379端口

点击管理规则

点击快速添加选择Redis,最后点击确定

很简单,对不对!但是如果我不说一下的话,大家很有可能半天都不知道为啥自己的Redis连接不上,关于这个安全组,还有些东西要注意,比如授权IP为0.0.0.0就是对所有网段都生效,后面还会经常配置,遇到了再说。

5. 连接工具

RedisInsight(推荐使用)

五、安装MySQL8.0

数据库对于我们这个
摸鱼小网站
来说是最为重要的,但是它的安装也是最为复杂。我也搜了不少资料,还是才踩了不少坑,如果大家实在是搞不定,
建议大家直接用阿里云那个免费3个月使用的RDS,效果更好

1. 下载MySQL8.0

执行下载命令

## 创建MySQL目录
mkdir /usr/local/mysql

## 切换到MySQL目录
cd /usr/local/mysql

## 下载MySQL安装包
wget https://cdn.mysql.com/archives/mysql-8.0/mysql-8.0.35-1.el7.x86_64.rpm-bundle.tar

## 解压MySQL安装包
tar -xvf mysql-8.0.35-1.el7.x86_64.rpm-bundle.tar

2. 准备安装环境

依次运行如下命令:

yum update -y

yum install -y libaio
yum install -y net-tools
yum install openssl-devel.x86_64 openssl.x86_64 -y

yum -y install autoconf
yum install perl.x86_64 perl-devel.x86_64 -y
yum install perl-JSON.noarch -y
yum install perl-Test-Simple -y

mariadb要卸载掉不然后面安装也会报错,不知道啥原因,查询已经安装的mariadb

## 'xxxx’代表 rpm -qa | grep mariadb 展示出来所有名字,有多个,则重复删除多次
rpm -qa | grep mariadb

## 务必删除干净
rpm -e --nodeps 文件名

以上命令是解决安装mysql8时的依赖环境问题。(如果以上命令运行失败可能会影响接下来的安装)

3. 安装MySQL8

在/usr/local/mysql8目录下,依次运行以下几个命令,安装MySQL8,注意如下命令运行顺序不能颠倒,必须严格按照以下顺序运行:

rpm -ivh mysql-community-common-8.0.35-1.el7.x86_64.rpm   
rpm -ivh mysql-community-client-plugins-8.0.35-1.el7.x86_64.rpm 
rpm -ivh mysql-community-libs-8.0.35-1.el7.x86_64.rpm
rpm -ivh mysql-community-client-8.0.35-1.el7.x86_64.rpm 
rpm -ivh mysql-community-icu-data-files-8.0.35-1.el7.x86_64.rpm
rpm -ivh mysql-community-server-8.0.35-1.el7.x86_64.rpm
rpm -ivh mysql-community-libs-compat-8.0.35-1.el7.x86_64.rpm
rpm -ivh mysql-community-embedded-compat-8.0.35-1.el7.x86_64.rpm
rpm -ivh mysql-community-devel-8.0.35-1.el7.x86_64.rpm  

4. 设置配置文件

编辑/etc/my.cnf文件,可以为MySQL设置如下参数(按实际需求选择即可)

[mysqld]
# 设置 MySQL 服务运行的端口号
port = 3306
# 指定 MySQL 数据库的默认字符集
character-set-server=utf8mb4
# 允许外部访问
bind-address=0.0.0.0

# 设置client连接mysql时的字符集,防止乱码
init_connect='SET NAMES utf8mb4'

# 设置 MySQL 允许访问的最大连接数
max_connections = 1000

# 指定MySQL服务器存储数据文件的目录
datadir=/var/lib/mysql

# 是否对sql语句大小写敏感,1表示不敏感
lower_case_table_names = 1

# 事务隔离级别,默认为可重复读,mysql默认可重复读级别(此级别下可能参数很多间隙锁,影响性能)
transaction_isolation = READ-COMMITTED

# TIMESTAMP如果没有显示声明NOT NULL,允许NULL值
explicit_defaults_for_timestamp = true

5. 目录授权

chown -R mysql:mysql /var/lib/mysql/

6. 启动MySQL

## 启动MySQL
systemctl start mysqld.service

## 查看MySQL状态
systemctl status mysqld.service

## 设置开启自启动
systemctl enable mysqld

7. 连接MySQL

(1)配置MySQL支持远程访问

启动mysql后会生成一个临时密码,使用此命令可以查询到临时密码,

grep "A temporary password" /var/log/mysqld.log

使用这个密码登录到MySQL里面,登录命令为
mysql -u root -p
按enter后输入刚才看到的临时密码。
进去后,我们为root用户设置一个新的密码,命令如下:

# 修改密码,注意密码强度校验,这里把密码设置为 xxx(你的密码)
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'xxx(你的密码)';
# 刷新权限
FLUSH PRIVILEGES;

设置允许root远程登录

UPDATE mysql.user SET host='%' WHERE user='root';
FLUSH PRIVILEGES;

(2)服务器防火墙上开放3306端口

## 开启6379对外访问
sudo firewall-cmd --zone=public --add-port=3306/tcp --permanent
## 重启防火墙
sudo firewall-cmd --reload
## 查看当前对外访问的端口
sudo firewall-cmd --zone=public --list-ports

同Redis一样,还需要去阿里云的安全组中放开3306端口。

(3)阿里云安全组配置3306

点击快速添加选择MySQL,最后点击确定

8. 连接工具

可以选择Navicat(最好用,但收费)、DBeaver(还不错,免费)、MySQL-Front (还不错,免费)

六、小结一下

曾几何时,安装环境和搭脚手架是我最害怕的事,太多的知识盲区,失败不知道原因,这玩意就算别人文档写的再细致,轮到自己安装时却总是遇到奇奇怪怪问题,有时都怀疑自己是不是BUG体质。在这里我提醒大家一下,如果是新手,安装环境时千万别给自己加戏,考虑这考虑那,这个不安全那个要个性一点,千万别这样搞,失败千万次不如成功一次!

我这次的安装教程是直接在服务器上操作的,安装包都是用命令下载的,出问题的概率应该不大,有问题评论区交流。正如开篇所说这就跟新房子装修一样,基础打好了,后面的事儿才能顺顺利利。不要心急,工程化相比于研究源码有意思多了,获得感很强。全军出动,大家加油!

大多数同学大多数时间都只是在干CRUD的活,也没有独立建站的经验,虽然没有但是可以学!现在不会练练就会!100块钱的实操经验绝对比100块钱买的专栏更有意义更有用!

最后,
自建摸鱼网站
,各大网站热搜一览,上班和摸鱼很配哦!

开心一刻

快 8 点了,街边卖油条的还没来,我只能给他打电话

大哥在电话中说到:劳资卖了这么多年油条,从来都是自由自在,自从特么认识了你,居然让我有了上班的感觉!

你让我有了上班的感觉

Session 共享

SpringBoot
session 共享配置,我相信你们都会,但出于负责的态度,我还是给你们演示一遍

  1. 添加依赖

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>com.qsl</groupId>
        <artifactId>spring-boot-session-demo</artifactId>
        <version>1.0-SNAPSHOT</version>
    
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.7.18</version>
        </parent>
    
        <properties>
            <maven.compiler.source>8</maven.compiler.source>
            <maven.compiler.target>8</maven.compiler.target>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.session</groupId>
                <artifactId>spring-session-data-redis</artifactId>
            </dependency>
        </dependencies>
    </project>
    
  2. 添加配置

    文件配置
    application.yml

    spring:
      session:
        store-type: redis
      redis:
        timeout: 3000
        password: 123456
        host: 10.5.108.226
        port: 6379
    

    注解配置

    @SpringBootApplication
    @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 900, redisNamespace = "session-demo")
    public class SessionApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(SessionApplication.class, args);
        }
    }
    

    两个配置项需要说明下


    maxInactiveIntervalInSeconds:session 有效时长,单位是秒,示例中 session 有效时长是 900s

    redisNamespace:redis 命名空间,即将 session 信息存于 redis 的哪个命名空间下,没有会创建,示例中是 session-demo

  3. 操作 session

    为了简化,直接提供接口设置和访问 session

    @RestController
    @RequestMapping("hello")
    public class HelloController {
        @GetMapping("/set")
        public String set(HttpSession session) {
            session.setAttribute("user", "qsl");
            return "qsl";
        }
        @GetMapping("/get")
        public String get(HttpSession session) {
            return session.getAttribute("user") + "";
        }
    }
    

至此,搭建就算完成了,启动后访问

http://localhost:8080/hello/set

然后去 redis 看 session 信息

redis_session

有效时长为什么是
870
而不是
900
,请把头伸过来,我悄悄告诉你

20230115143049

我就问你们,
SpringBoot Session
共享是不是很简单?但就是这么简单的内容,竟然有人往里面下毒,而我很不幸的成了那个中毒之人,如果不是我有绝招,说不定就噶过去了,具体细节且听我慢慢道来

配置不生效

实际项目中,我也是按如上配置的,可 redis 中的存放内容却是

异现象

从结果来看,session 确实是共享了,但为什么
maxInactiveIntervalInSeconds

redisNamespace
配置都未生效?我还特意去对比了另外一个项目,一样的配置流程,那个项目的
命名空间

有效时长
都是正常生效的,而此项目却未生效,这就让我彻底懵圈了

懵

debug 源码

该尝试的都尝试了,
maxInactiveIntervalInSeconds

redisNamespace
始终不生效,没有办法了,只能上绝招了

debug 调试源码

问题又来了:断点打在哪?有两个地方需要打断点

  1. RedisHttpSessionConfiguration#sessionRepository

    跟进到
    @EnableRedisHttpSession
    注解里面,会看到
    @Import(RedisHttpSessionConfiguration.class)
    ,跟进
    RedisHttpSessionConfiguration
    ,会看到被
    @Bean
    修饰的
    sessionRepository
    方法,正常情况下,
    SpringBoot
    启动过程中会调用该方法,我们在该方法第一行打个断点


    sessionRepository 断点
  2. SpringHttpSessionConfiguration#springSessionRepositoryFilter

    注意看
    RedisHttpSessionConfiguration
    的完整定义

    @Configuration(proxyBeanMethods = false)
    public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration
    		implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware
    

    它继承
    SpringHttpSessionConfiguration
    ,跟进去你会发现有个被
    @Bean
    修饰的
    springSessionRepositoryFilter
    方法,正常情况下,
    SpringBoot
    启动过程中也会调用该方法,我们也在该方法第一行打个断点


    springSessionRepositoryFilter 断点

打完断点后,重新以
debug
方式进行启动,我们会发现最先来到
springSessionRepositoryFilter
的断点

springSessionRepositoryFilter 断点进入

然后我们按
F9
,会发现项目启动完了都没有来到
RedisHttpSessionConfiguration#sessionRepository
的断点,这是为什么?
SpringHttpSessionConfiguration#springSessionRepositoryFilter
方法有个参数
SessionRepository<S> sessionRepository
,它依赖
RedisIndexedSessionRepository
实例,也就说
RedisHttpSessionConfiguration#sessionRepository
应该被先调用,
sessionRepository
方法都没有被调用,那
springSessionRepositoryFilter
方法的参数实例是个什么鬼?我们再次以
debug
方式启动

springSessionRepositoryFilter 参数

怎么是
RedisOperationsSessionRepository
,为什么不是
RedisIndexedSessionRepository
?我们来看看
RedisOperationsSessionRepository

RedisOperationsSessionRepository

它继承了
RedisIndexedSessionRepository
,重点是它被
@Deprecated
了呀,怎么还会创建该类型的实例,它是哪里被实例化了?按住
ctrl
键,鼠标左击
RedisOperationsSessionRepository

RedisOperationsSessionRepository 被调用

点进
RedisConfig
一看吓一跳

RedisOperationsSessionRepository 实例化

一看提交记录,竟然是
2021-09-26
提交的,一看提交人,好家伙,早就离职了!

代码里下毒

我估摸着,当初想做 session 共享,但是开发到了一半,直接离职了,你说你离职就离职吧,为什么要提交这一半代码,真的是,气的我牙都咬碎了!

注释掉
RedisConfig
后重启,一切恢复正常,
maxInactiveIntervalInSeconds

redisNamespace
都正常生效;实际工作开发中,此事就完结了,不要再去细扣了,除非你确实闲的蛋疼。但话说回来,你们都来看博客了,那确实是闲,既然你们这么闲,那我们继续扣一扣,扣什么呢

为什么我们指定 RedisOperationsSessionRepository 后,RedisHttpSessionConfiguration#sessionRepository 方法不被调用,而且 maxInactiveIntervalInSeconds 、redisNamespace 不生效

  1. RedisHttpSessionConfiguration#sessionRepository 为什么没被调用

    不管是我们自定义的
    RedisConfig#redisOperationsSessionRepository
    ,还是 SpringBoot 的
    RedisHttpSessionConfiguration#sessionRepository
    ,都会在启动过程中被 SpringBoot 解析成
    BeanDefinition
    ,至于如何解析的,这就涉及到
    @Configuration
    的解析原理,不了解的可以先看看:
    spring-boot-2.0.3源码篇 - @Configuration、Condition与@Conditional
    。另外,BeanDefinition 的扫描是有先后顺序的,详情请看:
    三探循环依赖 → 记一次线上偶现的循环依赖问题

    回到我们的案例,那么
    RedisConfig#redisOperationsSessionRepository
    会先于
    RedisHttpSessionConfiguration#sessionRepository
    扫描成 BeanDefinition


    bean定义顺序

    而紧接着的 bean 实例化就是按着这个顺序进行的,也就说
    RedisConfig#redisOperationsSessionRepository
    会先被调用;我们把重点放到名字叫做
    sessionRepository
    的 bean 的实例化过程上。这里补充个
    debug
    小技巧,因为 bean 很多,而我们只关注其中某个 bean 的实例化,可以借助 IDEA 的
    Condition
    来实现


    idea 条件debug

    然后按
    F9
    ,会直接来到
    sessionRepository
    实例化过程,然后经过
    getBean(String name)
    来到
    doGetBean


    doGetBeanpng

    跟进
    transformedBeanName
    方法,继续跟进来到
    canonicalName


    anonialName

    这是重点,大家看仔细了,根据别名递归读取主名,返回最后那个主名,是不是这么个逻辑?然而新的疑问又来了


    哪来的别名、主名呀


    常规情况下,bean 只有一个名字,也就是主名,使用
    @Bean
    的时候如果没有指定名字,那么名字默认就是方法名,而如果指定了名字就采用指定的名字;支持指定多个名字,第一个是主名,后面的都是别名


    主名别名

    aliasMap

    所以,根据别名
    sessionRepository
    就得到了
    redisOperationsSessionRepository
    这个主名


    sessionRepository被替换OperationsSessionRepository

    而名叫
    redisOperationsSessionRepository
    的 bean 已经被创建过了,类型是
    RedisOperationsSessionRepository
    ,直接从容器中获取,然后返回;所以
    RedisHttpSessionConfiguration#sessionRepository
    没被调用,你们明白了吗?

    回到最初的问题,如果不注释
    RedisConfig
    ,而只是拿掉别名
    sessionRepository

    @Configuration
    public class RedisConfig {
    
    	@Autowired
    	private RedisTemplate redisTemplate;
    
    	@Bean({"redisOperationsSessionRepository"})
    	public RedisOperationsSessionRepository redisOperationsSessionRepository() {
    		return new RedisOperationsSessionRepository(redisTemplate);
    	}
    }
    

    问题能不能得到解决?

    总结下


    根据扫描先后循序,RedisConfig#redisOperationsSessionRepository 的 BeanDefinition 排在 RedisHttpSessionConfiguration#sessionRepository 前面,所以 bean 实例创建的时候,RedisOperationsSessionRepository 实例会被先创建,而这个实例的别名
    sessionRepository
    正好与 RedisHttpSessionConfiguration#sessionRepository 名字重复,所以不会调用 RedisHttpSessionConfiguration#sessionRepository 来创建实例,而是直接返回已经创建好的 RedisOperationsSessionRepository 实例

  2. maxInactiveIntervalInSeconds 、redisNamespace 为什么不生效

    大家注意看
    RedisConfig

    @Configuration
    public class RedisConfig {
    
    	@Autowired
    	private RedisTemplate redisTemplate;
    
    	@Bean({"redisOperationsSessionRepository", "sessionRepository"})
    	public RedisOperationsSessionRepository redisOperationsSessionRepository() {
    		return new RedisOperationsSessionRepository(redisTemplate);
    	}
    }
    

    试问如何让 maxInactiveIntervalInSeconds 、redisNamespace 生效?

    既然官方已经把
    RedisOperationsSessionRepository
    废弃了,我们就不要纠结它了,直接不用它!

总结

  1. SpringBoot Session 共享配置很简单,如果配置好了结果不对,不要怀疑自己,肯定是有人在代码里下毒了
  2. 压箱底的东西(debug 源码)虽说不推荐用,但确实是一个万能的方法,不要求你们精通,但必须掌握
  3. 作为一个开发者,一定要有职业素养,开发一半的代码就不要提交了,着实坑人呀!