Repository: tikv/pd
Pull request #9354: feat(resourcemanager): add service limit for keyspace
Author: login: JmPotato
Labels: name: component/keyspace
description: Key space.
name: size/XXL
description: Denotes a PR that changes 1000+ lines, ignoring generated files.
name: release-note-none
description: Denotes a PR that doesn't merit a release note.
name: component/resource-control
name: lgtm
name: approved
name: dco-signoff: yes
description: Indicates the PR's author has signed the dco.
Created: 2025-05-26T09:44:38Z | Merged: 2025-05-30T13:42:05Z

<!--

Thank you for working on PD! Please read PD's [CONTRIBUTING](https://github.com/tikv/pd/blob/master/CONTRIBUTING.md) document **BEFORE** filing this PR.

PR Title Format:
1. pkg [, pkg2, pkg3]: what's changed
2. *: what's changed

-->

### What problem does this PR solve?

<!--

Please create an issue first to describe the problem.
There MUST be one line starting with "Issue Number:  " and 
linking the relevant issues via the "close" or "ref".
For more info, check https://github.com/tikv/pd/blob/master/CONTRIBUTING.md#linking-issues.

-->
Issue Number: ref #9296.

### What is changed and how does it work?

<!--

You could use the "commit message" code block to add more description to the final commit message.
For more info, check https://github.com/tikv/pd/blob/master/CONTRIBUTING.md#format-of-the-commit-message.

-->

```commit-message
Add a keyspace-level service limiter to control the maximum request rate for each keyspace.
The service limit is an unburstable RU per second rate limit that applies across all resource groups within a keyspace.
```

### Check List

<!-- Remove the items that are not applicable. -->

Tests

<!-- At least one of these tests must be included. -->

- Unit test
- Integration test
- Manual test (add detailed scripts or steps below)

With a resource group rg1 whose RU is 5000 and is non-burstable, set the service limit to 10000 RU > 5000 RU:

<img width="2354" alt="image" src="https://github.com/user-attachments/assets/88f849d2-9d6c-42db-988b-c7bd7bd4ba1b" />

<img width="650" alt="image" src="https://github.com/user-attachments/assets/8dda5d40-9bc3-40f8-92ee-9b978333f3f6" />

Set the service limit to 2500 RU:

<img width="2360" alt="image" src="https://github.com/user-attachments/assets/aefc41cc-47e3-498a-8764-9d52fedef93b" />

<img width="675" alt="image" src="https://github.com/user-attachments/assets/d6168eac-0d0c-45ab-9db1-4b19e0531de2" />

rg1 5000 RU + rg2 2500 RU with 2500 RU service limit.

![image](https://github.com/user-attachments/assets/da6eae22-3cfd-485c-949f-a0cda9dc0af9)

### Release note

<!--

A bugfix or a new feature needs a release note. If there is no need to give a release note, just leave it with the `None`.

Please refer to [Release Notes Language Style Guide](https://pingcap.github.io/tidb-dev-guide/contribute-to-tidb/release-notes-style-guide.html) to write a quality release note.

-->

```release-note
None.
```



Diff:
diff --git a/pkg/mcs/resourcemanager/server/apis/v1/api.go b/pkg/mcs/resourcemanager/server/apis/v1/api.go
index 8150e8f0b6e..6bb0d90fab2 100644
--- a/pkg/mcs/resourcemanager/server/apis/v1/api.go
+++ b/pkg/mcs/resourcemanager/server/apis/v1/api.go
@@ -99,6 +99,12 @@ func (s *Service) RegisterRouter() {
 	configEndpoint.DELETE("/group/:name", s.deleteResourceGroup)
 	configEndpoint.GET("/controller", s.getControllerConfig)
 	configEndpoint.POST("/controller", s.setControllerConfig)
+	// Without keyspace name, it will get/set the service limit of the null keyspace.
+	configEndpoint.POST("/keyspace/service-limit", s.setKeyspaceServiceLimit)
+	configEndpoint.GET("/keyspace/service-limit", s.getKeyspaceServiceLimit)
+	// With keyspace name, it will get/set the service limit of the given keyspace.
+	configEndpoint.POST("/keyspace/service-limit/:keyspace_name", s.setKeyspaceServiceLimit)
+	configEndpoint.GET("/keyspace/service-limit/:keyspace_name", s.getKeyspaceServiceLimit)
 }
 
 func (s *Service) handler() http.Handler {
@@ -239,3 +245,62 @@ func (s *Service) setControllerConfig(c *gin.Context) {
 	}
 	c.String(http.StatusOK, "Success!")
 }
+
+// KeyspaceServiceLimitRequest is the request body for setting the service limit of the keyspace.
+type KeyspaceServiceLimitRequest struct {
+	ServiceLimit float64 `json:"service_limit"`
+}
+
+// SetKeyspaceServiceLimit
+//
+//	@Tags		ResourceManager
+//	@Summary	Set the service limit of the keyspace. If the keyspace is valid, the service limit will be set.
+//	@Param		keyspace_name		path	string	true	"Keyspace name"
+//	@Param		service_limit	body		object	true	"json params, keyspaceServiceLimitRequest"
+//	@Success	200				{string}	string	"Success!"
+//	@Failure	400				{string}	error
+//	@Router		/config/keyspace/service-limit/{keyspace_name} [post]
+func (s *Service) setKeyspaceServiceLimit(c *gin.Context) {
+	keyspaceName := c.Param("keyspace_name")
+	keyspaceIDValue, err := s.manager.GetKeyspaceIDByName(c, keyspaceName)
+	if err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	var req KeyspaceServiceLimitRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	if req.ServiceLimit < 0 {
+		c.String(http.StatusBadRequest, "service_limit must be non-negative")
+		return
+	}
+	s.manager.SetKeyspaceServiceLimit(keyspaceIDValue.GetValue(), req.ServiceLimit)
+	c.String(http.StatusOK, "Success!")
+}
+
+// GetKeyspaceServiceLimit
+//
+//	@Tags		ResourceManager
+//	@Summary	Get the service limit of the keyspace. If the keyspace name is empty, it will return the service limit of the null keyspace.
+//	@Param		keyspace_name	path		string	true	"Keyspace name"
+//	@Success	200				{string}	json	format	of	rmserver.serviceLimiter
+//	@Failure	400				{string}	error
+//	@Failure	404				{string}	error
+//	@Router		/config/keyspace/service-limit/{keyspace_name} [get]
+func (s *Service) getKeyspaceServiceLimit(c *gin.Context) {
+	keyspaceName := c.Param("keyspace_name")
+	keyspaceIDValue, err := s.manager.GetKeyspaceIDByName(c, keyspaceName)
+	if err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	keyspaceID := keyspaceIDValue.GetValue()
+	limiter := s.manager.GetKeyspaceServiceLimiter(keyspaceID)
+	if limiter == nil {
+		c.String(http.StatusNotFound, fmt.Sprintf("keyspace manager not found with keyspace name: %s, id: %d", keyspaceName, keyspaceID))
+		return
+	}
+	c.IndentedJSON(http.StatusOK, limiter)
+}
diff --git a/pkg/mcs/resourcemanager/server/grpc_service.go b/pkg/mcs/resourcemanager/server/grpc_service.go
index 6003bcec9f5..177bd5dfb71 100644
--- a/pkg/mcs/resourcemanager/server/grpc_service.go
+++ b/pkg/mcs/resourcemanager/server/grpc_service.go
@@ -174,15 +174,27 @@ func (s *Service) AcquireTokenBuckets(stream rmpb.ResourceManager_AcquireTokenBu
 		if err := s.checkServing(); err != nil {
 			return err
 		}
-		targetPeriodMs := request.GetTargetRequestPeriodMs()
-		clientUniqueID := request.GetClientUniqueId()
-		resps := &rmpb.TokenBucketsResponse{}
+		var (
+			targetPeriodMs = request.GetTargetRequestPeriodMs()
+			clientUniqueID = request.GetClientUniqueId()
+			resps          = &rmpb.TokenBucketsResponse{}
+			logFields      = make([]zap.Field, 2)
+		)
 		for _, req := range request.Requests {
+			keyspaceID := extractKeyspaceID(req.GetKeyspaceId())
 			resourceGroupName := req.GetResourceGroupName()
+			logFields[0] = zap.Uint32("keyspace-id", keyspaceID)
+			logFields[1] = zap.String("resource-group", resourceGroupName)
+			// Get keyspace resource group manager to apply service limit later.
+			krgm := s.manager.getKeyspaceResourceGroupManager(keyspaceID)
+			if krgm == nil {
+				log.Warn("keyspace resource group manager not found", logFields...)
+				continue
+			}
 			// Get the resource group from manager to acquire token buckets.
-			rg := s.manager.GetMutableResourceGroup(extractKeyspaceID(req.GetKeyspaceId()), resourceGroupName)
+			rg := s.manager.GetMutableResourceGroup(keyspaceID, resourceGroupName)
 			if rg == nil {
-				log.Warn("resource group not found", zap.String("resource-group", resourceGroupName))
+				log.Warn("resource group not found", logFields...)
 				continue
 			}
 			// Send the consumption to update the metrics.
@@ -202,7 +214,7 @@ func (s *Service) AcquireTokenBuckets(stream rmpb.ResourceManager_AcquireTokenBu
 				var tokens *rmpb.GrantedRUTokenBucket
 				for _, re := range req.GetRuItems().GetRequestRU() {
 					if re.Type == rmpb.RequestUnitType_RU {
-						tokens = rg.RequestRU(now, re.Value, targetPeriodMs, clientUniqueID)
+						tokens = rg.RequestRU(now, re.Value, targetPeriodMs, clientUniqueID, krgm.getServiceLimiter())
 					}
 					if tokens == nil {
 						continue
@@ -210,10 +222,11 @@ func (s *Service) AcquireTokenBuckets(stream rmpb.ResourceManager_AcquireTokenBu
 					resp.GrantedRUTokens = append(resp.GrantedRUTokens, tokens)
 				}
 			case rmpb.GroupMode_RawMode:
-				log.Warn("not supports the resource type", zap.String("resource-group", resourceGroupName), zap.String("mode", rmpb.GroupMode_name[int32(rmpb.GroupMode_RawMode)]))
+				log.Warn("not supports the resource type",
+					append(logFields, zap.String("mode", rmpb.GroupMode_name[int32(rmpb.GroupMode_RawMode)]))...)
 				continue
 			}
-			log.Debug("finish token request from", zap.String("resource-group", resourceGroupName))
+			log.Debug("finish token request from", logFields...)
 			resps.Responses = append(resps.Responses, resp)
 		}
 		if err := stream.Send(resps); err != nil {
diff --git a/pkg/mcs/resourcemanager/server/keyspace_manager.go b/pkg/mcs/resourcemanager/server/keyspace_manager.go
index fe0c752e353..8f0f4e5fad4 100644
--- a/pkg/mcs/resourcemanager/server/keyspace_manager.go
+++ b/pkg/mcs/resourcemanager/server/keyspace_manager.go
@@ -56,6 +56,7 @@ type keyspaceResourceGroupManager struct {
 
 	keyspaceID uint32
 	storage    endpoint.ResourceGroupStorage
+	sl         *serviceLimiter
 }
 
 func newKeyspaceResourceGroupManager(keyspaceID uint32, storage endpoint.ResourceGroupStorage) *keyspaceResourceGroupManager {
@@ -63,6 +64,7 @@ func newKeyspaceResourceGroupManager(keyspaceID uint32, storage endpoint.Resourc
 		groups:     make(map[string]*ResourceGroup),
 		keyspaceID: keyspaceID,
 		storage:    storage,
+		sl:         newServiceLimiter(keyspaceID, 0),
 	}
 }
 
@@ -237,3 +239,17 @@ func (krgm *keyspaceResourceGroupManager) persistResourceGroupRunningState() {
 		krgm.RUnlock()
 	}
 }
+
+func (krgm *keyspaceResourceGroupManager) setServiceLimiter(serviceLimit float64) {
+	krgm.RLock()
+	sl := krgm.sl
+	krgm.RUnlock()
+	// Set the new service limit to the limiter.
+	sl.setServiceLimit(serviceLimit)
+}
+
+func (krgm *keyspaceResourceGroupManager) getServiceLimiter() *serviceLimiter {
+	krgm.RLock()
+	defer krgm.RUnlock()
+	return krgm.sl
+}
diff --git a/pkg/mcs/resourcemanager/server/manager.go b/pkg/mcs/resourcemanager/server/manager.go
index 6a6c0a13579..de04f109c5f 100644
--- a/pkg/mcs/resourcemanager/server/manager.go
+++ b/pkg/mcs/resourcemanager/server/manager.go
@@ -67,6 +67,8 @@ type Manager struct {
 	consumptionDispatcher chan *consumptionItem
 	// cached keyspace name for each keyspace ID.
 	keyspaceNameLookup map[uint32]string
+	// used to get the keyspace ID by name.
+	keyspaceIDLookup map[string]uint32
 }
 
 // ConfigProvider is used to get resource manager config from the given
@@ -83,6 +85,7 @@ func NewManager[T ConfigProvider](srv bs.Server) *Manager {
 		krgms:                 make(map[uint32]*keyspaceResourceGroupManager),
 		consumptionDispatcher: make(chan *consumptionItem, defaultConsumptionChanSize),
 		keyspaceNameLookup:    make(map[uint32]string),
+		keyspaceIDLookup:      make(map[string]uint32),
 	}
 	// The first initialization after the server is started.
 	srv.AddStartCallback(func() {
@@ -116,6 +119,21 @@ func (m *Manager) GetStorage() endpoint.ResourceGroupStorage {
 	return m.storage
 }
 
+// GetKeyspaceServiceLimiter returns the service limit of the keyspace.
+func (m *Manager) GetKeyspaceServiceLimiter(keyspaceID uint32) *serviceLimiter {
+	krgm := m.getKeyspaceResourceGroupManager(keyspaceID)
+	if krgm == nil {
+		return nil
+	}
+	return krgm.getServiceLimiter().Clone()
+}
+
+// SetKeyspaceServiceLimit sets the service limit of the keyspace.
+func (m *Manager) SetKeyspaceServiceLimit(keyspaceID uint32, serviceLimit float64) {
+	// If the keyspace is not found, create a new keyspace resource group manager.
+	m.getOrCreateKeyspaceResourceGroupManager(keyspaceID, true).setServiceLimiter(serviceLimit)
+}
+
 func (m *Manager) getOrCreateKeyspaceResourceGroupManager(keyspaceID uint32, initDefault bool) *keyspaceResourceGroupManager {
 	m.Lock()
 	krgm, ok := m.krgms[keyspaceID]
@@ -369,7 +387,9 @@ func (m *Manager) getKeyspaceNameByID(ctx context.Context, id uint32) (string, e
 		return "", nil
 	}
 	// Try to get the keyspace name from the cache first.
+	m.RLock()
 	name, ok := m.keyspaceNameLookup[id]
+	m.RUnlock()
 	if ok {
 		return name, nil
 	}
@@ -391,10 +411,51 @@ func (m *Manager) getKeyspaceNameByID(ctx context.Context, id uint32) (string, e
 		return "", fmt.Errorf("got an empty keyspace name by id %d", id)
 	}
 	// Update the cache.
-	m.keyspaceNameLookup[id] = loadedName
+	m.updateKeyspaceNameLookup(id, loadedName)
 	return loadedName, nil
 }
 
+func (m *Manager) updateKeyspaceNameLookup(id uint32, name string) {
+	m.Lock()
+	defer m.Unlock()
+	m.keyspaceNameLookup[id] = name
+	m.keyspaceIDLookup[name] = id
+}
+
+// GetKeyspaceIDByName gets the keyspace ID by name.
+func (m *Manager) GetKeyspaceIDByName(ctx context.Context, name string) (*rmpb.KeyspaceIDValue, error) {
+	if len(name) == 0 {
+		return &rmpb.KeyspaceIDValue{Value: constant.NullKeyspaceID}, nil
+	}
+	m.RLock()
+	id, ok := m.keyspaceIDLookup[name]
+	m.RUnlock()
+	if ok {
+		return &rmpb.KeyspaceIDValue{Value: id}, nil
+	}
+	var (
+		loadedID uint32
+		err      error
+	)
+	err = m.storage.RunInTxn(ctx, func(txn kv.Txn) error {
+		ok, loadedID, err = m.storage.LoadKeyspaceID(txn, name)
+		if err != nil {
+			return err
+		}
+		return nil
+	})
+	if err != nil {
+		log.Error("failed to get the keyspace id", zap.String("keyspace-name", name), zap.Error(err))
+		return nil, err
+	}
+	if !ok {
+		return nil, fmt.Errorf("keyspace not found with name: %s", name)
+	}
+	// Update the cache.
+	m.updateKeyspaceNameLookup(loadedID, name)
+	return &rmpb.KeyspaceIDValue{Value: loadedID}, nil
+}
+
 func (m *Manager) backgroundMetricsFlush(ctx context.Context) {
 	defer logutil.LogPanic()
 	defer m.wg.Done()
diff --git a/pkg/mcs/resourcemanager/server/manager_test.go b/pkg/mcs/resourcemanager/server/manager_test.go
index 5fd2e9c6e2e..ffe51531aa3 100644
--- a/pkg/mcs/resourcemanager/server/manager_test.go
+++ b/pkg/mcs/resourcemanager/server/manager_test.go
@@ -164,7 +164,10 @@ func prepareKeyspaceName(ctx context.Context, re *require.Assertions, manager *M
 	}
 	err := manager.storage.RunInTxn(ctx, func(txn kv.Txn) error {
 		err := manager.storage.SaveKeyspaceMeta(txn, keyspaceMeta)
-		return err
+		if err != nil {
+			return err
+		}
+		return manager.storage.SaveKeyspaceID(txn, keyspaceMeta.Id, keyspaceMeta.Name)
 	})
 	re.NoError(err)
 }
@@ -262,3 +265,106 @@ func TestCleanUpTicker(t *testing.T) {
 	re.NoError(err)
 	re.Equal("test_keyspace", keyspaceName)
 }
+
+func TestKeyspaceServiceLimit(t *testing.T) {
+	re := require.New(t)
+
+	storage := storage.NewStorageWithMemoryBackend()
+	m := NewManager[*mockConfigProvider](&mockConfigProvider{})
+	m.storage = storage
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	err := m.Init(ctx)
+	re.NoError(err)
+	// Test the default service limit is 0.0.
+	limiter := m.GetKeyspaceServiceLimiter(constant.NullKeyspaceID)
+	re.NotNil(limiter)
+	re.Equal(0.0, limiter.ServiceLimit)
+	re.Equal(0.0, limiter.AvailableTokens)
+	group := &rmpb.ResourceGroup{
+		Name:     "test_group",
+		Mode:     rmpb.GroupMode_RUMode,
+		Priority: 5,
+		RUSettings: &rmpb.GroupRequestUnitSettings{
+			RU: &rmpb.TokenBucket{
+				Settings: &rmpb.TokenLimitSettings{
+					FillRate:   100,
+					BurstLimit: 200,
+				},
+			},
+		},
+		KeyspaceId: &rmpb.KeyspaceIDValue{Value: 1},
+	}
+	// Test the limiter of the non-existing keyspace is nil.
+	limiter = m.GetKeyspaceServiceLimiter(group.KeyspaceId.Value)
+	re.Nil(limiter)
+	// Test the limiter of the newly created keyspace is 0.0.
+	err = m.AddResourceGroup(group)
+	re.NoError(err)
+	limiter = m.GetKeyspaceServiceLimiter(1)
+	re.Equal(0.0, limiter.ServiceLimit)
+	re.Equal(0.0, limiter.AvailableTokens)
+	// Test set the service limit of the keyspace.
+	m.SetKeyspaceServiceLimit(1, 100.0)
+	limiter = m.GetKeyspaceServiceLimiter(1)
+	re.Equal(100.0, limiter.ServiceLimit)
+	re.Equal(0.0, limiter.AvailableTokens) // When setting from 0 to positive, available tokens remain 0
+	// Test set the service limit of the non-existing keyspace.
+	limiter = m.GetKeyspaceServiceLimiter(2)
+	re.Nil(limiter)
+	m.SetKeyspaceServiceLimit(2, 100.0)
+	limiter = m.GetKeyspaceServiceLimiter(2)
+	re.Equal(100.0, limiter.ServiceLimit)
+	re.Equal(0.0, limiter.AvailableTokens)
+	// Ensure the keyspace resource group manager is initialized correctly.
+	krgm := m.getKeyspaceResourceGroupManager(2)
+	re.NotNil(krgm)
+	re.Equal(uint32(2), krgm.keyspaceID)
+	re.Equal(DefaultResourceGroupName, krgm.getMutableResourceGroup(DefaultResourceGroupName).Name)
+}
+
+func TestKeyspaceNameLookup(t *testing.T) {
+	re := require.New(t)
+	m := prepareManager()
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	err := m.Init(ctx)
+	re.NoError(err)
+	// Get the null keyspace ID by an empty name.
+	idValue, err := m.GetKeyspaceIDByName(ctx, "")
+	re.NoError(err)
+	re.NotNil(idValue)
+	re.Equal(constant.NullKeyspaceID, idValue.Value)
+	// Get the non-existing keyspace ID by name.
+	idValue, err = m.GetKeyspaceIDByName(ctx, "non-existing-keyspace")
+	re.Error(err)
+	re.Nil(idValue)
+	// Get the null keyspace name.
+	name, err := m.getKeyspaceNameByID(ctx, constant.NullKeyspaceID)
+	re.NoError(err)
+	re.Empty(name)
+	// Get the non-existing keyspace name.
+	name, err = m.getKeyspaceNameByID(ctx, 1)
+	re.Error(err)
+	re.Empty(name)
+	// Get the keyspace ID by name first, then get the keyspace name by ID.
+	prepareKeyspaceName(ctx, re, m, &rmpb.KeyspaceIDValue{Value: 1}, "test_keyspace")
+	idValue, err = m.GetKeyspaceIDByName(ctx, "test_keyspace")
+	re.NoError(err)
+	re.NotNil(idValue)
+	re.Equal(uint32(1), idValue.Value)
+	name, err = m.getKeyspaceNameByID(ctx, 1)
+	re.NoError(err)
+	re.Equal("test_keyspace", name)
+	// Get the keyspace name by ID first, then get the keyspace ID by name.
+	prepareKeyspaceName(ctx, re, m, &rmpb.KeyspaceIDValue{Value: 2}, "test_keyspace_2")
+	name, err = m.getKeyspaceNameByID(ctx, 2)
+	re.NoError(err)
+	re.Equal("test_keyspace_2", name)
+	idValue, err = m.GetKeyspaceIDByName(ctx, "test_keyspace_2")
+	re.NoError(err)
+	re.NotNil(idValue)
+	re.Equal(uint32(2), idValue.Value)
+}
diff --git a/pkg/mcs/resourcemanager/server/resource_group.go b/pkg/mcs/resourcemanager/server/resource_group.go
index 701061e6598..7f742562b23 100644
--- a/pkg/mcs/resourcemanager/server/resource_group.go
+++ b/pkg/mcs/resourcemanager/server/resource_group.go
@@ -189,6 +189,7 @@ func (rg *ResourceGroup) RequestRU(
 	now time.Time,
 	requiredToken float64,
 	targetPeriodMs, clientUniqueID uint64,
+	sl *serviceLimiter,
 ) *rmpb.GrantedRUTokenBucket {
 	rg.Lock()
 	defer rg.Unlock()
@@ -196,7 +197,18 @@ func (rg *ResourceGroup) RequestRU(
 	if rg.RUSettings == nil || rg.RUSettings.RU.Settings == nil {
 		return nil
 	}
+	// First, try to get tokens from the resource group.
 	tb, trickleTimeMs := rg.RUSettings.RU.request(now, requiredToken, targetPeriodMs, clientUniqueID)
+	// Then, try to apply the service limit.
+	grantedTokens := tb.GetTokens()
+	limitedTokens := sl.applyServiceLimit(now, grantedTokens)
+	if limitedTokens < grantedTokens {
+		tb.Tokens = limitedTokens
+		// Retain the unused tokens for the later requests if it has a burst limit.
+		if rg.RUSettings.RU.Settings.BurstLimit > 0 {
+			rg.RUSettings.RU.lastLimitedTokens += grantedTokens - limitedTokens
+		}
+	}
 	return &rmpb.GrantedRUTokenBucket{GrantedTokens: tb, TrickleTimeMs: trickleTimeMs}
 }
 
diff --git a/pkg/mcs/resourcemanager/server/service_limit.go b/pkg/mcs/resourcemanager/server/service_limit.go
new file mode 100644
index 00000000000..17e829ecb29
--- /dev/null
+++ b/pkg/mcs/resourcemanager/server/service_limit.go
@@ -0,0 +1,151 @@
+// Copyright 2025 TiKV Project Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"math"
+	"time"
+
+	"go.uber.org/zap"
+
+	"github.com/pingcap/log"
+
+	"github.com/tikv/pd/pkg/utils/syncutil"
+)
+
+// serviceLimiterBurstFactor defines how many seconds worth of tokens can be accumulated.
+// This allows the limiter to handle batch requests from clients that request tokens periodically.
+// Since the client will request tokens with a 5-second period by default, the burst factor is 5.0 here.
+const serviceLimiterBurstFactor = 5.0
+
+// TODO: persist the service limit to the storage and reload it when the service starts.
+type serviceLimiter struct {
+	syncutil.RWMutex
+	// ServiceLimit is the configured service limit for this limiter.
+	// It's an unburstable RU per second rate limit.
+	ServiceLimit float64 `json:"service_limit"`
+	// AvailableTokens tracks the available RU tokens for this limiter.
+	AvailableTokens float64 `json:"available_tokens"`
+	// LastUpdate records the last time the limiter was updated.
+	LastUpdate time.Time `json:"last_update"`
+	// KeyspaceID is the keyspace ID of the keyspace that this limiter belongs to.
+	keyspaceID uint32
+}
+
+func newServiceLimiter(keyspaceID uint32, serviceLimit float64) *serviceLimiter {
+	// The service limit should be non-negative.
+	serviceLimit = math.Max(0, serviceLimit)
+	return &serviceLimiter{
+		ServiceLimit: serviceLimit,
+		LastUpdate:   time.Now(),
+		keyspaceID:   keyspaceID,
+	}
+}
+
+func (krl *serviceLimiter) setServiceLimit(newServiceLimit float64) {
+	// The service limit should be non-negative.
+	newServiceLimit = math.Max(0, newServiceLimit)
+	krl.Lock()
+	defer krl.Unlock()
+	if newServiceLimit == krl.ServiceLimit {
+		return
+	}
+	oldServiceLimit := krl.ServiceLimit
+	krl.ServiceLimit = newServiceLimit
+	now := time.Now()
+	// If the old service limit was 0 (no limit) or the new service limit is 0,
+	// initialize the available tokens or clear the available tokens.
+	if oldServiceLimit == 0 || newServiceLimit == 0 {
+		krl.AvailableTokens = 0
+		krl.LastUpdate = now
+	} else {
+		// Trigger the refill of the available tokens to ensure that the previous
+		// service limit setting was not too high, so that many available tokens
+		// are not left unused, causing the new service limit to become invalid.
+		krl.refillTokensLocked(now)
+	}
+}
+
+func (krl *serviceLimiter) refillTokensLocked(now time.Time) {
+	// No limit configured, do nothing.
+	if krl.ServiceLimit <= 0 {
+		return
+	}
+	// Calculate the elapsed time since the last update.
+	elapsed := now.Sub(krl.LastUpdate).Seconds()
+	if elapsed < 0 {
+		log.Warn("refill service limit tokens with negative elapsed time",
+			zap.Uint32("keyspace-id", krl.keyspaceID),
+			zap.Float64("service_limit", krl.ServiceLimit),
+			zap.Float64("available_tokens", krl.AvailableTokens),
+			zap.Float64("elapsed", elapsed),
+		)
+		return
+	}
+	// Add tokens based on the configured rate and the burst limit.
+	krl.AvailableTokens = math.Min(
+		krl.AvailableTokens+krl.ServiceLimit*elapsed,
+		krl.ServiceLimit*serviceLimiterBurstFactor,
+	)
+	// Update the last update time.
+	krl.LastUpdate = now
+}
+
+// applyServiceLimit applies the service limit to the requested tokens and returns the limited tokens.
+func (krl *serviceLimiter) applyServiceLimit(
+	now time.Time,
+	requestedTokens float64,
+) (limitedTokens float64) {
+	if krl == nil {
+		return requestedTokens
+	}
+	krl.Lock()
+	defer krl.Unlock()
+
+	// No limit configured, allow all tokens.
+	if krl.ServiceLimit <= 0 {
+		return requestedTokens
+	}
+
+	// Refill first to ensure the available tokens is up to date.
+	krl.refillTokensLocked(now)
+
+	// If the requested tokens is less than the available tokens, grant all tokens.
+	if requestedTokens <= krl.AvailableTokens {
+		krl.AvailableTokens -= requestedTokens
+		return requestedTokens
+	}
+
+	// If the requested tokens is greater than the available tokens, grant all available tokens.
+	if krl.AvailableTokens > 0 && requestedTokens > krl.AvailableTokens {
+		limitedTokens = krl.AvailableTokens
+		// TODO: allow the loan to decrease the allocation at a smooth rate.
+		krl.AvailableTokens = 0
+	}
+
+	return limitedTokens
+}
+
+// Clone returns a copy of the service limiter.
+func (krl *serviceLimiter) Clone() *serviceLimiter {
+	krl.RLock()
+	defer krl.RUnlock()
+	return &serviceLimiter{
+		ServiceLimit:    krl.ServiceLimit,
+		AvailableTokens: krl.AvailableTokens,
+		LastUpdate:      krl.LastUpdate,
+		keyspaceID:      krl.keyspaceID,
+	}
+}
diff --git a/pkg/mcs/resourcemanager/server/service_limit_test.go b/pkg/mcs/resourcemanager/server/service_limit_test.go
new file mode 100644
index 00000000000..d17af34e582
--- /dev/null
+++ b/pkg/mcs/resourcemanager/server/service_limit_test.go
@@ -0,0 +1,314 @@
+// Copyright 2025 TiKV Project Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/tikv/pd/pkg/mcs/utils/constant"
+)
+
+func TestNewServiceLimiter(t *testing.T) {
+	re := require.New(t)
+
+	// Test creating a service limiter with positive limit
+	limiter := newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	re.NotNil(limiter)
+	re.Equal(100.0, limiter.ServiceLimit)
+	re.Equal(0.0, limiter.AvailableTokens)
+
+	// Test creating a service limiter with zero limit
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 0.0)
+	re.NotNil(limiter)
+	re.Equal(0.0, limiter.ServiceLimit)
+	re.Equal(0.0, limiter.AvailableTokens)
+
+	// Test creating a service limiter with negative limit
+	limiter = newServiceLimiter(constant.NullKeyspaceID, -10.0)
+	re.NotNil(limiter)
+	re.Equal(0.0, limiter.ServiceLimit)
+	re.Equal(0.0, limiter.AvailableTokens)
+}
+
+func TestRefillTokensLocked(t *testing.T) {
+	re := require.New(t)
+
+	// Test refill with positive service limit
+	limiter := newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	baseTime := time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 50.0
+
+	// Refill after 1 second should add 100 tokens, but cap at burst limit (100 * serviceLimiterBurstFactor)
+	futureTime := baseTime.Add(time.Second)
+	limiter.refillTokensLocked(futureTime)
+	re.Equal(150.0, limiter.AvailableTokens) // 50 + 100 = 150 (within burst limit)
+	re.Equal(futureTime, limiter.LastUpdate)
+
+	// Test refill when reach the burst limit
+	limiter.AvailableTokens = 475.0
+	limiter.LastUpdate = baseTime
+	limiter.refillTokensLocked(futureTime)
+	re.Equal(100*serviceLimiterBurstFactor, limiter.AvailableTokens) // Should remain at burst limit
+	re.Equal(futureTime, limiter.LastUpdate)                         // Should update time even when no tokens added
+
+	// Test partial refill
+	limiter.AvailableTokens = 20.0
+	limiter.LastUpdate = baseTime
+	halfSecondLater := baseTime.Add(500 * time.Millisecond)
+	limiter.refillTokensLocked(halfSecondLater)
+	re.InDelta(70.0, limiter.AvailableTokens, 0.1) // 20 + 100*0.5 = 70
+	re.Equal(halfSecondLater, limiter.LastUpdate)
+
+	// Test with zero service limit
+	limiter.ServiceLimit = 0.0
+	limiter.AvailableTokens = 0.0
+	limiter.LastUpdate = baseTime
+	limiter.refillTokensLocked(futureTime)
+	re.Equal(0.0, limiter.AvailableTokens) // Should remain 0
+	re.Equal(baseTime, limiter.LastUpdate) // Should not update time
+
+	// Test with elapsed time <= 0 (time going backwards)
+	limiter.ServiceLimit = 100.0
+	limiter.AvailableTokens = 50.0
+	limiter.LastUpdate = futureTime
+	limiter.refillTokensLocked(baseTime)     // Earlier time
+	re.Equal(50.0, limiter.AvailableTokens)  // Should remain unchanged
+	re.Equal(futureTime, limiter.LastUpdate) // Should not update time
+}
+
+func TestApplyServiceLimit(t *testing.T) {
+	re := require.New(t)
+
+	// Test with nil limiter
+	var limiter *serviceLimiter
+	tokens := limiter.applyServiceLimit(time.Now(), 50.0)
+	re.Equal(50.0, tokens)
+
+	// Test with zero service limit (no limit)
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 0.0)
+	now := time.Now()
+	tokens = limiter.applyServiceLimit(now, 50.0)
+	re.Equal(50.0, tokens)
+
+	// Test request within available tokens (need to set available tokens first)
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	limiter.AvailableTokens = 100.0 // Manually set available tokens
+	limiter.LastUpdate = now
+	tokens = limiter.applyServiceLimit(now, 50.0)
+	re.Equal(50.0, tokens)
+	re.Equal(50.0, limiter.AvailableTokens) // 100 - 50 = 50
+
+	// Test request exactly equal to available tokens
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	limiter.AvailableTokens = 100.0 // Manually set available tokens
+	limiter.LastUpdate = now
+	tokens = limiter.applyServiceLimit(now, 100.0)
+	re.Equal(100.0, tokens)
+	re.Equal(0.0, limiter.AvailableTokens)
+
+	// Test request exceeding available tokens
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	limiter.LastUpdate = now
+	limiter.AvailableTokens = 30.0
+	tokens = limiter.applyServiceLimit(now, 80.0)
+	re.Equal(30.0, tokens) // Only available tokens granted
+	re.Equal(0.0, limiter.AvailableTokens)
+}
+
+func TestApplyServiceLimitWithRefill(t *testing.T) {
+	re := require.New(t)
+
+	// Test that refill happens before applying limit
+	limiter := newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	baseTime := time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 20.0
+
+	// Request after 1 second should trigger refill first
+	futureTime := baseTime.Add(time.Second)
+	tokens := limiter.applyServiceLimit(futureTime, 50.0)
+	re.Equal(50.0, tokens)
+	re.Equal(70.0, limiter.AvailableTokens) // 20 + 100 - 50 = 70
+	re.Equal(futureTime, limiter.LastUpdate)
+
+	// Test partial refill scenario
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 10.0
+
+	halfSecondLater := baseTime.Add(500 * time.Millisecond)
+	tokens = limiter.applyServiceLimit(halfSecondLater, 80.0)
+	// After refill: 10 + 100*0.5 = 60 tokens available
+	re.Equal(60.0, tokens)
+	re.Equal(0.0, limiter.AvailableTokens)
+}
+
+func TestServiceLimiterEdgeCases(t *testing.T) {
+	re := require.New(t)
+
+	// Test with very small service limit
+	limiter := newServiceLimiter(constant.NullKeyspaceID, 0.1)
+	limiter.AvailableTokens = 0.1   // Manually set available tokens
+	limiter.LastUpdate = time.Now() // Set LastUpdate to current time to avoid refill
+	now := time.Now()
+	tokens := limiter.applyServiceLimit(now, 1.0)
+	re.InDelta(0.1, tokens, 0.001) // Use InDelta to handle floating point precision
+
+	// Test with very large service limit
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 1000000.0)
+	limiter.AvailableTokens = 1000000.0 // Manually set available tokens
+	tokens = limiter.applyServiceLimit(now, 500000.0)
+	re.Equal(500000.0, tokens)
+
+	// Test with zero requested tokens
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	limiter.AvailableTokens = 100.0 // Manually set available tokens
+	tokens = limiter.applyServiceLimit(now, 0.0)
+	re.Equal(0.0, tokens)
+	re.Equal(100.0, limiter.AvailableTokens) // Should remain unchanged
+
+	// Test with fractional tokens
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 10.5)
+	limiter.LastUpdate = now
+	limiter.AvailableTokens = 5.25
+	tokens = limiter.applyServiceLimit(now, 7.75)
+	re.Equal(5.25, tokens)
+}
+
+func TestSetServiceLimit(t *testing.T) {
+	re := require.New(t)
+
+	// Test setting the same service limit (should be no-op)
+	limiter := newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	originalTokens := limiter.AvailableTokens
+	originalUpdate := limiter.LastUpdate
+
+	limiter.setServiceLimit(100.0) // Same limit
+	re.Equal(100.0, limiter.ServiceLimit)
+	re.Equal(originalTokens, limiter.AvailableTokens) // Should remain unchanged
+	re.Equal(originalUpdate, limiter.LastUpdate)      // Should remain unchanged
+
+	// Test increasing service limit
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 50.0)
+	baseTime := time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 30.0
+
+	limiter.setServiceLimit(100.0)
+	re.Equal(100.0, limiter.ServiceLimit)
+	re.InDelta(30.0, limiter.AvailableTokens, 0.1) // Should remain the same since no time elapsed (allow for floating point precision)
+	re.True(limiter.LastUpdate.After(baseTime))    // Should update time
+
+	// Test decreasing service limit with available tokens exceeding new limit
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	baseTime = time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 80.0
+
+	// Sleep a bit to ensure some time passes
+	time.Sleep(1 * time.Millisecond)
+	limiter.setServiceLimit(50.0)
+	re.Equal(50.0, limiter.ServiceLimit)
+	// After refill, tokens might exceed 80, but should be capped at burst limit (50 * serviceLimiterBurstFactor)
+	re.Greater(limiter.AvailableTokens, 80.0)                               // Should increase the available tokens
+	re.LessOrEqual(limiter.AvailableTokens, 50.0*serviceLimiterBurstFactor) // Should not exceed burst limit
+	re.True(limiter.LastUpdate.After(baseTime))                             // Should update time
+
+	// Test decreasing service limit with available tokens below new limit
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	baseTime = time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 30.0
+
+	limiter.setServiceLimit(50.0)
+	re.Equal(50.0, limiter.ServiceLimit)
+	re.InDelta(30.0, limiter.AvailableTokens, 0.1) // Should remain unchanged since below new limit (allow for floating point precision)
+	re.True(limiter.LastUpdate.After(baseTime))    // Should update time
+
+	// Test setting service limit to zero
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	baseTime = time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 50.0
+
+	limiter.setServiceLimit(0.0)
+	re.Equal(0.0, limiter.ServiceLimit)
+	re.Equal(0.0, limiter.AvailableTokens)      // Should be cleared
+	re.True(limiter.LastUpdate.After(baseTime)) // Should update time
+
+	// Test setting service limit from zero to positive (should NOT initialize available tokens)
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 0.0)
+	baseTime = time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 0.0
+
+	limiter.setServiceLimit(50.0)
+	re.Equal(50.0, limiter.ServiceLimit)
+	re.Equal(0.0, limiter.AvailableTokens)      // Should remain 0 (not initialized to service limit)
+	re.True(limiter.LastUpdate.After(baseTime)) // Should update time
+
+	// Test setting negative service limit (should be treated as zero)
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 100.0)
+	baseTime = time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 50.0
+
+	limiter.setServiceLimit(-10.0)
+	re.Equal(0.0, limiter.ServiceLimit)         // Should be treated as zero
+	re.Equal(0.0, limiter.AvailableTokens)      // Should be cleared
+	re.True(limiter.LastUpdate.After(baseTime)) // Should update time
+
+	// Test setting service limit with time elapsed (should trigger refill)
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 50.0)
+	baseTime = time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 20.0
+
+	// Simulate time passing before setting new limit
+	time.Sleep(10 * time.Millisecond)
+	limiter.setServiceLimit(100.0)
+	re.Equal(100.0, limiter.ServiceLimit)
+	// Available tokens should be refilled based on elapsed time, but capped at new service limit
+	re.GreaterOrEqual(limiter.AvailableTokens, 20.0) // Should be at least the original amount
+	re.LessOrEqual(limiter.AvailableTokens, 100.0)   // Should not exceed new service limit
+	re.True(limiter.LastUpdate.After(baseTime))      // Should update time
+
+	// Test setting a smaller service limit
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 1000.0)
+	baseTime = time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 500.0
+
+	limiter.setServiceLimit(10.0)
+	re.Equal(10.0, limiter.ServiceLimit)
+	// After refill with new limit, tokens should be capped at burst limit (10 * serviceLimiterBurstFactor)
+	re.Equal(10.0*serviceLimiterBurstFactor, limiter.AvailableTokens) // Should be capped at burst limit, not service limit
+	re.True(limiter.LastUpdate.After(baseTime))                       // Should update time
+
+	// Test setting a larger service limit
+	limiter = newServiceLimiter(constant.NullKeyspaceID, 10.0)
+	baseTime = time.Now()
+	limiter.LastUpdate = baseTime
+	limiter.AvailableTokens = 5.0
+
+	limiter.setServiceLimit(1000000.0)
+	re.Equal(1000000.0, limiter.ServiceLimit)
+	re.Greater(limiter.AvailableTokens, 5.0)
+	re.True(limiter.LastUpdate.After(baseTime))
+}
diff --git a/pkg/mcs/resourcemanager/server/token_buckets.go b/pkg/mcs/resourcemanager/server/token_buckets.go
index 1a6ade9f2df..090e79dcf41 100644
--- a/pkg/mcs/resourcemanager/server/token_buckets.go
+++ b/pkg/mcs/resourcemanager/server/token_buckets.go
@@ -120,7 +120,14 @@ type GroupTokenBucketState struct {
 	// ClientUniqueID -> TokenSlot
 	tokenSlots                 map[uint64]*TokenSlot
 	clientConsumptionTokensSum float64
-	lastBurstTokens            float64
+	// Used to store tokens in the token slot that exceed burst limits,
+	// ensuring that these tokens are not lost but are reintroduced into
+	// token calculation during the next update.
+	lastBurstTokens float64
+	// Used to store tokens that exceed the service limit,
+	// ensuring that these tokens are not lost but are reintroduced into
+	// token calculation during the next update.
+	lastLimitedTokens float64
 
 	LastUpdate  *time.Time `json:"last_update,omitempty"`
 	Initialized bool       `json:"initialized"`
@@ -347,8 +354,9 @@ func (gtb *GroupTokenBucket) updateTokens(now time.Time, burstLimit int64, clien
 		gtb.init(now, clientUniqueID)
 	} else if burst := float64(burstLimit); burst > 0 {
 		if delta := now.Sub(*gtb.LastUpdate); delta > 0 {
-			elapseTokens = float64(gtb.Settings.GetFillRate())*delta.Seconds() + gtb.lastBurstTokens
+			elapseTokens = float64(gtb.Settings.GetFillRate())*delta.Seconds() + gtb.lastBurstTokens + gtb.lastLimitedTokens
 			gtb.lastBurstTokens = 0
+			gtb.lastLimitedTokens = 0
 			gtb.Tokens += elapseTokens
 		}
 		if gtb.Tokens > burst {
diff --git a/tests/integrations/mcs/resourcemanager/api_test.go b/tests/integrations/mcs/resourcemanager/api_test.go
index 1bf3428f494..f3597456090 100644
--- a/tests/integrations/mcs/resourcemanager/api_test.go
+++ b/tests/integrations/mcs/resourcemanager/api_test.go
@@ -26,8 +26,10 @@ import (
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
 
+	"github.com/pingcap/failpoint"
 	rmpb "github.com/pingcap/kvproto/pkg/resource_manager"
 
+	"github.com/tikv/pd/pkg/keyspace"
 	"github.com/tikv/pd/pkg/mcs/resourcemanager/server"
 	"github.com/tikv/pd/pkg/mcs/resourcemanager/server/apis/v1"
 	"github.com/tikv/pd/tests"
@@ -221,3 +223,75 @@ func (suite *resourceManagerAPITestSuite) mustSetControllerConfig(re *require.As
 	bodyBytes := suite.mustSendRequest(re, http.MethodPost, "/config/controller", config)
 	re.Equal("Success!", string(bodyBytes))
 }
+
+func (suite *resourceManagerAPITestSuite) TestKeyspaceServiceLimitAPI() {
+	re := suite.Require()
+	re.NoError(failpoint.Enable("github.com/tikv/pd/pkg/keyspace/skipSplitRegion", "return(true)"))
+	defer func() {
+		re.NoError(failpoint.Disable("github.com/tikv/pd/pkg/keyspace/skipSplitRegion"))
+	}()
+
+	// Prepare the keyspace for later test.
+	leaderServer := suite.cluster.GetLeaderServer()
+	leaderServer.GetKeyspaceManager().CreateKeyspace(
+		&keyspace.CreateKeyspaceRequest{
+			Name: "test_keyspace",
+		},
+	)
+	for _, keyspaceName := range []string{"", "test_keyspace"} {
+		// Get the keyspace service limit.
+		limit, statusCode := suite.tryToGetKeyspaceServiceLimit(re, keyspaceName)
+		if len(keyspaceName) == 0 {
+			// The null keyspace is always available.
+			re.Equal(http.StatusOK, statusCode)
+			re.Equal(0.0, limit)
+		} else {
+			// The keyspace manager has not been created yet.
+			re.Equal(http.StatusNotFound, statusCode)
+			re.Equal(0.0, limit)
+		}
+		// Try to set the keyspace service limit to a negative value.
+		resp, statusCode := suite.tryToSetKeyspaceServiceLimit(re, keyspaceName, -1.0)
+		re.Equal(http.StatusBadRequest, statusCode)
+		re.Equal("service_limit must be non-negative", resp)
+		// Set the keyspace service limit to a positive value.
+		resp, statusCode = suite.tryToSetKeyspaceServiceLimit(re, keyspaceName, 1.0)
+		re.Equal(http.StatusOK, statusCode)
+		re.Equal("Success!", resp)
+		limit, statusCode = suite.tryToGetKeyspaceServiceLimit(re, keyspaceName)
+		re.Equal(http.StatusOK, statusCode)
+		re.Equal(1.0, limit)
+	}
+	// Try to set a non-existing keyspace's service limit.
+	resp, statusCode := suite.tryToSetKeyspaceServiceLimit(re, "non_existing_keyspace", 1.0)
+	re.Equal(http.StatusBadRequest, statusCode)
+	re.Equal("keyspace not found with name: non_existing_keyspace", resp)
+	// Try to get a non-existing keyspace's service limit.
+	limit, statusCode := suite.tryToGetKeyspaceServiceLimit(re, "non_existing_keyspace")
+	re.Equal(http.StatusBadRequest, statusCode)
+	re.Equal(0.0, limit)
+}
+
+func (suite *resourceManagerAPITestSuite) tryToGetKeyspaceServiceLimit(re *require.Assertions, keyspaceName string) (float64, int) {
+	bodyBytes, statusCode := suite.sendRequest(re, http.MethodGet, "/config/keyspace/service-limit/"+keyspaceName, nil)
+	if statusCode != http.StatusOK {
+		return 0.0, statusCode
+	}
+	var limiter struct {
+		ServiceLimit float64 `json:"service_limit"`
+	}
+	re.NoError(json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&limiter))
+	return limiter.ServiceLimit, statusCode
+}
+
+func (suite *resourceManagerAPITestSuite) tryToSetKeyspaceServiceLimit(re *require.Assertions, keyspaceName string, limit float64) (string, int) {
+	bodyBytes, statusCode := suite.sendRequest(
+		re,
+		http.MethodPost,
+		"/config/keyspace/service-limit/"+keyspaceName,
+		apis.KeyspaceServiceLimitRequest{
+			ServiceLimit: limit,
+		},
+	)
+	return string(bodyBytes), statusCode
+}
diff --git a/tools/pd-ut/testdata/group.9.etcdkey b/tools/pd-ut/testdata/group.9.etcdkey
index cecb60233db..82b9ec5f0fd 100644
--- a/tools/pd-ut/testdata/group.9.etcdkey
+++ b/tools/pd-ut/testdata/group.9.etcdkey
@@ -21,8 +21,12 @@
 /pd//config get
 /pd//config save
 /pd//config watch
+/pd//keyspaces/alloc_id get
 /pd//keyspaces/id/DEFAULT get
 /pd//keyspaces/id/DEFAULT save
+/pd//keyspaces/id/non_existing_keyspace get
+/pd//keyspaces/id/test_keyspace get
+/pd//keyspaces/id/test_keyspace save
 /pd//keyspaces/meta/ get
 /pd//keyspaces/meta/ save
 /pd//leader get


Reviews:
user: login: JmPotato
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T06:58:03Z
user: login: rleungx
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T07:16:36Z
user: login: JmPotato
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T07:27:18Z
user: login: glorv
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T07:33:23Z
user: login: JmPotato
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T07:35:17Z
user: login: JmPotato
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T07:37:17Z
user: login: JmPotato
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T08:53:28Z
user: login: glorv
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T09:45:49Z
user: login: glorv
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T09:48:29Z
user: login: rleungx
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T08:44:33Z
user: login: rleungx
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T08:46:15Z
user: login: JmPotato
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T08:47:53Z
user: login: rleungx
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T08:53:23Z
user: login: rleungx
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T08:56:40Z
user: login: rleungx
body: The rest LGTM
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T09:20:39Z
user: login: glorv
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T06:04:00Z
user: login: glorv
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T06:09:05Z
user: login: rleungx
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-30T06:58:00Z
user: login: lhy1024
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T08:56:01Z
user: login: JmPotato
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-30T09:12:22Z
user: login: lhy1024
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T13:28:11Z

Review comments:
diff_hunk: @@ -99,6 +99,8 @@ func (s *Service) RegisterRouter() {
 	configEndpoint.DELETE("/group/:name", s.deleteResourceGroup)
 	configEndpoint.GET("/controller", s.getControllerConfig)
 	configEndpoint.POST("/controller", s.setControllerConfig)
+	configEndpoint.POST("/keyspace/:keyspace_id/service-limit", s.setKeyspaceServiceLimit)
+	configEndpoint.GET("/keyspace/:keyspace_id/service-limit", s.getKeyspaceServiceLimit)
path: pkg/mcs/resourcemanager/server/apis/v1/api.go
user: login: JmPotato
body: We should use the keyspace name to identify the keyspace in the API.
created_at: 2025-05-28T06:58:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,137 @@
+// Copyright 2025 TiKV Project Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"math"
+	"time"
+
+	"github.com/tikv/pd/pkg/utils/syncutil"
+)
+
+// serviceLimiterBurstFactor defines how many seconds worth of tokens can be accumulated.
+// This allows the limiter to handle batch requests from clients that request tokens periodically.
+// Since the client will request tokens with a 5-second period by default, the burst factor is 5.0 here.
+const serviceLimiterBurstFactor = 5.0
+
+// TODO: persist the service limit to the storage and reload it when the service starts.
+type serviceLimiter struct {
+	syncutil.RWMutex
+	// ServiceLimit is the configured service limit for this limiter.
+	// It's an unburstable RU per second rate limit.
+	ServiceLimit float64 `json:"service_limit"`
+	// AvailableTokens tracks the available RU tokens for this limiter.
+	AvailableTokens float64 `json:"available_tokens"`
+	// LastUpdate records the last time the limiter was updated.
+	LastUpdate time.Time `json:"last_update"`
+}
+
+func newServiceLimiter(serviceLimit float64) *serviceLimiter {
+	// The service limit should be non-negative.
+	serviceLimit = math.Max(0, serviceLimit)
+	return &serviceLimiter{
+		ServiceLimit: serviceLimit,
+		LastUpdate:   time.Now(),
+	}
+}
+
+func (krl *serviceLimiter) setServiceLimit(newServiceLimit float64) {
+	// The service limit should be non-negative.
+	newServiceLimit = math.Max(0, newServiceLimit)
+	krl.Lock()
+	defer krl.Unlock()
+	if newServiceLimit == krl.ServiceLimit {
+		return
+	}
+	oldServiceLimit := krl.ServiceLimit
+	krl.ServiceLimit = newServiceLimit
+	now := time.Now()
+	// If the old service limit was 0 (no limit) or the new service limit is 0,
path: pkg/mcs/resourcemanager/server/service_limit.go
user: login: rleungx
body: Do we support forbidding a keyspace via setting the service limit?
created_at: 2025-05-28T07:09:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,137 @@
+// Copyright 2025 TiKV Project Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"math"
+	"time"
+
+	"github.com/tikv/pd/pkg/utils/syncutil"
+)
+
+// serviceLimiterBurstFactor defines how many seconds worth of tokens can be accumulated.
+// This allows the limiter to handle batch requests from clients that request tokens periodically.
+// Since the client will request tokens with a 5-second period by default, the burst factor is 5.0 here.
+const serviceLimiterBurstFactor = 5.0
+
+// TODO: persist the service limit to the storage and reload it when the service starts.
+type serviceLimiter struct {
+	syncutil.RWMutex
+	// ServiceLimit is the configured service limit for this limiter.
+	// It's an unburstable RU per second rate limit.
+	ServiceLimit float64 `json:"service_limit"`
+	// AvailableTokens tracks the available RU tokens for this limiter.
+	AvailableTokens float64 `json:"available_tokens"`
+	// LastUpdate records the last time the limiter was updated.
+	LastUpdate time.Time `json:"last_update"`
+}
+
+func newServiceLimiter(serviceLimit float64) *serviceLimiter {
+	// The service limit should be non-negative.
+	serviceLimit = math.Max(0, serviceLimit)
+	return &serviceLimiter{
+		ServiceLimit: serviceLimit,
+		LastUpdate:   time.Now(),
+	}
+}
+
+func (krl *serviceLimiter) setServiceLimit(newServiceLimit float64) {
+	// The service limit should be non-negative.
+	newServiceLimit = math.Max(0, newServiceLimit)
+	krl.Lock()
+	defer krl.Unlock()
+	if newServiceLimit == krl.ServiceLimit {
+		return
+	}
+	oldServiceLimit := krl.ServiceLimit
+	krl.ServiceLimit = newServiceLimit
+	now := time.Now()
+	// If the old service limit was 0 (no limit) or the new service limit is 0,
+	// initialize the available tokens or clear the available tokens.
+	if oldServiceLimit == 0 || newServiceLimit == 0 {
+		krl.AvailableTokens = 0
+		krl.LastUpdate = now
+	} else {
+		// Trigger the refill of the available tokens to ensure that the previous
+		// service limit setting was not too high, so that many available tokens
+		// are not left unused, causing the new service limit to become invalid.
+		krl.refillTokensLocked(now)
+	}
+}
+
+func (krl *serviceLimiter) refillTokensLocked(now time.Time) {
+	// No limit configured, do nothing.
+	if krl.ServiceLimit <= 0 {
+		return
+	}
+	// Calculate the elapsed time since the last update.
+	elapsed := now.Sub(krl.LastUpdate).Seconds()
+	if elapsed < 0 {
+		return
+	}
+	// Add tokens based on the configured rate and the burst limit.
+	krl.AvailableTokens = math.Min(
+		krl.AvailableTokens+krl.ServiceLimit*elapsed,
+		krl.ServiceLimit*serviceLimiterBurstFactor,
+	)
+	// Update the last update time.
+	krl.LastUpdate = now
path: pkg/mcs/resourcemanager/server/service_limit.go
user: login: rleungx
body: How about moving it outside and merging it with line 65?
created_at: 2025-05-28T07:13:24Z
author_association: MEMBER
diff_hunk: @@ -50,10 +50,10 @@ type MinResolvedTS struct {
 // GetStoreMinResolvedTS gets the store-level min resolved ts.
 // @Tags     min_store_resolved_ts
 // @Summary  Get store-level min resolved ts.
-// @Produce  json
-// @Success  200  {array}   MinResolvedTS
+// @Produce      json
path: server/api/min_resolved_ts.go
user: login: rleungx
body: Looks like there are some formatting issues
created_at: 2025-05-28T07:15:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,137 @@
+// Copyright 2025 TiKV Project Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"math"
+	"time"
+
+	"github.com/tikv/pd/pkg/utils/syncutil"
+)
+
+// serviceLimiterBurstFactor defines how many seconds worth of tokens can be accumulated.
+// This allows the limiter to handle batch requests from clients that request tokens periodically.
+// Since the client will request tokens with a 5-second period by default, the burst factor is 5.0 here.
+const serviceLimiterBurstFactor = 5.0
+
+// TODO: persist the service limit to the storage and reload it when the service starts.
+type serviceLimiter struct {
+	syncutil.RWMutex
+	// ServiceLimit is the configured service limit for this limiter.
+	// It's an unburstable RU per second rate limit.
+	ServiceLimit float64 `json:"service_limit"`
+	// AvailableTokens tracks the available RU tokens for this limiter.
+	AvailableTokens float64 `json:"available_tokens"`
+	// LastUpdate records the last time the limiter was updated.
+	LastUpdate time.Time `json:"last_update"`
+}
+
+func newServiceLimiter(serviceLimit float64) *serviceLimiter {
+	// The service limit should be non-negative.
+	serviceLimit = math.Max(0, serviceLimit)
+	return &serviceLimiter{
+		ServiceLimit: serviceLimit,
+		LastUpdate:   time.Now(),
+	}
+}
+
+func (krl *serviceLimiter) setServiceLimit(newServiceLimit float64) {
+	// The service limit should be non-negative.
+	newServiceLimit = math.Max(0, newServiceLimit)
+	krl.Lock()
+	defer krl.Unlock()
+	if newServiceLimit == krl.ServiceLimit {
+		return
+	}
+	oldServiceLimit := krl.ServiceLimit
+	krl.ServiceLimit = newServiceLimit
+	now := time.Now()
+	// If the old service limit was 0 (no limit) or the new service limit is 0,
path: pkg/mcs/resourcemanager/server/service_limit.go
user: login: JmPotato
body: Currently, we do not have this requirement. If there is, it should be relatively easy to achieve by using a negative service limit value. Or can we also achieve this by changing the state of the keyspace?
created_at: 2025-05-28T07:27:18Z
author_association: MEMBER
diff_hunk: @@ -107,6 +107,24 @@ func (m *Manager) GetStorage() endpoint.ResourceGroupStorage {
 	return m.storage
 }
 
+// GetKeyspaceServiceLimiter returns the service limit of the keyspace.
+func (m *Manager) GetKeyspaceServiceLimiter(keyspaceID uint32) *serviceLimiter {
+	krgm := m.getKeyspaceResourceGroupManager(keyspaceID)
path: pkg/mcs/resourcemanager/server/manager.go
user: login: glorv
body: Do we need to also call `getOrCreateKeyspaceResourceGroupManager` here?  It's possible the the keyspace does not create any resource groups manually (so only the default group exists).
created_at: 2025-05-28T07:32:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -237,3 +239,68 @@ func (s *Service) setControllerConfig(c *gin.Context) {
 	}
 	c.String(http.StatusOK, "Success!")
 }
+
+type keyspaceServiceLimitRequest struct {
+	ServiceLimit float64 `json:"service_limit"`
+}
+
+// SetKeyspaceServiceLimit
+//
+//	@Tags		ResourceManager
+//	@Summary	Set the service limit of the keyspace.
+//	@Param		keyspace_id		path		string	true	"Keyspace ID"
+//	@Param		service_limit	body		object	true	"json params, keyspaceServiceLimitRequest"
+//	@Success	200				{string}	string	"Success!"
+//	@Failure	400				{string}	error
+//	@Router		/config/keyspace/{keyspace_id}/service-limit [post]
+func (s *Service) setKeyspaceServiceLimit(c *gin.Context) {
+	keyspaceID, err := parseKeyspaceID(c)
+	if err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	var req keyspaceServiceLimitRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	if req.ServiceLimit < 0 {
path: pkg/mcs/resourcemanager/server/apis/v1/api.go
user: login: glorv
body: What's the expected behavior if limit is 0, IIRC, the ru_per_sec does not allow 0 value.
created_at: 2025-05-28T07:33:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,137 @@
+// Copyright 2025 TiKV Project Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"math"
+	"time"
+
+	"github.com/tikv/pd/pkg/utils/syncutil"
+)
+
+// serviceLimiterBurstFactor defines how many seconds worth of tokens can be accumulated.
+// This allows the limiter to handle batch requests from clients that request tokens periodically.
+// Since the client will request tokens with a 5-second period by default, the burst factor is 5.0 here.
+const serviceLimiterBurstFactor = 5.0
+
+// TODO: persist the service limit to the storage and reload it when the service starts.
+type serviceLimiter struct {
+	syncutil.RWMutex
+	// ServiceLimit is the configured service limit for this limiter.
+	// It's an unburstable RU per second rate limit.
+	ServiceLimit float64 `json:"service_limit"`
+	// AvailableTokens tracks the available RU tokens for this limiter.
+	AvailableTokens float64 `json:"available_tokens"`
+	// LastUpdate records the last time the limiter was updated.
+	LastUpdate time.Time `json:"last_update"`
+}
+
+func newServiceLimiter(serviceLimit float64) *serviceLimiter {
+	// The service limit should be non-negative.
+	serviceLimit = math.Max(0, serviceLimit)
+	return &serviceLimiter{
+		ServiceLimit: serviceLimit,
+		LastUpdate:   time.Now(),
+	}
+}
+
+func (krl *serviceLimiter) setServiceLimit(newServiceLimit float64) {
+	// The service limit should be non-negative.
+	newServiceLimit = math.Max(0, newServiceLimit)
+	krl.Lock()
+	defer krl.Unlock()
+	if newServiceLimit == krl.ServiceLimit {
+		return
+	}
+	oldServiceLimit := krl.ServiceLimit
+	krl.ServiceLimit = newServiceLimit
+	now := time.Now()
+	// If the old service limit was 0 (no limit) or the new service limit is 0,
+	// initialize the available tokens or clear the available tokens.
+	if oldServiceLimit == 0 || newServiceLimit == 0 {
+		krl.AvailableTokens = 0
+		krl.LastUpdate = now
+	} else {
+		// Trigger the refill of the available tokens to ensure that the previous
+		// service limit setting was not too high, so that many available tokens
+		// are not left unused, causing the new service limit to become invalid.
+		krl.refillTokensLocked(now)
+	}
+}
+
+func (krl *serviceLimiter) refillTokensLocked(now time.Time) {
+	// No limit configured, do nothing.
+	if krl.ServiceLimit <= 0 {
+		return
+	}
+	// Calculate the elapsed time since the last update.
+	elapsed := now.Sub(krl.LastUpdate).Seconds()
+	if elapsed < 0 {
+		return
+	}
+	// Add tokens based on the configured rate and the burst limit.
+	krl.AvailableTokens = math.Min(
+		krl.AvailableTokens+krl.ServiceLimit*elapsed,
+		krl.ServiceLimit*serviceLimiterBurstFactor,
+	)
+	// Update the last update time.
+	krl.LastUpdate = now
path: pkg/mcs/resourcemanager/server/service_limit.go
user: login: JmPotato
body: `refillTokensLocked` is widely used in the unit tests, moving this line out will cause us to manually update `LastUpdate` in the test.
created_at: 2025-05-28T07:35:17Z
author_association: MEMBER
diff_hunk: @@ -237,3 +239,68 @@ func (s *Service) setControllerConfig(c *gin.Context) {
 	}
 	c.String(http.StatusOK, "Success!")
 }
+
+type keyspaceServiceLimitRequest struct {
+	ServiceLimit float64 `json:"service_limit"`
+}
+
+// SetKeyspaceServiceLimit
+//
+//	@Tags		ResourceManager
+//	@Summary	Set the service limit of the keyspace.
+//	@Param		keyspace_id		path		string	true	"Keyspace ID"
+//	@Param		service_limit	body		object	true	"json params, keyspaceServiceLimitRequest"
+//	@Success	200				{string}	string	"Success!"
+//	@Failure	400				{string}	error
+//	@Router		/config/keyspace/{keyspace_id}/service-limit [post]
+func (s *Service) setKeyspaceServiceLimit(c *gin.Context) {
+	keyspaceID, err := parseKeyspaceID(c)
+	if err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	var req keyspaceServiceLimitRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	if req.ServiceLimit < 0 {
path: pkg/mcs/resourcemanager/server/apis/v1/api.go
user: login: JmPotato
body: Currently, a zero value means no restriction at all, which is equivalent to the absence of a Service Limit.
created_at: 2025-05-28T07:37:17Z
author_association: MEMBER
diff_hunk: @@ -107,6 +107,24 @@ func (m *Manager) GetStorage() endpoint.ResourceGroupStorage {
 	return m.storage
 }
 
+// GetKeyspaceServiceLimiter returns the service limit of the keyspace.
+func (m *Manager) GetKeyspaceServiceLimiter(keyspaceID uint32) *serviceLimiter {
+	krgm := m.getKeyspaceResourceGroupManager(keyspaceID)
path: pkg/mcs/resourcemanager/server/manager.go
user: login: JmPotato
body: Considering that a GET request should be idempotent, I did not modify the behavior of `GetKeyspaceServiceLimit`, but for `SetKeyspaceServiceLimit`, now if the corresponding keyspace manager does not exist, it will be created.
created_at: 2025-05-28T08:53:28Z
author_association: MEMBER
diff_hunk: @@ -107,6 +107,24 @@ func (m *Manager) GetStorage() endpoint.ResourceGroupStorage {
 	return m.storage
 }
 
+// GetKeyspaceServiceLimiter returns the service limit of the keyspace.
+func (m *Manager) GetKeyspaceServiceLimiter(keyspaceID uint32) *serviceLimiter {
+	krgm := m.getKeyspaceResourceGroupManager(keyspaceID)
path: pkg/mcs/resourcemanager/server/manager.go
user: login: glorv
body: I commented at the wrong place, it should be `SetKeyspaceServiceLimit `
created_at: 2025-05-28T09:45:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -116,6 +119,25 @@ func (m *Manager) GetStorage() endpoint.ResourceGroupStorage {
 	return m.storage
 }
 
+// GetKeyspaceServiceLimiter returns the service limit of the keyspace.
+func (m *Manager) GetKeyspaceServiceLimiter(keyspaceID uint32) *serviceLimiter {
+	krgm := m.getKeyspaceResourceGroupManager(keyspaceID)
+	if krgm == nil {
+		return nil
+	}
+	return krgm.getServiceLimiter().Clone()
+}
+
+// SetKeyspaceServiceLimit sets the service limit of the keyspace.
+func (m *Manager) SetKeyspaceServiceLimit(keyspaceID uint32, serviceLimit float64) {
+	// If the keyspace is not found, create a new keyspace resource group manager.
+	krgm := m.getOrCreateKeyspaceResourceGroupManager(keyspaceID)
+	if krgm == nil {
path: pkg/mcs/resourcemanager/server/manager.go
user: login: glorv
body: Seems this is an unreachable path as `getOrCreateKeyspaceResourceGroupManager` should never fail. If it can actually retrun `nil` value, I think you should return an error as `SetKeyspaceServiceLimit` is not successful here.
created_at: 2025-05-28T09:48:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +99,12 @@ func (s *Service) RegisterRouter() {
 	configEndpoint.DELETE("/group/:name", s.deleteResourceGroup)
 	configEndpoint.GET("/controller", s.getControllerConfig)
 	configEndpoint.POST("/controller", s.setControllerConfig)
+	// Without keyspace name, it will get/set the service limit of the null keyspace.
path: pkg/mcs/resourcemanager/server/apis/v1/api.go
user: login: rleungx
body: Does the null keyspace also have the service limit?
created_at: 2025-05-29T08:44:33Z
author_association: MEMBER
diff_hunk: @@ -239,3 +245,69 @@ func (s *Service) setControllerConfig(c *gin.Context) {
 	}
 	c.String(http.StatusOK, "Success!")
 }
+
+// KeyspaceServiceLimitRequest is the request body for setting the service limit of the keyspace.
+type KeyspaceServiceLimitRequest struct {
+	ServiceLimit float64 `json:"service_limit"`
+}
+
+// SetKeyspaceServiceLimit
+//
+//	@Tags		ResourceManager
+//	@Summary	Set the service limit of the keyspace. If the keyspace is valid, the service limit will be set.
+//	@Param		keyspace_name		path	string	true	"Keyspace name"
+//	@Param		service_limit	body		object	true	"json params, keyspaceServiceLimitRequest"
+//	@Success	200				{string}	string	"Success!"
+//	@Failure	400				{string}	error
+//	@Router		/config/keyspace/service-limit/{keyspace_name} [post]
+func (s *Service) setKeyspaceServiceLimit(c *gin.Context) {
+	keyspaceName := c.Param("keyspace_name")
+	keyspaceIDValue, err := s.manager.GetKeyspaceIDByName(c, keyspaceName)
+	if err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	if keyspaceIDValue == nil {
+		c.String(http.StatusNotFound, fmt.Sprintf("keyspace not found with name: %s", keyspaceName))
+		return
+	}
+	var req KeyspaceServiceLimitRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	if req.ServiceLimit < 0 {
+		c.String(http.StatusBadRequest, "service_limit must be non-negative")
+		return
+	}
+	s.manager.SetKeyspaceServiceLimit(keyspaceIDValue.GetValue(), req.ServiceLimit)
+	c.String(http.StatusOK, "Success!")
+}
+
+// GetKeyspaceServiceLimit
+//
+//	@Tags		ResourceManager
+//	@Summary	Get the service limit of the keyspace. If the keyspace name is empty, it will return the service limit of the null keyspace.
+//	@Param		keyspace_name	path		string	true	"Keyspace name"
+//	@Success	200				{string}	json	format	of	rmserver.serviceLimiter
+//	@Failure	400				{string}	error
path: pkg/mcs/resourcemanager/server/apis/v1/api.go
user: login: rleungx
body: 404 also needed
created_at: 2025-05-29T08:46:14Z
author_association: MEMBER
diff_hunk: @@ -239,3 +245,69 @@ func (s *Service) setControllerConfig(c *gin.Context) {
 	}
 	c.String(http.StatusOK, "Success!")
 }
+
+// KeyspaceServiceLimitRequest is the request body for setting the service limit of the keyspace.
+type KeyspaceServiceLimitRequest struct {
+	ServiceLimit float64 `json:"service_limit"`
+}
+
+// SetKeyspaceServiceLimit
+//
+//	@Tags		ResourceManager
+//	@Summary	Set the service limit of the keyspace. If the keyspace is valid, the service limit will be set.
+//	@Param		keyspace_name		path	string	true	"Keyspace name"
+//	@Param		service_limit	body		object	true	"json params, keyspaceServiceLimitRequest"
+//	@Success	200				{string}	string	"Success!"
+//	@Failure	400				{string}	error
+//	@Router		/config/keyspace/service-limit/{keyspace_name} [post]
+func (s *Service) setKeyspaceServiceLimit(c *gin.Context) {
+	keyspaceName := c.Param("keyspace_name")
+	keyspaceIDValue, err := s.manager.GetKeyspaceIDByName(c, keyspaceName)
+	if err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	if keyspaceIDValue == nil {
+		c.String(http.StatusNotFound, fmt.Sprintf("keyspace not found with name: %s", keyspaceName))
+		return
+	}
+	var req KeyspaceServiceLimitRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	if req.ServiceLimit < 0 {
+		c.String(http.StatusBadRequest, "service_limit must be non-negative")
+		return
+	}
+	s.manager.SetKeyspaceServiceLimit(keyspaceIDValue.GetValue(), req.ServiceLimit)
+	c.String(http.StatusOK, "Success!")
+}
+
+// GetKeyspaceServiceLimit
+//
+//	@Tags		ResourceManager
+//	@Summary	Get the service limit of the keyspace. If the keyspace name is empty, it will return the service limit of the null keyspace.
+//	@Param		keyspace_name	path		string	true	"Keyspace name"
+//	@Success	200				{string}	json	format	of	rmserver.serviceLimiter
+//	@Failure	400				{string}	error
+//	@Router		/config/keyspace/service-limit/{keyspace_name} [get]
+func (s *Service) getKeyspaceServiceLimit(c *gin.Context) {
+	keyspaceName := c.Param("keyspace_name")
+	keyspaceIDValue, err := s.manager.GetKeyspaceIDByName(c, keyspaceName)
+	if err != nil {
+		c.String(http.StatusBadRequest, err.Error())
+		return
+	}
+	if keyspaceIDValue == nil {
path: pkg/mcs/resourcemanager/server/apis/v1/api.go
user: login: rleungx
body: BTW, It seems this situation will never happen?
created_at: 2025-05-29T08:47:43Z
author_association: MEMBER
diff_hunk: @@ -99,6 +99,12 @@ func (s *Service) RegisterRouter() {
 	configEndpoint.DELETE("/group/:name", s.deleteResourceGroup)
 	configEndpoint.GET("/controller", s.getControllerConfig)
 	configEndpoint.POST("/controller", s.setControllerConfig)
+	// Without keyspace name, it will get/set the service limit of the null keyspace.
path: pkg/mcs/resourcemanager/server/apis/v1/api.go
user: login: JmPotato
body: I don't want to make the null keyspace too special within the system; also it's useful for the test's convenience.
created_at: 2025-05-29T08:47:53Z
author_association: MEMBER
diff_hunk: @@ -391,10 +411,50 @@ func (m *Manager) getKeyspaceNameByID(ctx context.Context, id uint32) (string, e
 		return "", fmt.Errorf("got an empty keyspace name by id %d", id)
 	}
 	// Update the cache.
+	m.Lock()
path: pkg/mcs/resourcemanager/server/manager.go
user: login: rleungx
body: How about using a separate function to avoid forgetting to update one of them?
created_at: 2025-05-29T08:50:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,137 @@
+// Copyright 2025 TiKV Project Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"math"
+	"time"
+
+	"github.com/tikv/pd/pkg/utils/syncutil"
+)
+
+// serviceLimiterBurstFactor defines how many seconds worth of tokens can be accumulated.
+// This allows the limiter to handle batch requests from clients that request tokens periodically.
+// Since the client will request tokens with a 5-second period by default, the burst factor is 5.0 here.
+const serviceLimiterBurstFactor = 5.0
+
+// TODO: persist the service limit to the storage and reload it when the service starts.
+type serviceLimiter struct {
+	syncutil.RWMutex
+	// ServiceLimit is the configured service limit for this limiter.
+	// It's an unburstable RU per second rate limit.
+	ServiceLimit float64 `json:"service_limit"`
+	// AvailableTokens tracks the available RU tokens for this limiter.
+	AvailableTokens float64 `json:"available_tokens"`
+	// LastUpdate records the last time the limiter was updated.
+	LastUpdate time.Time `json:"last_update"`
+}
+
+func newServiceLimiter(serviceLimit float64) *serviceLimiter {
+	// The service limit should be non-negative.
+	serviceLimit = math.Max(0, serviceLimit)
+	return &serviceLimiter{
+		ServiceLimit: serviceLimit,
+		LastUpdate:   time.Now(),
+	}
+}
+
+func (krl *serviceLimiter) setServiceLimit(newServiceLimit float64) {
+	// The service limit should be non-negative.
+	newServiceLimit = math.Max(0, newServiceLimit)
+	krl.Lock()
+	defer krl.Unlock()
+	if newServiceLimit == krl.ServiceLimit {
+		return
+	}
+	oldServiceLimit := krl.ServiceLimit
+	krl.ServiceLimit = newServiceLimit
+	now := time.Now()
+	// If the old service limit was 0 (no limit) or the new service limit is 0,
+	// initialize the available tokens or clear the available tokens.
+	if oldServiceLimit == 0 || newServiceLimit == 0 {
+		krl.AvailableTokens = 0
+		krl.LastUpdate = now
+	} else {
+		// Trigger the refill of the available tokens to ensure that the previous
+		// service limit setting was not too high, so that many available tokens
+		// are not left unused, causing the new service limit to become invalid.
+		krl.refillTokensLocked(now)
+	}
+}
+
+func (krl *serviceLimiter) refillTokensLocked(now time.Time) {
+	// No limit configured, do nothing.
+	if krl.ServiceLimit <= 0 {
+		return
+	}
+	// Calculate the elapsed time since the last update.
+	elapsed := now.Sub(krl.LastUpdate).Seconds()
+	if elapsed < 0 {
path: pkg/mcs/resourcemanager/server/service_limit.go
user: login: rleungx
body: Do we need a log here?
created_at: 2025-05-29T08:54:39Z
author_association: MEMBER
diff_hunk: @@ -79,6 +86,12 @@ func (krl *serviceLimiter) refillTokensLocked(now time.Time) {
 	// Calculate the elapsed time since the last update.
 	elapsed := now.Sub(krl.LastUpdate).Seconds()
 	if elapsed < 0 {
+		log.Warn("refill service limit tokens with negative elapsed time",
+			zap.Uint32("keyspace_id", krl.keyspaceID),
path: pkg/mcs/resourcemanager/server/service_limit.go
user: login: rleungx
body: Please use `-`
created_at: 2025-05-29T09:20:15Z
author_association: MEMBER
diff_hunk: @@ -99,6 +99,12 @@ func (s *Service) RegisterRouter() {
 	configEndpoint.DELETE("/group/:name", s.deleteResourceGroup)
 	configEndpoint.GET("/controller", s.getControllerConfig)
 	configEndpoint.POST("/controller", s.setControllerConfig)
+	// Without keyspace name, it will get/set the service limit of the null keyspace.
path: pkg/mcs/resourcemanager/server/apis/v1/api.go
user: login: glorv
body: +1
created_at: 2025-05-30T06:04:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,10 +411,51 @@ func (m *Manager) getKeyspaceNameByID(ctx context.Context, id uint32) (string, e
 		return "", fmt.Errorf("got an empty keyspace name by id %d", id)
 	}
 	// Update the cache.
-	m.keyspaceNameLookup[id] = loadedName
+	m.updateKeyspaceNameLookup(id, loadedName)
path: pkg/mcs/resourcemanager/server/manager.go
user: login: lhy1024
body: Is it possible to change the keyspace name? If keyspace is modified or deleted, do we need to update it?
created_at: 2025-05-30T08:51:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,10 +411,51 @@ func (m *Manager) getKeyspaceNameByID(ctx context.Context, id uint32) (string, e
 		return "", fmt.Errorf("got an empty keyspace name by id %d", id)
 	}
 	// Update the cache.
-	m.keyspaceNameLookup[id] = loadedName
+	m.updateKeyspaceNameLookup(id, loadedName)
path: pkg/mcs/resourcemanager/server/manager.go
user: login: JmPotato
body: Currently, once a keyspace is created, its ID and name cannot be changed. Therefore, we don’t need to consider cache misses at this stage.
created_at: 2025-05-30T09:12:22Z
author_association: MEMBER

PR comments:
user: login: ti-chi-bot[bot]
created_at: 2025-05-26T09:44:41Z
body: Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with `/test all`
author_association: CONTRIBUTOR
user: login: JmPotato
created_at: 2025-05-27T05:26:12Z
body: /cc @rleungx @lhy1024 @glorv 
author_association: MEMBER
user: login: ti-chi-bot[bot]
created_at: 2025-05-27T05:26:15Z
body: @JmPotato: GitHub didn't allow me to request PR reviews from the following users: glorv.

Note that only [tikv members](https://github.com/orgs/tikv/people) and repo collaborators can review this PR, and authors cannot review their own PRs.

<details>

In response to [this](https://github.com/tikv/pd/pull/9354#issuecomment-2911143858):

>/cc @rleungx @lhy1024 @glorv 


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-27T05:41:44Z
body: ## [Codecov](https://app.codecov.io/gh/tikv/pd/pull/9354?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tikv) Report
Attention: Patch coverage is `89.57346%` with `22 lines` in your changes missing coverage. Please review.
> Project coverage is 76.09%. Comparing base [(`3bc8b2c`)](https://app.codecov.io/gh/tikv/pd/commit/3bc8b2cab38e4ca28e6b6a9c80f7180eb8fea340?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tikv) to head [(`139cb8c`)](https://app.codecov.io/gh/tikv/pd/commit/139cb8c24f82a9e5599fdd0123fa34ba5fa5d1b4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tikv).
> Report is 1 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #9354      +/-   ##
==========================================
- Coverage   76.13%   76.09%   -0.05%     
==========================================
  Files         477      478       +1     
  Lines       74282    74482     +200     
==========================================
+ Hits        56557    56674     +117     
- Misses      14211    14281      +70     
- Partials     3514     3527      +13     
```

| [Flag](https://app.codecov.io/gh/tikv/pd/pull/9354/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tikv) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/tikv/pd/pull/9354/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tikv) | `76.09% <89.57%> (-0.05%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tikv#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: JmPotato
created_at: 2025-05-27T06:19:16Z
body: /hold
author_association: MEMBER
user: login: JmPotato
created_at: 2025-05-27T10:25:05Z
body: /unhold
author_association: MEMBER
user: login: ti-chi-bot[bot]
created_at: 2025-05-30T06:09:11Z
body: @glorv: adding LGTM is restricted to approvers and reviewers in OWNERS files.

<details>

In response to [this](https://github.com/tikv/pd/pull/9354#pullrequestreview-2880271456):

>LGTM


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
author_association: CONTRIBUTOR
user: login: ti-chi-bot[bot]
created_at: 2025-05-30T13:28:54Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/tikv/pd/pull/9354#pullrequestreview-2880271456" title="Approved">glorv</a>*, *<a href="https://github.com/tikv/pd/pull/9354#pullrequestreview-2881368864" title="Approved">lhy1024</a>*, *<a href="https://github.com/tikv/pd/pull/9354#pullrequestreview-2880357894" title="Approved">rleungx</a>*

The full list of commands accepted by this bot can be found [here](https://prow.tidb.net/command-help?repo=tikv%2Fpd).

The pull request process is described [here](https://book.prow.tidb.net/#/workflows/pr)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/tikv/pd/blob/master/OWNERS)~~ [lhy1024,rleungx]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: ti-chi-bot[bot]
created_at: 2025-05-30T13:28:54Z
body: [LGTM Timeline notifier]
---
Timeline:

- `2025-05-30 06:58:04.906756512 +0000 UTC m=+174905.968774124`: :ballot_box_with_check: agreed by [rleungx](https://github.com/rleungx).
- `2025-05-30 13:28:53.899265928 +0000 UTC m=+16420.101611192`: :ballot_box_with_check: agreed by [lhy1024](https://github.com/lhy1024).
author_association: CONTRIBUTOR