单体应用(单一职责、依赖注入)
flowchart LR
style A fill:#FFDDC1,stroke:#FF6F61,stroke-width:2px
style B fill:#FFEBCD,stroke:#FFA500,stroke-width:2px
style C fill:#E6E6FA,stroke:#9370DB,stroke-width:2px
style D fill:#D1F2EB,stroke:#1ABC9C,stroke-width:2px
style E fill:#FFEBE8,stroke:#F08080,stroke-width:2px
A[Handler] <--> B[Service]
B <--> C[Repository]
C <--> D[DAO]
C <--> E[Cache]
- Handler:负责接收和解析 HTTP 请求,调用 Service 层进行业务处理,然后返回响应
- Service:处理业务逻辑,协调 Repository 层和其他业务操作
- Repository:上层业务逻辑和数据层的桥梁,不关心数据存储的具体实现
- DAO(Data Access Object):与数据库交互,执行具体的 CRUD 操作
- Cache:通用的缓存层,减少对数据库的频繁访问,提高系统的读性能
拆分微服务
graph LR
style A fill:#E3F2FD,stroke:#64B5F6,stroke-width:2px
style B fill:#BBDEFB,stroke:#42A5F5,stroke-width:2px
style C fill:#90CAF9,stroke:#2196F3,stroke-width:2px
style D fill:#64B5F6,stroke:#1E88E5,stroke-width:2px,color:#000
A[单体应用-混沌一片] -->|模块划分| B[单体应用-模块化]
B -->|模块独立维护| C[单体应用-模块依赖化]
C -->|模块独立部署| D[微服务化]
- 拆分为模块化(代码抽离,模块独立)
- 改造服务端(添加 RPC 接口和服务)
- 集成测试代码调整( service 层 → rpc server 层)
- 改造客户端(替换原来用到该模块 service 接口的代码)
开发环境
IDE🧑💻: GoLand
OS🪟🐧:Ubuntu 22.04.3 LTS (WSL2)
开发计划
- 用户服务 👤
- 注册、登录态校验与刷新
- 保护登录系统
- 短信验证码登录
- 长短 Token 与退出
- 微信扫码登录
- 权限模型 (RBAC)
- 接入配置模块 ⚙️
- 接入日志模块 📋️
- 系统监控模块 📹️
- 文章服务 📃
- 阅读、点赞和收藏
- 榜单模型和缓存
- 分布式任务调度
- 评论服务 ✍
- 降级策略逻辑
- 异步写入数据库
- 热门资源评论缓存预加载
- 用户关系 🧩
- 搜索服务 🔍
- 即时通讯 💬
- 单元/集成测试 ✅
用户服务
用户模块是一个非常常见的模块,几乎所有系统在记录用户信息时都需要这个模块。
- 登录系统的关键问题包括安全性、可用性和性能。安全性是最重要的,其次是可用性。虽然性能也很关键,但相对而言,登录是一个低频行为,因此性能问题不如前两者重要。如果用户频繁使用系统,则不应该让用户每次都登录。
- 安全问题主要关注的是如果有人试图入侵系统,登录过程将成为他们攻击的重点。相关知识点包括:
- 加密算法
- 保护 session 或 JWT token,并在这些信息泄露后,尽可能识别伪装成真实用户的攻击者
- 可用性问题则涉及到保证登录系统的稳定性,不会轻易崩溃。要提供多种登录方式,以便即使一种途径崩溃,仍有其他途径可用。例如,短信登录需要确保短信服务不会崩溃,或者在崩溃时提供备用方案。
- 在资源管理方面,可以考虑 JWT token 和长短期 token 的方案,需要理解使用 Session 和 JWT 的区别。
短信服务 基本的功能实现是比较简单的,重点难点在于:
- 需求分析能力:当实现像“验证码登录”这样的复合功能时,需要识别出应拆分出的独立模块。
- 接口抽象与扩展:掌握如何抽象出接口,并在接口基础上使用装饰器来扩展功能的技巧。
- 重试机制:重试是常用的策略,需要考虑重试的间隔和次数。
- 故障转移:简单的故障 转移方式是直接轮询,复杂的则需要动态判断下游服务的状态。
- 客户端限流:实现客户端限流的策略
- 同步转异步:转为异步后,需要考虑如何确保短信最终会发出去
- 要注意并发问题,在这一过程中,Lua 脚本的使用尤为重要,虽然 Redis 是线程安全的,但它不能保证在连续执行多个命令时不会插入其他命令。
文章服务
- 理解内容生产平台常见的制作库和线上库的设计思路。虽然被称为制作库和线上库,它们可以是不同的数据库,也可以是同一数据库中的不同表。
- 文档型数据库:类似于 MongoDB 的 NoSQL 文档型数据库非常适合存储文章、帖子等内容。
- 多媒体存储:对于真正的多媒体内容,如图片、视频、音频等,使用 OSS(对象存储服务)加 CDN(内容分发网络)会更为合适。
- 数据库事务:需要学习手动控制事务,以及使用闭包事务。虽然闭包事务能够满足大多数场景的需求,但在某些特定场景下,仍需手动控制事务。 榜单功能
- 常见的榜单模型,只需要有基本了解,核心是记住榜单通常与用户行为正相关,与文章发表时间负相关。
- 榜单的整体计算过程稍微复杂,需要注意以下几点:
- 优先级队列:用于维持榜单的前 n 名数据。
- 分批次计算:因为无法一次性将所有数据加载到内存中,所以需要分批计算。
- 榜单的查询接口需要强调高并发和高可用性(位于应用的首页):
- 数据库崩溃:考虑到如果数据库崩溃,榜单是否仍能正确查询。
- Redis 崩溃:考虑到如果 Redis 崩溃,榜单是否仍能正确查询。通常,使用 Redis 作为缓存就足够了。但在追求极高性能时,Redis 可能无法满足需求,需要使用本地缓存。此外,本地缓存未命中的情况也要尽可能避免。
- 极致性能:在追求极致性能的情况下,本地缓存可能无法满足需求,可能需要考虑借助 CDN 等技术。
- 榜单的计算本身是一个定时任务,因此涉及到定时任务的调度。
- 节点选择:有些定时任务,如榜单计算,只需要一个节点来执行。因此需要调度一个节点来执行任务,而不是所有节点都去执行,以避免资源浪费。
- 负载均衡:在定时任务调度时,需要考虑负载均衡的问题。如果所有任务都被调度到同一个节点执行,该节点可能会过载。因此,需要选择负载最低的节点进行任务执行。在执行过程中,如果发现节点负载过高,立即中断任务并选择其他节点执行。
第三方库
- gin-gonic/gin - HTTP web 框架
- Middleware - Collection of middlewares created by the community
- cors - Official cross-origin resource sharing (CORS) gin's middleware
- sessions - Gin middleware for session management
- dlclark/regexp2 - full-featured 正则表达式
- go-gorm/gorm - The fantastic ORM library for Golang
- go-gorm/mysql - GORM mysql driver
- golang-jwt/jwt - Golang implementation of JSON Web Tokens (JWT)
- tencentcloud-sdk-go - Tencent Cloud API 3.0 SDK for Golang
- 腾讯云 SMS 个人用户无法使用短信服务 API
shansuma - 闪速码 SMS 的 API 接口- wire - Compile-time Dependency Injection for Go
- ekit - 支持泛型的工具库
- mock - GoMock is a mocking framework for the Go programming language
- go-sqlmock - Sql mock driver for golang to test database interactions
- viper - Go configuration with fangs
- etcd - Distributed reliable key-value store for the most critical data of a distributed system
- zap - Blazing fast, structured, leveled logging in Go
- mongo-go-driver - The Official Golang driver for MongoDB
- sarama - Sarama is a Go library for Apache Kafka
- prometheus/client_golang - Prometheus instrumentation library for Go applications
- cron - a cron library for go 定时任务
redis-lock - 基于 Redis 实现的分布式锁- protobuf - Go support for Google's protocol buffers
- grpc-go - The Go language implementation of gRPC. HTTP/2 based RPC
环境/工具
- Node.js
- Docker
- 镜像源(还是挂代理方便)
- mysql - An open-source relational database management system (RDBMS)
- redis - An open-source in-memory storage
- etcd - A distributed key-value store designed to securely store data across a cluster
- mongo - MongoDB document databases provide high availability and easy scalability
- kafka - Apache Kafka is a distributed streaming platform used for building real-time applications
- prometheus - The Prometheus monitoring system and time series database
- grafana - The open observability platform
- zipkin - A distributed tracing system
- kubernates
- Kubernetes cluster architecture
- kubectl - The Kubernetes command-line tool
- HELM - The package manager for Kubernetes
- ingress-nignx - Ingress-NGINX Controller for Kubernetes
- wrk - Modern HTTP benchmarking tool
- protobuf - Protocol Buffers - Google's data interchange format
- buf - The best way of working with Protocol Buffers
面向失败编程
面向失败编程(Failure-oriented Programming,FOP)是一种编程范式,强调在编写逻辑时尽可能考虑边界条件和失败情况,以增强程序的稳定性。 简单来说,就是时刻考虑系统可能会崩溃。无论是系统本身、依赖的服务还是依赖的数据库,都可能会崩溃。 面向失败编程不仅仅是对输入进行校验,它还包括:
- 错误处理:需要严密处理各种可能的错误情况
- 容错设计:长期培养的能力是针对业务和系统特征设计容错策略。这通常是较难掌握的,而其余部分可以通过规范来达成。 在面向失败编程中,需要长期培养的能力是针对业务和系统特征设计容错策略。其他方面较容易掌握,或者公司可以通过规范来达成。 在项目中,讨论了许多容错方案,包括:
- 重试机制:需要考虑重试的间隔和次数,以及最后可能需要人工介入。
- 监控与告警:在追求高可用时,还要考虑自动修复的程度
- 限流:用于保护系统本身。
- 下游服务治理:如果下游服务可能崩溃,需使用一些治理技巧:
- 轮询:可以是每次都轮询,也可以针对某个下游节点失败后的限流。
- 客户端限流:限制客户端的请求速率以保护系统资源。
- 同步转异步:在转为异步后,必须保证请求会被处理而不会遗漏
- 考虑安全性:例如,防止 token 泄露以增强系统的安全性。 在设计容错方案时,尽可能在平时收集别人使用的容错方案,以了解各种处理方式。根据自己实际处理的业务设计合适的容错方案。简单地生搬硬套别人的方案,效果可能不佳。
灵活的缓存方案
在整个单体应用中,已经充分接触了缓存方案。相比传统的缓存方案,项目中的缓存方案更具“趣味性”。在实践中,除非逼不得已,通常不会使用看起来非常特殊的缓存方案。 使用过和讨论过的缓存方案包括:
- 只使用 Redis:更新缓存的常见方案是更新数据库后删除缓存。
- 本地缓存与 Redis 缓存结合使用。大多数系统完成这些步骤即可,
- 查找顺序:本地缓存 - Redis - 数据库
- 更新顺序:数据库 - 本地缓存 - Redis
- 根据业务特征动态设置缓存的过期时间。例如,如果能判定某个用户是大 V,则他的数据过期时间应设得更长。
- 淘汰对象:根据业务特征来淘汰缓存对象。
- 缓存崩溃:需要考虑缓存崩溃的问题。在实践中,缓存崩溃可能导致数据库也一起崩溃。 在上述缓存方案的基础上,需要能够举一反三,根据业务特征设计针对性的解决方案。在整个职业生涯中,如果能有效使用缓存,就能解决 90% 的性能问题。剩下的 10% 则需要依靠各种技巧和优化手段。
注意并发问题
无论是代码中的 Go 实例,还是外部数据库,在实现任何功能时操作对象或 Redis 缓存数据时,都必须考虑并发问题。具体来说,需要关注是否有多个 goroutine 在同一时刻读写对象,这些 goroutine 可能在不同的实例(机器)上,也可能在同一实例(机器)上。 在项目中,使用了多种方法来解决并发问题:
- SELECT FOR UPDATE:用于确保读取的数据在操作期间不会被修改,简单且有效。
- 分布式锁:用于保证同一时刻只有一个 goroutine 可以执行特定操作。
- Lua 脚本:在 Redis 中使用 Lua 脚本来确保在执行多个操作时没有其他 goroutine 修改 Redis 数据。
- 乐观锁:使用数据库 version 加 CAS(Compare and Swap)机制来保证在修改数据时,数据未被其他操作修改过。
- Go 对象锁:使用
sync.Mutex
和sync.RWMutex
来管理对 Go 对象的并发访问,在某些情况下,还可以使用原子操作(atomic
包)来处理简单的并发问题。 在实践中,只能通过长期训练来培养并发意识。在项目开始时,就应有意识地培养自己对并发问题的关注和敏感度。
依赖注入
首先要整体上领悟依赖注入和面向接口编程的优势,这些优点在项目中体现得非常明显:
- 依赖注入完全达成了控制反转的目标。不再关心如何创建依赖对象。例如,在 cache 模块中,虽然使用了 Redis 客户端,但 cache 实现并不关心具体的实现或客户端的相关参数。
- 依赖注入提高了代码的可测试性。可以在单元测试中注入由
gomock
生成的实例。在集成测试阶段,为了节省公司资源,第三方依赖通常被替换为内存实现或 mock 实现。 - 依赖注入叠加面向接口编程后,装饰器模式效果更佳。在 sms 模块中,有各种装饰器的实现,这些实现都是基于面向接口编程和依赖注入的。这使得装饰器可以自由组合,提升了系统的灵活性和扩展性。